/*
 * Copyright © 2016 Software Freedom Conservancy Inc.
 * Copyright © 2018, 2020 Michael Gratton <mike@vee.net>
 *
 * This software is licensed under the GNU Lesser General Public License
 * (version 2.1 or later). See the COPYING file in this distribution.
 */

/**
 * Manages connecting to an SMTP network service.
 *
 * This class maintains a queue of email messages to be delivered, and
 * opens SMTP connections to deliver queued messages as needed.
 */
public class Geary.Smtp.ClientService : Geary.ClientService {


    /** The GLib logging domain used for SMTP sub-system logging. */
    public const string LOGGING_DOMAIN = Logging.DOMAIN + ".Smtp";

    /** The GLib logging domain used for SMTP protocol logging. */
    public const string PROTOCOL_LOGGING_DOMAIN = Logging.DOMAIN + ".Smtp.Net";


    // Used solely for debugging, hence "(no subject)" not marked for
    // translation
    private static string email_subject(EmailHeaderSet email) {
        return (
            email.subject != null && !String.is_empty(email.subject.to_string()))
            ? email.subject.to_string()
            : "(no subject)";
    }


    /** Folder used for storing and retrieving queued mail. */
    public Outbox.Folder? outbox { get; internal set; default = null; }

    /** Progress monitor indicating when email is being sent. */
    public ProgressMonitor sending_monitor {
        get;
        private set;
        default = new SimpleProgressMonitor(ProgressType.ACTIVITY);
    }

    /** {@inheritDoc} */
    public override string logging_domain {
        get { return LOGGING_DOMAIN; }
    }

    private Account owner { get { return this.outbox.account; } }

    private Nonblocking.Queue<EmailIdentifier> outbox_queue =
        new Nonblocking.Queue<EmailIdentifier>.fifo();
    private Cancellable? queue_cancellable = null;

    /** Emitted when the manager has sent an email. */
    public signal void email_sent(Geary.Email email);

    /** Emitted when an error occurred sending an email. */
    public signal void report_problem(Geary.ProblemReport problem);


    public ClientService(AccountInformation account,
                         ServiceInformation service,
                         Endpoint remote) {
        base(account, service, remote);
    }

    /**
     * Starts the manager opening IMAP client sessions.
     */
    public override async void start(GLib.Cancellable? cancellable = null)
        throws GLib.Error {
        yield this.outbox.open_async(Folder.OpenFlags.NONE, cancellable);
        yield this.fill_outbox_queue(cancellable);
        notify_started();
    }

    /**
     * Stops the manager running, closing any existing sessions.
     */
    public override async void stop(GLib.Cancellable? cancellable = null)
        throws GLib.Error {
        notify_stopped();
        this.stop_postie();
        // Wait for the postie to actually stop before closing the
        // folder so w don't interrupt e.g. sending/saving/deleting
        // mail
        while (this.queue_cancellable != null) {
            GLib.Idle.add(this.stop.callback);
            yield;
        }
        yield this.outbox.close_async(cancellable);
    }

    /**
     * Saves and queues email for immediate delivery.
     *
     * This is a convenience method that calls {@link save_email} then
     * {@link queue_email} with the resulting id.
     */
    public async void send_email(Geary.ComposedEmail composed,
                                 GLib.Cancellable? cancellable)
        throws GLib.Error {
        queue_email(yield save_email(composed, cancellable));
    }

    /**
     * Saves a composed email in the outbox.
     *
     * This sets a suitable MessageID header for the message, then
     * saves the updated message in {@link outbox}. Returns the
     * identifier for the saved email, suitable for use with {@link
     * queue_email}.
     *
     * @see send_email
     */
    public async EmailIdentifier save_email(Geary.ComposedEmail composed,
                                            GLib.Cancellable? cancellable)
        throws GLib.Error {
        debug("Saving composed email: %s", email_subject(composed));

        // XXX work out what our public IP address is somehow and use
        // that in preference to the sender's domain
        string domain = composed.sender != null
            ? composed.sender.domain
            : this.account.primary_mailbox.domain;
        Geary.RFC822.Message rfc822 =
            yield new Geary.RFC822.Message.from_composed_email(
                composed, GMime.utils_generate_message_id(domain), cancellable
            );

        EmailIdentifier id = yield this.outbox.create_email_async(
            rfc822, null, null, cancellable
        );
        debug("Saved composed email as %s", id.to_string());
        return id;
    }

    /**
     * Queues an email for immediate delivery.
     *
     * The given identifier must be for {@link outbox}, for example as
     * given by {@link save_email}.
     *
     * @see send_email
     */
    public void queue_email(EmailIdentifier outbox_identifier) {
        debug("Queuing email for sending: %s", outbox_identifier.to_string());
        this.outbox_queue.send(outbox_identifier);
    }

    /** Starts the postie delivering messages. */
    protected override void became_reachable() {
        this.start_postie.begin();
    }

    /** Stops the postie delivering. */
    protected override void became_unreachable() {
        this.stop_postie();
    }

    /**
     * Starts delivery of messages in the queue.
     */
    private async void start_postie() {
        debug("Starting outbox postie with %u messages queued", this.outbox_queue.size);
        if (this.queue_cancellable != null) {
            return;
        }

        Cancellable cancellable = this.queue_cancellable =
            new GLib.Cancellable();

        // Start the send queue.
        while (!cancellable.is_cancelled()) {
            // yield until a message is ready
            EmailIdentifier id = null;
            bool email_handled = false;
            try {
                id = yield this.outbox_queue.receive(cancellable);
                yield process_email(id, cancellable);
                email_handled = true;
            } catch (SmtpError err) {
                if (err is SmtpError.AUTHENTICATION_FAILED) {
                    notify_authentication_failed();
                } else if (err is SmtpError.STARTTLS_FAILED ||
                           err is SmtpError.NOT_CONNECTED) {
                    notify_connection_failed(new ErrorContext(err));
                } else if (err is SmtpError.PARSE_ERROR ||
                           err is SmtpError.SERVER_ERROR ||
                           err is SmtpError.NOT_SUPPORTED) {
                    notify_unrecoverable_error(new ErrorContext(err));
                }
                cancellable.cancel();
            } catch (GLib.IOError.CANCELLED err) {
                // Nothing to do here — we're already cancelled.
            } catch (EngineError.NOT_FOUND err) {
                debug("Queued email %s not found in outbox, ignoring: %s",
                      id.to_string(), err.message);
            } catch (GLib.Error err) {
                notify_connection_failed(new ErrorContext(err));
                cancellable.cancel();
            }

            if (!email_handled && id != null) {
                // Send was bad, try sending again later
                this.outbox_queue.send(id);
            }
        }

        this.queue_cancellable = null;
        debug("Outbox postie exited");
    }

    /**
     * Stops delivery of messages in the queue.
     */
    private void stop_postie() {
        debug("Stopping outbox postie");
        if (this.queue_cancellable != null) {
            this.queue_cancellable.cancel();
        }
    }

    /**
     * Loads any email in the outbox and adds them to the queue.
     */
    private async void fill_outbox_queue(GLib.Cancellable cancellable) {
        debug("Filling queue");
        try {
            Gee.List<Email>? queued = yield this.outbox.list_email_by_id_async(
                null,
                int.MAX, // fetch all
                Email.Field.NONE, // ids only
                Folder.ListFlags.OLDEST_TO_NEWEST,
                cancellable
            );
            if (queued != null) {
                foreach (Email email in queued) {
                    this.outbox_queue.send(email.id);
                }
            }
        } catch (Error err) {
            warning("Error filling queue: %s", err.message);
        }
    }

    // Returns true if email was successfully processed, else false
    private async void process_email(EmailIdentifier id, Cancellable cancellable)
        throws GLib.Error {
        // To prevent spurious connection failures, ensure tokens are
        // up-to-date before attempting to send the email
        if (!yield this.account.load_outgoing_credentials(cancellable)) {
            throw new SmtpError.AUTHENTICATION_FAILED("Credentials not loaded");
        }

        Email email = yield this.outbox.fetch_email_async(
            id, Email.Field.ALL, Folder.ListFlags.NONE, cancellable
        );

        if (!email.email_flags.contains(EmailFlags.OUTBOX_SENT)) {
            RFC822.Message message = email.get_message();
            debug("Outbox postie: Sending \"%s\" (ID:%s)...",
                  email_subject(message), email.id.to_string());
            yield send_email_internal(message, cancellable);
            email_sent(email);

            // Mark as sent, so if there's a problem pushing up to
            // Sent, we don't retry sending. Don't pass the
            // cancellable here - if it's been sent we want to try to
            // update the sent flag anyway
            debug("Outbox postie: Marking %s as sent", email.id.to_string());
            Geary.EmailFlags flags = new Geary.EmailFlags();
            flags.add(Geary.EmailFlags.OUTBOX_SENT);
            yield this.outbox.mark_email_async(
                Collection.single(email.id), flags, null, null
            );

            if (cancellable.is_cancelled()) {
                throw new GLib.IOError.CANCELLED("Send has been cancelled");
            }
        }

        // If we get to this point, the message has either been just
        // sent, or previously sent but not saved. So now try flagging
        // as such and saving it if enabled, else sync the folder in
        // case the provider saved it so the new mail shows up.
        if (this.account.save_sent) {
            debug("Outbox postie: Saving %s to sent mail",
                  email.id.to_string());
            yield save_sent_mail(email, cancellable);
        } else {
            debug("Outbox postie: Syncing sent mail to find %s",
                  email.id.to_string());
            yield sync_sent_mail(email, cancellable);
        }

        // Again, don't observe the cancellable here - if it's been
        // send and saved we want to try to remove it anyway.
        debug("Outbox postie: Deleting row %s", email.id.to_string());
        yield this.outbox.remove_email_async(Collection.single(email.id), null);
    }

    private async void send_email_internal(Geary.RFC822.Message rfc822, Cancellable? cancellable)
        throws Error {
        Credentials? login = this.account.get_outgoing_credentials();
        if (login != null && !login.is_complete()) {
            throw new SmtpError.AUTHENTICATION_FAILED("Token not loaded");
        }

        Smtp.ClientSession smtp = new Geary.Smtp.ClientSession(this.remote);
        smtp.set_logging_parent(this);
        sending_monitor.notify_start();

        Error? smtp_err = null;
        try {
            yield smtp.login_async(login, cancellable);
        } catch (Error login_err) {
            debug("SMTP login error: %s", login_err.message);
            smtp_err = login_err;
        }

        if (smtp_err == null) {
            // Determine the SMTP reverse path, this gets used for
            // bounce notifications, etc. Use the sender by default,
            // since if specified the message is explicitly being sent
            // on behalf of someone else.
            RFC822.MailboxAddress? reverse_path = rfc822.sender;
            if (reverse_path == null) {
                // If no sender specified, use the first from address
                // that is accountured for this account.
                if (rfc822.from != null) {
                    foreach (RFC822.MailboxAddress from in rfc822.from) {
                        if (this.account.has_sender_mailbox(from)) {
                            reverse_path = from;
                            break;
                        }
                    }
                }

                if (reverse_path == null) {
                    // Fall back to using the account's primary
                    // mailbox if nether a sender nor a from address
                    // from this account is found.
                    reverse_path = this.account.primary_mailbox;
                }
            }

            // Now send it
            try {
                yield smtp.send_email_async(reverse_path, rfc822, cancellable);
            } catch (Error send_err) {
                debug("SMTP send mail error: %s", send_err.message);
                smtp_err = send_err;
            }
        }

        try {
            // always logout
            yield smtp.logout_async(false, null);
        } catch (Error err) {
            debug("Unable to disconnect from SMTP server %s: %s", smtp.to_string(), err.message);
        }

        sending_monitor.notify_finish();

        if (smtp_err != null)
            throw smtp_err;
    }

    private async void save_sent_mail(Geary.Email message,
                                      GLib.Cancellable? cancellable)
        throws GLib.Error {
        Geary.FolderSupport.Create? create = (
            yield this.owner.get_required_special_folder_async(
                SENT, cancellable
            )
        ) as Geary.FolderSupport.Create;
        if (create == null) {
            throw new EngineError.UNSUPPORTED(
                "Save sent mail enabled, but no writable sent mail folder"
            );
        }

        RFC822.Message raw = message.get_message();
        bool open = false;
        try {
            yield create.open_async(NO_DELAY, cancellable);
            open = true;
            yield create.create_email_async(raw, null, null, cancellable);
            yield wait_for_message(create, message, cancellable);
        } finally {
            if (open) {
                try {
                    yield create.close_async(null);
                } catch (Error e) {
                    debug("Error closing folder %s: %s", create.to_string(), e.message);
                }
            }
        }
    }

    private async void sync_sent_mail(Geary.Email message,
                                      GLib.Cancellable? cancellable)
        throws GLib.Error {
        Geary.Folder sent = this.owner.get_special_folder(SENT);
        if (sent != null) {
            bool open = false;
            try {
                yield sent.open_async(NO_DELAY, cancellable);
                open = true;
                yield sent.synchronise_remote(cancellable);
                yield wait_for_message(sent, message, cancellable);
            } finally {
                if (open) {
                    try {
                        yield sent.close_async(null);
                    } catch (Error e) {
                        debug("Error closing folder %s: %s",
                              sent.to_string(), e.message);
                    }
                }
            }
        }
    }

    // Wait for a sent message to turn up. There's no guarantee how or
    // when a server may make newly saved email show up, so poll for
    // it. :(
    private async void wait_for_message(Folder location,
                                        Email sent,
                                        GLib.Cancellable cancellable)
        throws GLib.Error {
        RFC822.MessageID? id = sent.message_id;
        if (id != null) {
            const int MAX_RETRIES = 3;
            for (int i = 0; i < MAX_RETRIES; i++) {
                Gee.List<Email>? list = yield location.list_email_by_id_async(
                    null, 1, REFERENCES, NONE, cancellable
                );
                if (list != null && !list.is_empty) {
                    Email listed = Collection.first(list);
                    if (listed.message_id != null &&
                        listed.message_id.equal_to(id)) {
                        break;
                    }
                }

                // Wait a second before retrying to give the server
                // some breathing room
                debug("Waiting for sent mail...");
                GLib.Timeout.add_seconds(1, wait_for_message.callback);
                yield;
            }
        }
    }

}
