[geary/wip/714104-refine-account-dialog: 60/69] Implement ClientService for SMTP (3/3)



commit eb691ce7af8df056e3ce3ae8b4d14efe871e17eb
Author: Michael Gratton <mike vee net>
Date:   Sun Nov 18 23:10:11 2018 +1100

    Implement ClientService for SMTP (3/3)
    
    This splits off the sending part of SmtpOutboxFolder into a new
    Smtp.ClientService class that uses the standard Folder APIs for managing
    mail in the outbox, and makes SmtpOutboxFolder handle local mail
    CRUD operations only. This enables removing SmtpOutboxFolder from
    ImapDB.Account and managing it from GenericAccount instead.
    
    Part three of a three part series.

 po/POTFILES.in                                     |   1 +
 src/engine/imap-db/imap-db-account.vala            |  51 +-
 src/engine/imap-db/outbox/smtp-outbox-folder.vala  | 691 +++++----------------
 .../imap-engine/imap-engine-generic-account.vala   |  89 ++-
 src/engine/meson.build                             |   1 +
 src/engine/smtp/smtp-client-service.vala           | 421 +++++++++++++
 6 files changed, 652 insertions(+), 602 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index dd8898ca..85031de2 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -373,6 +373,7 @@ src/engine/rfc822/rfc822.vala
 src/engine/smtp/smtp-authenticator.vala
 src/engine/smtp/smtp-capabilities.vala
 src/engine/smtp/smtp-client-connection.vala
+src/engine/smtp/smtp-client-service.vala
 src/engine/smtp/smtp-client-session.vala
 src/engine/smtp/smtp-command.vala
 src/engine/smtp/smtp-data-format.vala
diff --git a/src/engine/imap-db/imap-db-account.vala b/src/engine/imap-db/imap-db-account.vala
index 85941990..6b1db5e6 100644
--- a/src/engine/imap-db/imap-db-account.vala
+++ b/src/engine/imap-db/imap-db-account.vala
@@ -51,7 +51,6 @@ private class Geary.ImapDB.Account : BaseObject {
         attachments_dir = user_data_dir.get_child(ATTACHMENTS_DIR);
     }
 
-
     private class FolderReference : Geary.SmartReference {
         public Geary.FolderPath path;
         
@@ -73,12 +72,9 @@ private class Geary.ImapDB.Account : BaseObject {
     private static Gee.HashMap<string, string> search_op_is_values =
         new Gee.HashMap<string, string>();
 
-    public signal void email_sent(Geary.RFC822.Message rfc822);
-
     public signal void contacts_loaded();
-    
+
     // Only available when the Account is opened
-    public SmtpOutboxFolder? outbox { get; private set; default = null; }
     public ImapEngine.ContactStore contact_store { get; private set; }
     public IntervalProgressMonitor search_index_monitor { get; private set; 
         default = new IntervalProgressMonitor(ProgressType.SEARCH_INDEX, 0, 0); }
@@ -86,12 +82,12 @@ private class Geary.ImapDB.Account : BaseObject {
         ProgressType.DB_UPGRADE); }
     public SimpleProgressMonitor vacuum_monitor { get; private set; default = new SimpleProgressMonitor(
         ProgressType.DB_VACUUM); }
-    public SimpleProgressMonitor sending_monitor { get; private set;
-        default = new SimpleProgressMonitor(ProgressType.ACTIVITY); }
-    
+
+    /** The backing database for the account. */
+    public ImapDB.Database? db { get; private set; default = null; }
+
     private string name;
     private AccountInformation account_information;
-    private ImapDB.Database? db = null;
     private Gee.HashMap<Geary.FolderPath, FolderReference> folder_refs =
         new Gee.HashMap<Geary.FolderPath, FolderReference>();
     private Cancellable? background_cancellable = null;
@@ -244,10 +240,10 @@ private class Geary.ImapDB.Account : BaseObject {
         search_op_is_values.set(SEARCH_OP_VALUE_UNREAD, SEARCH_OP_VALUE_UNREAD);
     }
 
-    public Account(Geary.AccountInformation account_information) {
-        this.account_information = account_information;
+    public Account(AccountInformation config) {
+        this.account_information = config;
         this.contact_store = new ImapEngine.ContactStore(this);
-        this.name = account_information.id + ":db";
+        this.name = config.id + ":db";
     }
 
     private void check_open() throws Error {
@@ -355,10 +351,6 @@ private class Geary.ImapDB.Account : BaseObject {
         });
         
         initialize_contacts(cancellable);
-        
-        // ImapDB.Account holds the Outbox, which is tied to the database it maintains
-        outbox = new SmtpOutboxFolder(db, account, sending_monitor);
-        outbox.email_sent.connect(on_outbox_email_sent);
     }
 
     public async void close_async(Cancellable? cancellable) throws Error {
@@ -376,13 +368,6 @@ private class Geary.ImapDB.Account : BaseObject {
         this.background_cancellable = null;
 
         this.folder_refs.clear();
-
-        this.outbox.email_sent.disconnect(on_outbox_email_sent);
-        this.outbox = null;
-    }
-
-    private void on_outbox_email_sent(Geary.RFC822.Message rfc822) {
-        email_sent(rfc822);
     }
 
     public async Folder clone_folder_async(Geary.Imap.Folder imap_folder,
@@ -1411,18 +1396,18 @@ private class Geary.ImapDB.Account : BaseObject {
      * would be empty.  Only throw database errors et al., not errors due to
      * the email id not being found.
      */
-    public async Gee.MultiMap<Geary.EmailIdentifier, Geary.FolderPath>? get_containing_folders_async(
-        Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable) throws Error {
+    public async void
+        get_containing_folders_async(Gee.Collection<Geary.EmailIdentifier> ids,
+                                     Gee.MultiMap<Geary.EmailIdentifier,FolderPath>? map,
+                                     GLib.Cancellable? cancellable)
+        throws GLib.Error {
         check_open();
-        
-        Gee.HashMultiMap<Geary.EmailIdentifier, Geary.FolderPath> map
-            = new Gee.HashMultiMap<Geary.EmailIdentifier, Geary.FolderPath>();
         yield db.exec_transaction_async(Db.TransactionType.RO, (cx, cancellable) => {
             foreach (Geary.EmailIdentifier id in ids) {
                 ImapDB.EmailIdentifier? imap_db_id = id as ImapDB.EmailIdentifier;
                 if (imap_db_id == null)
                     continue;
-                
+
                 Gee.Set<Geary.FolderPath>? folders = do_find_email_folders(
                     cx, imap_db_id.message_id, false, cancellable);
                 if (folders != null) {
@@ -1430,15 +1415,11 @@ private class Geary.ImapDB.Account : BaseObject {
                         Geary.FolderPath>(map, id, folders);
                 }
             }
-            
+
             return Db.TransactionOutcome.DONE;
         }, cancellable);
-        
-        yield outbox.add_to_containing_folders_async(ids, map, cancellable);
-        
-        return (map.size == 0 ? null : map);
     }
-    
+
     private async void populate_search_table_async(Cancellable? cancellable) {
         debug("%s: Populating search table", account_information.id);
         try {
diff --git a/src/engine/imap-db/outbox/smtp-outbox-folder.vala 
b/src/engine/imap-db/outbox/smtp-outbox-folder.vala
index 1215b287..c194a183 100644
--- a/src/engine/imap-db/outbox/smtp-outbox-folder.vala
+++ b/src/engine/imap-db/outbox/smtp-outbox-folder.vala
@@ -3,27 +3,17 @@
  * Copyright 2017-2018 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.
+ * (version 2.1 or later). See the COPYING file in this distribution.
  */
 
-// Special type of folder that runs an asynchronous send queue.  Messages are
-// saved to the database, then queued up for sending.
-//
-// The Outbox table is not currently maintained in its own database, so it must piggy-back
-// on the ImapDB.Database.  SmtpOutboxFolder assumes the database is opened before it's passed in
-// to the constructor -- it does not open or close the database itself and will start using it
-// immediately.
+/**
+ * Local folder for storing outgoing mail.
+ */
 private class Geary.SmtpOutboxFolder :
-    Geary.AbstractLocalFolder, Geary.FolderSupport.Remove, Geary.FolderSupport.Create {
-
-
-    // Min and max times between attempting to re-send after a connection failure.
-    private const uint MIN_SEND_RETRY_INTERVAL_SEC = 4;
-    private const uint MAX_SEND_RETRY_INTERVAL_SEC = 64;
-
-    // Time to wait before starting the postman for accounts to be
-    // loaded, connections to settle, pigs to fly, etc.
-    private const uint START_TIMEOUT = 4;
+    Geary.AbstractLocalFolder,
+    Geary.FolderSupport.Create,
+    Geary.FolderSupport.Mark,
+    Geary.FolderSupport.Remove {
 
 
     private class OutboxRow {
@@ -48,13 +38,6 @@ private class Geary.SmtpOutboxFolder :
     }
 
 
-    // Used solely for debugging, hence "(no subject)" not marked for translation
-    private static string message_subject(RFC822.Message message) {
-        return (message.subject != null && !String.is_empty(message.subject.to_string()))
-            ? message.subject.to_string() : "(no subject)";
-    }
-
-
     public override Account account { get { return this._account; } }
 
     public override FolderProperties properties { get { return _properties; } }
@@ -72,130 +55,47 @@ private class Geary.SmtpOutboxFolder :
         }
     }
 
-    private Endpoint smtp_endpoint {
-        get { return null; }
-    }
-
     private weak Account _account;
-    private ImapDB.Database db;
-
-    private Cancellable? queue_cancellable = null;
-    private Nonblocking.Queue<OutboxRow> outbox_queue = new Nonblocking.Queue<OutboxRow>.fifo();
-    private Geary.ProgressMonitor sending_monitor;
+    private weak ImapDB.Account local;
+    private Db.Database? db = null;
     private SmtpOutboxFolderProperties _properties = new SmtpOutboxFolderProperties(0, 0);
     private int64 next_ordering = 0;
 
-    private TimeoutManager start_timer;
-
-    /** Fired when an email has successfully been sent. */
-    public signal void email_sent(Geary.RFC822.Message rfc822);
-
-    /** Fired if a user-notifiable problem occurs. */
-    public signal void report_problem(ProblemReport report);
-
 
     // Requires the Database from the get-go because it runs a background task that access it
     // whether open or not
-    public SmtpOutboxFolder(ImapDB.Database db, Account account, Geary.ProgressMonitor sending_monitor) {
-        base();
+    public SmtpOutboxFolder(Account account, ImapDB.Account local) {
         this._account = account;
-        this._account.opened.connect(on_account_opened);
-        this._account.closed.connect(on_account_closed);
-        this.db = db;
-        this.sending_monitor = sending_monitor;
-        this.start_timer = new TimeoutManager.seconds(
-            START_TIMEOUT,
-            () => { this.start_postman_async.begin(); }
-        );
+        this.local = local;
     }
 
-    /**
-     * Starts delivery of messages in the outbox.
-     */
-    public async void start_postman_async() {
-        debug("Starting outbox postman with %u messages queued", this.outbox_queue.size);
-        if (this.queue_cancellable != null) {
-            debug("Postman already started, not starting another");
-            return;
+    public override async bool open_async(Geary.Folder.OpenFlags open_flags,
+                                          GLib.Cancellable? cancellable = null)
+        throws GLib.Error {
+        bool opened = yield base.open_async(open_flags, cancellable);
+        if (opened) {
+            this.db = this.local.db;
         }
+        return opened;
+    }
 
-        Cancellable cancellable = this.queue_cancellable = new Cancellable();
-        uint send_retry_seconds = MIN_SEND_RETRY_INTERVAL_SEC;
-
-        // Start the send queue.
-        while (!cancellable.is_cancelled()) {
-            // yield until a message is ready
-            OutboxRow? row = null;
-            bool row_handled = false;
-            try {
-                row = yield this.outbox_queue.receive(cancellable);
-                row_handled = yield postman_send(row, cancellable);
-            } catch (SmtpError err) {
-                ProblemType problem = ProblemType.GENERIC_ERROR;
-                if (err is SmtpError.AUTHENTICATION_FAILED) {
-                    problem = ProblemType.LOGIN_FAILED;
-                } else if (err is SmtpError.STARTTLS_FAILED) {
-                    problem = ProblemType.CONNECTION_ERROR;
-                } else if (err is SmtpError.NOT_CONNECTED) {
-                    problem = ProblemType.NETWORK_ERROR;
-                } else if (err is SmtpError.PARSE_ERROR ||
-                           err is SmtpError.SERVER_ERROR ||
-                           err is SmtpError.NOT_SUPPORTED) {
-                    problem = ProblemType.SERVER_ERROR;
-                }
-                notify_report_problem(problem, err);
-                cancellable.cancel();
-            } catch (IOError.CANCELLED err) {
-                // Nothing to do here — we're already cancelled. In
-                // particular we don't want to report the cancelled
-                // error as a problem since this is the normal
-                // shutdown method.
-            } catch (IOError err) {
-                notify_report_problem(ProblemType.for_ioerror(err), err);
-                cancellable.cancel();
-            } catch (Error err) {
-                notify_report_problem(ProblemType.GENERIC_ERROR, err);
-                cancellable.cancel();
-            }
-
-            if (row_handled) {
-                // send was good, reset nap length
-                send_retry_seconds = MIN_SEND_RETRY_INTERVAL_SEC;
-            } else {
-                // send was bad, try sending again later
-                if (row != null) {
-                    this.outbox_queue.send(row);
-                }
-
-                if (!cancellable.is_cancelled()) {
-                    debug("Outbox napping for %u seconds...", send_retry_seconds);
-                    // Take a brief nap before continuing to allow
-                    // connection problems to resolve.
-                    yield Geary.Scheduler.sleep_async(send_retry_seconds);
-                    send_retry_seconds = Geary.Numeric.uint_ceiling(
-                        send_retry_seconds * 2,
-                        MAX_SEND_RETRY_INTERVAL_SEC
-                    );
-                }
-            }
+    public override async bool close_async(GLib.Cancellable? cancellable = null)
+        throws GLib.Error {
+        bool closed = yield base.close_async(cancellable);
+        if (closed) {
+            this.db = null;
         }
-
-        this.queue_cancellable = null;
-        debug("Exiting outbox postman");
+        return closed;
     }
 
-    /**
-     * Queues a message in the outbox for delivery.
-     *
-     * This should be used instead of {@link create_email_async},
-     * since that requires the Outbox be open according to contract,
-     * but enqueuing emails for background delivery can happen at any
-     * time, so this is the mechanism to do so.
-     */
-    public async SmtpOutboxEmailIdentifier enqueue_email_async(Geary.RFC822.Message rfc822,
-        Cancellable? cancellable) throws Error {
-        debug("Queuing message for sending: %s",
-              (rfc822.subject != null) ? rfc822.subject.to_string() : "(no subject)");
+    public virtual async EmailIdentifier?
+        create_email_async(RFC822.Message rfc822,
+                           EmailFlags? flags,
+                           DateTime? date_received,
+                           EmailIdentifier? id = null,
+                           GLib.Cancellable? cancellable = null)
+        throws GLib.Error {
+        check_open();
 
         int email_count = 0;
         OutboxRow? row = null;
@@ -208,34 +108,18 @@ private class Geary.SmtpOutboxFolder :
             stmt.bind_string_buffer(0, rfc822.get_network_buffer(false));
             stmt.bind_int64(1, ordering);
 
-            int64 id = stmt.exec_insert(cancellable);
-
-            stmt = cx.prepare("SELECT message FROM SmtpOutboxTable WHERE id=?");
-            stmt.bind_rowid(0, id);
-
-            // This has got to work; Db should throw an exception if the INSERT failed
-            Db.Result results = stmt.exec(cancellable);
-            assert(!results.finished);
-
-            Memory.Buffer message = results.string_buffer_at(0);
-
+            int64 new_id = stmt.exec_insert(cancellable);
             int position = do_get_position_by_ordering(cx, ordering, cancellable);
 
-            row = new OutboxRow(id, position, ordering, false, message);
+            row = new OutboxRow(new_id, position, ordering, false, null);
             email_count = do_get_email_count(cx, cancellable);
 
             return Db.TransactionOutcome.COMMIT;
         }, cancellable);
 
-        // should have thrown an error if this failed
-        assert(row != null);
-
         // update properties
         _properties.set_total(yield get_email_count_async(cancellable));
 
-        // immediately add to outbox queue for delivery
-        outbox_queue.send(row);
-
         Gee.List<SmtpOutboxEmailIdentifier> list = new Gee.ArrayList<SmtpOutboxEmailIdentifier>();
         list.add(row.outbox_id);
 
@@ -246,30 +130,29 @@ private class Geary.SmtpOutboxFolder :
         return row.outbox_id;
     }
 
-    public async void add_to_containing_folders_async(Gee.Collection<Geary.EmailIdentifier> ids,
-        Gee.HashMultiMap<Geary.EmailIdentifier, Geary.FolderPath> map, Cancellable? cancellable) throws 
Error {
-        yield db.exec_transaction_async(Db.TransactionType.RO, (cx, cancellable) => {
-            foreach (Geary.EmailIdentifier id in ids) {
-                SmtpOutboxEmailIdentifier? outbox_id = id as SmtpOutboxEmailIdentifier;
-                if (outbox_id == null)
-                    continue;
-
-                OutboxRow? row = do_fetch_row_by_ordering(cx, outbox_id.ordering, cancellable);
-                if (row == null)
-                    continue;
-
-                map.set(id, path);
-            }
-
-            return Db.TransactionOutcome.DONE;
-        }, cancellable);
-    }
-
-    public virtual async Geary.EmailIdentifier? create_email_async(Geary.RFC822.Message rfc822, EmailFlags? 
flags,
-        DateTime? date_received, Geary.EmailIdentifier? id = null, Cancellable? cancellable = null) throws 
Error {
+    public virtual async void
+        mark_email_async(Gee.Collection<EmailIdentifier> to_mark,
+                                 EmailFlags? flags_to_add,
+                                 EmailFlags? flags_to_remove,
+                                 GLib.Cancellable? cancellable = null)
+        throws GLib.Error {
         check_open();
+        Gee.Map<EmailIdentifier,EmailFlags> changed =
+            new Gee.HashMap<EmailIdentifier,EmailFlags>();
+
+        foreach (EmailIdentifier id in to_mark) {
+            SmtpOutboxEmailIdentifier? outbox_id = id as SmtpOutboxEmailIdentifier;
+            if (outbox_id != null) {
+                yield db.exec_transaction_async(Db.TransactionType.WR, (cx) => {
+                        do_mark_email_as_sent(cx, outbox_id, cancellable);
+                        return Db.TransactionOutcome.COMMIT;
+                    }, cancellable
+                );
+                changed.set(id, flags_to_add);
+            }
+        }
 
-        return yield enqueue_email_async(rfc822, cancellable);
+        notify_email_flags_changed(changed);
     }
 
     public virtual async void
@@ -278,11 +161,36 @@ private class Geary.SmtpOutboxFolder :
         throws GLib.Error {
         check_open();
 
-        yield internal_remove_email_async(email_ids, cancellable);
-    }
+        Gee.List<Geary.EmailIdentifier> removed = new Gee.ArrayList<Geary.EmailIdentifier>();
+        int final_count = 0;
+        yield db.exec_transaction_async(Db.TransactionType.WR, (cx) => {
+            foreach (Geary.EmailIdentifier id in email_ids) {
+                // ignore anything not belonging to the outbox, but also don't report it as removed
+                // either
+                SmtpOutboxEmailIdentifier? outbox_id = id as SmtpOutboxEmailIdentifier;
+                if (outbox_id == null)
+                    continue;
+
+                // Even though we discard the new value here, this check must
+                // occur before any insert/delete on the table, to ensure we
+                // never reuse an ordering value while Geary is running.
+                do_get_next_ordering(cx, cancellable);
+
+                if (do_remove_email(cx, outbox_id, cancellable))
+                    removed.add(outbox_id);
+            }
 
-    public override Geary.Folder.OpenState get_open_state() {
-        return is_open() ? Geary.Folder.OpenState.LOCAL : Geary.Folder.OpenState.CLOSED;
+            final_count = do_get_email_count(cx, cancellable);
+
+            return Db.TransactionOutcome.COMMIT;
+        }, cancellable);
+
+        if (removed.size >= 0) {
+            _properties.set_total(final_count);
+
+            notify_email_removed(removed);
+            notify_email_count_changed(final_count, CountChangeReason.REMOVED);
+        }
     }
 
     public override async Gee.List<Geary.Email>? list_email_by_id_async(
@@ -299,6 +207,13 @@ private class Geary.SmtpOutboxFolder :
         if (count <= 0)
             return null;
 
+        bool list_all = (required_fields != Email.Field.NONE);
+
+        string select = "id, ordering";
+        if (list_all) {
+            select = select + ", message, sent";
+        }
+
         Gee.List<Geary.Email>? list = null;
         yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
             string dir = flags.is_newest_to_oldest() ? "DESC" : "ASC";
@@ -306,22 +221,22 @@ private class Geary.SmtpOutboxFolder :
             Db.Statement stmt;
             if (initial_id != null) {
                 stmt = cx.prepare("""
-                    SELECT id, ordering, message, sent
+                    SELECT %s
                     FROM SmtpOutboxTable
                     WHERE ordering >= ?
                     ORDER BY ordering %s
                     LIMIT ?
-                """.printf(dir));
+                """.printf(select ,dir));
                 stmt.bind_int64(0,
                     flags.is_including_id() ? initial_id.ordering : initial_id.ordering + 1);
                 stmt.bind_int(1, count);
             } else {
                 stmt = cx.prepare("""
-                    SELECT id, ordering, message, sent
+                    SELECT %s
                     FROM SmtpOutboxTable
                     ORDER BY ordering %s
                     LIMIT ?
-                """.printf(dir));
+                """.printf(select, dir));
                 stmt.bind_int(0, count);
             }
 
@@ -334,12 +249,23 @@ private class Geary.SmtpOutboxFolder :
             do {
                 int64 ordering = results.int64_at(1);
                 if (position == -1) {
-                    position = do_get_position_by_ordering(cx, ordering, cancellable);
+                    position = do_get_position_by_ordering(
+                        cx, ordering, cancellable
+                    );
                     assert(position >= 1);
                 }
 
-                list.add(row_to_email(new OutboxRow(results.rowid_at(0), position, ordering,
-                    results.bool_at(3), results.string_buffer_at(2))));
+                list.add(
+                    row_to_email(
+                        new OutboxRow(
+                            results.rowid_at(0),
+                            position,
+                            ordering,
+                            list_all ? results.bool_at(3) : false,
+                            list_all ? results.string_buffer_at(2) : null
+                        )
+                    )
+                );
                 position += flags.is_newest_to_oldest() ? -1 : 1;
                 assert(position >= 1);
             } while (results.next());
@@ -427,291 +353,49 @@ private class Geary.SmtpOutboxFolder :
         return row_to_email(row);
     }
 
-    // Returns true if row was successfully processed, else false
-    private async bool postman_send(OutboxRow row, Cancellable cancellable)
-        throws Error {
-        AccountInformation account = this.account.information;
-        bool mail_sent = !yield is_unsent_async(row.ordering, cancellable);
-
-        // Convert row into RFC822 message suitable for sending or framing
-        RFC822.Message message;
-        try {
-            message = new RFC822.Message.from_buffer(row.message);
-        } catch (RFC822Error msg_err) {
-            // TODO: This needs to be reported to the user
-            debug("Outbox postman message error: %s", msg_err.message);
-            return false;
-        }
-
-        if (!mail_sent) {
-            // Get SMTP password if we haven't loaded it yet and the account needs credentials.
-            // If the account needs a password but it's not set or incorrect in the keyring, we'll
-            // prompt below after getting an AUTHENTICATION_FAILED error.
-            yield this.account.information.load_smtp_credentials(cancellable);
-
-            // only try sending if (a) no TLS issues or (b) user has
-            // acknowledged them and says to continue
-            if (!this.smtp_endpoint.is_trusted_or_never_connected) {
-                return false;
-            }
-
-            // We immediately retry auth errors after the prompting
-            // the user, but if they get it wrong enough times or
-            // cancel we have no choice other than to stop the postman
-            uint attempts = 0;
-            while (!mail_sent && ++attempts <= Geary.Account.AUTH_ATTEMPTS_MAX) {
-                try {
-                    debug("Outbox postman: Sending \"%s\" (ID:%s)...",
-                          message_subject(message), row.outbox_id.to_string());
-                    yield send_email_async(message, cancellable);
-                    mail_sent = true;
-                } catch (Error send_err) {
-                    debug("Outbox postman send error: %s", send_err.message);
-                    if (send_err is SmtpError.AUTHENTICATION_FAILED) {
-                        if (attempts == Geary.Account.AUTH_ATTEMPTS_MAX) {
-                            throw send_err;
-                        }
-
-                        // At this point we may already have a
-                        // password in memory -- but it's incorrect.
-                        if (!yield account.prompt_smtp_credentials(cancellable)) {
-                            // The user cancelled and hence they don't
-                            // want to be prompted again, so bail out.
-                            throw send_err;
-                        }
-                    } else if (send_err is TlsError) {
-                        // up to application to be aware of problem
-                        // via Geary.Engine, but do nap and try later
-                        debug("TLS connection warnings connecting to %s, user must confirm connection to 
continue",
-                              this.smtp_endpoint.to_string());
-                        break;
-                    } else {
-                        // not much else we can do - just bail out
-                        throw send_err;
-                    }
-                }
-            }
-
-            // Mark as sent, so if there's a problem pushing up to
-            // Sent, we don't retry sending. Don't observe the
-            // cancellable here - if it's been sent we want to try to
-            // update the sent flag anyway
-            if (mail_sent) {
-                debug("Outbox postman: Marking %s as sent", row.outbox_id.to_string());
-                yield mark_email_as_sent_async(row.outbox_id, null);
-            }
-
-            if (!mail_sent || cancellable.is_cancelled()) {
-                // try again later
-                return false;
-            }
-        }
-
-        // If we get to this point, the message has either been just
-        // sent, or previously sent but not saved. So now try saving
-        // if needed.
-        if (account.allow_save_sent_mail() &&
-            account.save_sent_mail) {
-            try {
-                debug("Outbox postman: Saving %s to sent mail", row.outbox_id.to_string());
-                yield save_sent_mail_async(message, cancellable);
-            } catch (Error err) {
-                debug("Outbox postman: Error saving sent mail: %s", err.message);
-                notify_report_problem(ProblemType.SEND_EMAIL_SAVE_FAILED, err);
-                return false;
-            }
-        }
-
-        // Remove from database ... can't use remove_email_async()
-        // because this runs even if the outbox is closed as a
-        // Geary.Folder. Again, don't observe the cancellable here -
-        // if it's been send and saved we want to try to remove it
-        // anyway.
-        debug("Outbox postman: Deleting row %s", row.outbox_id.to_string());
-        Gee.ArrayList<SmtpOutboxEmailIdentifier> list = new Gee.ArrayList<SmtpOutboxEmailIdentifier>();
-        list.add(row.outbox_id);
-        yield internal_remove_email_async(list, null);
-
-        return true;
-    }
-
-    private void stop_postman() {
-        debug("Stopping outbox postman");
-        Cancellable? old_cancellable = this.queue_cancellable;
-        if (old_cancellable != null) {
-            old_cancellable.cancel();
-        }
-    }
-
-    // Fill the send queue with existing mail (if any)
-    private async void fill_outbox_queue() {
-        debug("Filling outbox queue");
-        try {
-            Gee.ArrayList<OutboxRow> list = new Gee.ArrayList<OutboxRow>();
-            yield db.exec_transaction_async(Db.TransactionType.RO, (cx, cancellable) => {
-                Db.Statement stmt = cx.prepare("""
-                    SELECT id, ordering, message
-                    FROM SmtpOutboxTable
-                    ORDER BY ordering
-                """);
-
-                Db.Result results = stmt.exec(cancellable);
-                int position = 1;
-                while (!results.finished) {
-                    list.add(new OutboxRow(results.rowid_at(0), position++, results.int64_at(1),
-                        false, results.string_buffer_at(2)));
-                    results.next(cancellable);
-                }
-
-                return Db.TransactionOutcome.DONE;
-            }, null);
+    internal async void
+        add_to_containing_folders_async(Gee.Collection<EmailIdentifier> ids,
+                                        Gee.MultiMap<EmailIdentifier,FolderPath> map,
+                                        GLib.Cancellable? cancellable)
+        throws GLib.Error {
+        check_open();
+        yield db.exec_transaction_async(Db.TransactionType.RO, (cx, cancellable) => {
+            foreach (Geary.EmailIdentifier id in ids) {
+                SmtpOutboxEmailIdentifier? outbox_id = id as SmtpOutboxEmailIdentifier;
+                if (outbox_id == null)
+                    continue;
 
-            if (list.size > 0) {
-                // set properties now (can't do yield in ctor)
-                _properties.set_total(list.size);
+                OutboxRow? row = do_fetch_row_by_ordering(cx, outbox_id.ordering, cancellable);
+                if (row == null)
+                    continue;
 
-                debug("Priming outbox postman with %d stored messages", list.size);
-                foreach (OutboxRow row in list)
-                    outbox_queue.send(row);
+                map.set(id, path);
             }
-        } catch (Error prime_err) {
-            warning("Error priming outbox: %s", prime_err.message);
-        }
-    }
-
-    // Utility for getting an email object back from an outbox row.
-    private Geary.Email row_to_email(OutboxRow row) throws Error {
-        RFC822.Message message = new RFC822.Message.from_buffer(row.message);
-
-        Geary.Email email = message.get_email(row.outbox_id);
-        // TODO: Determine message's total size (header + body) to store in Properties.
-        email.set_email_properties(new SmtpOutboxEmailProperties(new DateTime.now_local(), -1));
-        Geary.EmailFlags flags = new Geary.EmailFlags();
-        if (row.sent)
-            flags.add(Geary.EmailFlags.OUTBOX_SENT);
-        email.set_flags(flags);
-
-        return email;
-    }
-
-    private async bool is_unsent_async(int64 ordering, Cancellable? cancellable) throws Error {
-        bool exists = false;
-        yield db.exec_transaction_async(Db.TransactionType.RO, (cx) => {
-            Db.Statement stmt = cx.prepare(
-                "SELECT 1 FROM SmtpOutboxTable WHERE ordering=? AND sent = 0");
-            stmt.bind_int64(0, ordering);
-
-            exists = !stmt.exec(cancellable).finished;
 
             return Db.TransactionOutcome.DONE;
         }, cancellable);
-
-        return exists;
     }
 
-    private async void send_email_async(Geary.RFC822.Message rfc822, Cancellable? cancellable)
-        throws Error {
-        AccountInformation account = this._account.information;
-        Smtp.ClientSession smtp = new Geary.Smtp.ClientSession(this.smtp_endpoint);
-
-        sending_monitor.notify_start();
-
-        Error? smtp_err = null;
-        try {
-            yield smtp.login_async(account.get_smtp_credentials(), 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 configured for this account.
-                if (rfc822.from != null) {
-                    foreach (RFC822.MailboxAddress from in rfc822.from) {
-                        if (account.has_email_address(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 = 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;
-            }
-        }
+    // Utility for getting an email object back from an outbox row.
+    private Geary.Email row_to_email(OutboxRow row) throws Error {
+        Geary.Email? email = null;
 
-        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);
+        // If the row doesn't contain any message, just the id will do
+        if (row.message == null) {
+            email = new Email(row.outbox_id);
+        } else {
+            RFC822.Message message = new RFC822.Message.from_buffer(row.message);
+            email = message.get_email(row.outbox_id);
+
+            // TODO: Determine message's total size (header + body) to store in Properties.
+            email.set_email_properties(new SmtpOutboxEmailProperties(new DateTime.now_local(), -1));
+            Geary.EmailFlags flags = new Geary.EmailFlags();
+            if (row.sent)
+                flags.add(Geary.EmailFlags.OUTBOX_SENT);
+            email.set_flags(flags);
         }
 
-        sending_monitor.notify_finish();
-
-        if (smtp_err != null)
-            throw smtp_err;
-
-        email_sent(rfc822);
-    }
-
-    private async void mark_email_as_sent_async(SmtpOutboxEmailIdentifier outbox_id,
-        Cancellable? cancellable = null) throws Error {
-        yield db.exec_transaction_async(Db.TransactionType.WR, (cx) => {
-            do_mark_email_as_sent(cx, outbox_id, cancellable);
-
-            return Db.TransactionOutcome.COMMIT;
-        }, cancellable);
-
-        Geary.EmailFlags flags = new Geary.EmailFlags();
-        flags.add(Geary.EmailFlags.OUTBOX_SENT);
-
-        Gee.HashMap<Geary.EmailIdentifier, Geary.EmailFlags> changed_map
-            = new Gee.HashMap<Geary.EmailIdentifier, Geary.EmailFlags>();
-        changed_map.set(outbox_id, flags);
-        notify_email_flags_changed(changed_map);
-    }
-
-    private async void save_sent_mail_async(Geary.RFC822.Message rfc822, Cancellable? cancellable)
-        throws Error {
-        Geary.FolderSupport.Create? create = (yield _account.get_required_special_folder_async(
-            Geary.SpecialFolderType.SENT, cancellable)) as Geary.FolderSupport.Create;
-        if (create == null)
-            throw new EngineError.NOT_FOUND("Save sent mail enabled, but no writable sent mail folder");
-
-        bool open = false;
-        try {
-            yield create.open_async(Geary.Folder.OpenFlags.NONE, cancellable);
-            open = true;
-            yield create.create_email_async(rfc822, null, null, null, cancellable);
-        } finally {
-            if (open) {
-                try {
-                    yield create.close_async();
-                } catch (Error e) {
-                    debug("Error closing folder %s: %s", create.to_string(), e.message);
-                }
-            }
-        }
+        return email;
     }
 
     private async int get_email_count_async(Cancellable? cancellable) throws Error {
@@ -725,46 +409,6 @@ private class Geary.SmtpOutboxFolder :
         return count;
     }
 
-    // Like remove_email_async(), but can be called even when the folder isn't open
-    private async bool
-        internal_remove_email_async(Gee.Collection<Geary.EmailIdentifier> email_ids,
-                                    GLib.Cancellable? cancellable)
-        throws GLib.Error {
-        Gee.List<Geary.EmailIdentifier> removed = new Gee.ArrayList<Geary.EmailIdentifier>();
-        int final_count = 0;
-        yield db.exec_transaction_async(Db.TransactionType.WR, (cx) => {
-            foreach (Geary.EmailIdentifier id in email_ids) {
-                // ignore anything not belonging to the outbox, but also don't report it as removed
-                // either
-                SmtpOutboxEmailIdentifier? outbox_id = id as SmtpOutboxEmailIdentifier;
-                if (outbox_id == null)
-                    continue;
-
-                // Even though we discard the new value here, this check must
-                // occur before any insert/delete on the table, to ensure we
-                // never reuse an ordering value while Geary is running.
-                do_get_next_ordering(cx, cancellable);
-
-                if (do_remove_email(cx, outbox_id, cancellable))
-                    removed.add(outbox_id);
-            }
-
-            final_count = do_get_email_count(cx, cancellable);
-
-            return Db.TransactionOutcome.COMMIT;
-        }, cancellable);
-
-        if (removed.size == 0)
-            return false;
-
-        _properties.set_total(final_count);
-
-        notify_email_removed(removed);
-        notify_email_count_changed(final_count, CountChangeReason.REMOVED);
-
-        return true;
-    }
-
     //
     // Transaction helper methods
     //
@@ -831,7 +475,9 @@ private class Geary.SmtpOutboxFolder :
             results.string_buffer_at(1));
     }
 
-    private void do_mark_email_as_sent(Db.Connection cx, SmtpOutboxEmailIdentifier id, Cancellable? 
cancellable)
+    private void do_mark_email_as_sent(Db.Connection cx,
+                                       SmtpOutboxEmailIdentifier id,
+                                       Cancellable? cancellable)
         throws Error {
         Db.Statement stmt = cx.prepare("UPDATE SmtpOutboxTable SET sent = 1 WHERE ordering = ?");
         stmt.bind_int64(0, id.ordering);
@@ -847,49 +493,4 @@ private class Geary.SmtpOutboxFolder :
         return stmt.exec_get_modified(cancellable) > 0;
     }
 
-    private void notify_report_problem(ProblemType problem, Error? err) {
-        report_problem(
-            new ServiceProblemReport(
-                problem,
-                this._account.information,
-                this.account.information.smtp,
-                err
-            )
-        );
-    }
-
-    private void on_account_opened() {
-        this.fill_outbox_queue.begin();
-        this.smtp_endpoint.connectivity.notify["is-reachable"].connect(on_reachable_changed);
-        this.smtp_endpoint.connectivity.address_error_reported.connect(on_connectivity_error);
-        if (this.smtp_endpoint.connectivity.is_reachable.is_certain()) {
-            this.start_timer.start();
-        } else {
-            this.smtp_endpoint.connectivity.check_reachable.begin();
-        }
-    }
-
-    private void on_account_closed() {
-        this.start_timer.reset();
-        this.stop_postman();
-        this.smtp_endpoint.connectivity.notify["is-reachable"].disconnect(on_reachable_changed);
-        this.smtp_endpoint.connectivity.address_error_reported.disconnect(on_connectivity_error);
-    }
-
-    private void on_reachable_changed() {
-        if (this.smtp_endpoint.connectivity.is_reachable.is_certain()) {
-            if (this.queue_cancellable == null) {
-                this.start_timer.start();
-            }
-        } else {
-            this.start_timer.reset();
-            stop_postman();
-        }
-    }
-
-    private void on_connectivity_error(Error error) {
-        stop_postman();
-        notify_report_problem(ProblemType.CONNECTION_ERROR, error);
-    }
-
 }
diff --git a/src/engine/imap-engine/imap-engine-generic-account.vala 
b/src/engine/imap-engine/imap-engine-generic-account.vala
index 9a198788..969f4291 100644
--- a/src/engine/imap-engine/imap-engine-generic-account.vala
+++ b/src/engine/imap-engine/imap-engine-generic-account.vala
@@ -37,7 +37,7 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
     public Imap.ClientService imap  { get; private set; }
 
     /** Service for outgoing SMTP connections. */
-    public ClientService smtp { get; private set; }
+    public Smtp.ClientService smtp { get; private set; }
 
     /** Local database for the account. */
     public ImapDB.Account local { get; private set; }
@@ -75,20 +75,25 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
 
         this.local = local;
         this.local.contacts_loaded.connect(() => { contacts_loaded(); });
-        this.local.email_sent.connect(on_email_sent);
+
+        this.smtp = new Smtp.ClientService(
+            information, information.smtp, new SmtpOutboxFolder(this, this.local)
+        );
+        this.smtp.email_sent.connect(on_email_sent);
+        this.smtp.report_problem.connect(notify_report_problem);
+
+        this.sync = new AccountSynchronizer(this);
 
         this.refresh_folder_timer = new TimeoutManager.seconds(
             REFRESH_FOLDER_LIST_SEC,
             () => { this.update_remote_folders(); }
          );
 
-        search_upgrade_monitor = local.search_index_monitor;
-        db_upgrade_monitor = local.upgrade_monitor;
-        db_vacuum_monitor = local.vacuum_monitor;
-        opening_monitor = new Geary.ReentrantProgressMonitor(Geary.ProgressType.ACTIVITY);
-        sending_monitor = local.sending_monitor;
-
-        this.sync = new AccountSynchronizer(this);
+        this.opening_monitor = new ReentrantProgressMonitor(Geary.ProgressType.ACTIVITY);
+        this.sending_monitor = this.smtp.sending_monitor;
+        this.search_upgrade_monitor = local.search_index_monitor;
+        this.db_upgrade_monitor = local.upgrade_monitor;
+        this.db_vacuum_monitor = local.vacuum_monitor;
 
         compile_special_search_names();
     }
@@ -131,10 +136,11 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
                 throw err;
         }
 
-        // Local folders
+        // Create/load local folders
 
-        local.outbox.report_problem.connect(notify_report_problem);
-        local_only.set(new SmtpOutboxFolderRoot(), local.outbox);
+        local_only.set(
+            new SmtpOutboxFolderRoot(), this.smtp.outbox
+        );
 
         this.search_folder = new_search_folder();
         local_only.set(new ImapDB.SearchFolderRoot(), this.search_folder);
@@ -150,14 +156,30 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
         // To prevent spurious connection failures, we make sure we
         // have passwords before attempting a connection.
         yield this.information.load_imap_credentials(cancellable);
+        yield this.information.load_smtp_credentials(cancellable);
+
+        // Start the mail services. Start incoming directly, but queue
+        // outgoing so local folders can be loaded first in case
+        // queued mail gets sent and needs to get saved somewhere.
         yield this.imap.start(cancellable);
+        this.queue_operation(new StartPostie(this));
+
     }
 
     public override async void close_async(Cancellable? cancellable = null) throws Error {
         if (!open)
             return;
 
-        // Block obtaining and reusing IMAP server connections
+        // Stop attempting to send any outgoing messages
+        try {
+            yield this.smtp.stop();
+        } catch (Error err) {
+            debug(
+                "%s: Error stopping SMTP service: %s", to_string(), err.message
+            );
+        }
+
+        // Block obtaining and reusing IMAP connections
         this.remote_ready_lock.reset();
         this.imap.discard_returned_sessions = true;
 
@@ -201,7 +223,6 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
         // Close local infrastructure
 
         this.search_folder = null;
-        this.local.outbox.report_problem.disconnect(notify_report_problem);
         try {
             yield local.close_async(cancellable);
         } finally {
@@ -461,18 +482,18 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
     }
 
     public override async void send_email_async(Geary.ComposedEmail composed,
-        Cancellable? cancellable = null) throws Error {
+                                                GLib.Cancellable? cancellable = null)
+        throws GLib.Error {
         check_open();
 
         // TODO: we should probably not use someone else's FQDN in something
         // that's supposed to be globally unique...
         Geary.RFC822.Message rfc822 = new Geary.RFC822.Message.from_composed_email(
             composed, GMime.utils_generate_message_id(
-                null //information.smtp.endpoint.remote_address.hostname
+                this.smtp.endpoint.remote_address.hostname
             ));
 
-        // don't use create_email_async() as that requires the folder be open to use
-        yield local.outbox.enqueue_email_async(rfc822, cancellable);
+        yield this.smtp.queue_email(rfc822, cancellable);
     }
 
     private void on_email_sent(Geary.RFC822.Message rfc822) {
@@ -527,10 +548,16 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.Account {
         Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable = null) throws Error {
         return yield local.get_search_matches_async(query, check_ids(ids), cancellable);
     }
-    
-    public override async Gee.MultiMap<Geary.EmailIdentifier, Geary.FolderPath>? 
get_containing_folders_async(
-        Gee.Collection<Geary.EmailIdentifier> ids, Cancellable? cancellable) throws Error {
-        return yield local.get_containing_folders_async(ids, cancellable);
+
+    public override async Gee.MultiMap<EmailIdentifier,FolderPath>?
+        get_containing_folders_async(Gee.Collection<Geary.EmailIdentifier> ids,
+                                     GLib.Cancellable? cancellable)
+        throws GLib.Error {
+        Gee.MultiMap<EmailIdentifier,FolderPath> map =
+            new Gee.HashMultiMap<EmailIdentifier,FolderPath>();
+        yield this.local.get_containing_folders_async(ids, map, cancellable);
+        yield this.smtp.outbox.add_to_containing_folders_async(ids, map, cancellable);
+        return (map.size == 0) ? null : map;
     }
 
     internal override void set_endpoints(Endpoint incoming, Endpoint outgoing) {
@@ -1146,6 +1173,24 @@ internal class Geary.ImapEngine.LoadFolders : AccountOperation {
 }
 
 
+/**
+ * Account operation for starting the outgoing service.
+ */
+internal class Geary.ImapEngine.StartPostie : AccountOperation {
+
+
+    internal StartPostie(Account account) {
+        base(account);
+    }
+
+    public override async void execute(GLib.Cancellable cancellable)
+        throws GLib.Error {
+        yield this.account.outgoing.start(cancellable);
+    }
+
+}
+
+
 /**
  * Account operation that updates folders from the remote.
  */
diff --git a/src/engine/meson.build b/src/engine/meson.build
index 5f991e80..ca708932 100644
--- a/src/engine/meson.build
+++ b/src/engine/meson.build
@@ -280,6 +280,7 @@ geary_engine_vala_sources = files(
   'smtp/smtp-authenticator.vala',
   'smtp/smtp-capabilities.vala',
   'smtp/smtp-client-connection.vala',
+  'smtp/smtp-client-service.vala',
   'smtp/smtp-client-session.vala',
   'smtp/smtp-command.vala',
   'smtp/smtp-data-format.vala',
diff --git a/src/engine/smtp/smtp-client-service.vala b/src/engine/smtp/smtp-client-service.vala
new file mode 100644
index 00000000..25b5c00f
--- /dev/null
+++ b/src/engine/smtp/smtp-client-service.vala
@@ -0,0 +1,421 @@
+/*
+ * Copyright 2016 Software Freedom Conservancy Inc.
+ * Copyright 2018 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.
+ */
+internal class Geary.Smtp.ClientService : Geary.ClientService {
+
+    // Min and max times between attempting to re-send after a
+    // connection failure.
+    private const uint MIN_SEND_RETRY_INTERVAL_SEC = 4;
+    private const uint MAX_SEND_RETRY_INTERVAL_SEC = 64;
+
+
+    // Used solely for debugging, hence "(no subject)" not marked for translation
+    private static string message_subject(RFC822.Message message) {
+        return (message.subject != null && !String.is_empty(message.subject.to_string()))
+            ? message.subject.to_string() : "(no subject)";
+    }
+
+
+    /** Folder used for storing and retrieving queued mail. */
+    public SmtpOutboxFolder outbox { get; private set; }
+
+    /** Progress monitor indicating when email is being sent. */
+    public ProgressMonitor sending_monitor {
+        get;
+        private set;
+        default = new SimpleProgressMonitor(ProgressType.ACTIVITY);
+    }
+
+    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.RFC822.Message rfc822);
+
+    /** Emitted when an error occurred sending an email. */
+    public signal void report_problem(Geary.ProblemReport problem);
+
+
+    public ClientService(AccountInformation account,
+                         ServiceInformation service,
+                         SmtpOutboxFolder outbox) {
+        base(account, service);
+        this.outbox = outbox;
+    }
+
+    /**
+     * Starts the manager opening IMAP client sessions.
+     */
+    public override async void start(GLib.Cancellable? cancellable = null)
+        throws GLib.Error {
+        this.is_running = true;
+        yield this.outbox.open_async(Folder.OpenFlags.NONE, cancellable);
+        this.fill_outbox_queue.begin();
+        this.endpoint.connectivity.notify["is-reachable"].connect(
+            on_reachable_changed
+        );
+        this.endpoint.connectivity.address_error_reported.connect(
+            on_connectivity_error
+        );
+        this.endpoint.connectivity.check_reachable.begin();
+    }
+
+    /**
+     * Stops the manager running, closing any existing sessions.
+     */
+    public override async void stop(GLib.Cancellable? cancellable = null)
+        throws GLib.Error {
+        this.endpoint.connectivity.notify["is-reachable"].disconnect(
+            on_reachable_changed
+        );
+        this.endpoint.connectivity.address_error_reported.disconnect(
+            on_connectivity_error
+        );
+        this.stop_postie();
+        yield this.outbox.close_async(cancellable);
+        this.is_running = false;
+    }
+
+    /**
+     * Saves and queues an email in the outbox for delivery.
+     */
+    public async void queue_email(RFC822.Message rfc822,
+                                  GLib.Cancellable? cancellable)
+        throws GLib.Error {
+        debug("Queuing message for sending: %s", message_subject(rfc822));
+
+        EmailIdentifier id = yield this.outbox.create_email_async(
+            rfc822, null, null, null, cancellable
+        );
+        this.outbox_queue.send(id);
+    }
+
+    /**
+     * Loads any email in the outbox and adds them to the queue.
+     */
+    private async void fill_outbox_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,
+                this.queue_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);
+        }
+    }
+
+    /**
+     * 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();
+        uint send_retry_seconds = MIN_SEND_RETRY_INTERVAL_SEC;
+
+        // 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);
+                email_handled = yield process_email(id, cancellable);
+            } catch (SmtpError err) {
+                ProblemType problem = ProblemType.GENERIC_ERROR;
+                if (err is SmtpError.AUTHENTICATION_FAILED) {
+                    problem = ProblemType.LOGIN_FAILED;
+                } else if (err is SmtpError.STARTTLS_FAILED) {
+                    problem = ProblemType.CONNECTION_ERROR;
+                } else if (err is SmtpError.NOT_CONNECTED) {
+                    problem = ProblemType.NETWORK_ERROR;
+                } else if (err is SmtpError.PARSE_ERROR ||
+                           err is SmtpError.SERVER_ERROR ||
+                           err is SmtpError.NOT_SUPPORTED) {
+                    problem = ProblemType.SERVER_ERROR;
+                }
+                notify_report_problem(problem, err);
+                cancellable.cancel();
+            } catch (IOError.CANCELLED err) {
+                // Nothing to do here — we're already cancelled. In
+                // particular we don't want to report the cancelled
+                // error as a problem since this is the normal
+                // shutdown method.
+            } catch (IOError err) {
+                notify_report_problem(ProblemType.for_ioerror(err), err);
+                cancellable.cancel();
+            } catch (Error err) {
+                notify_report_problem(ProblemType.GENERIC_ERROR, err);
+                cancellable.cancel();
+            }
+
+            if (email_handled) {
+                // send was good, reset nap length
+                send_retry_seconds = MIN_SEND_RETRY_INTERVAL_SEC;
+            } else {
+                // send was bad, try sending again later
+                if (id != null) {
+                    this.outbox_queue.send(id);
+                }
+
+                if (!cancellable.is_cancelled()) {
+                    debug("Outbox napping for %u seconds...", send_retry_seconds);
+                    // Take a brief nap before continuing to allow
+                    // connection problems to resolve.
+                    yield Geary.Scheduler.sleep_async(send_retry_seconds);
+                    send_retry_seconds = Geary.Numeric.uint_ceiling(
+                        send_retry_seconds * 2,
+                        MAX_SEND_RETRY_INTERVAL_SEC
+                    );
+                }
+            }
+        }
+
+        this.queue_cancellable = null;
+        debug("Exiting outbox postie");
+    }
+
+    /**
+     * Stops delivery of messages in the queue.
+     */
+    private void stop_postie() {
+        debug("Stopping outbox postie");
+        Cancellable? old_cancellable = this.queue_cancellable;
+        if (old_cancellable != null) {
+            old_cancellable.cancel();
+        }
+    }
+
+    // Returns true if email was successfully processed, else false
+    private async bool process_email(EmailIdentifier id, Cancellable cancellable)
+        throws GLib.Error {
+        Email? email = null;
+        try {
+            email = yield this.outbox.fetch_email_async(
+                id, Email.Field.ALL, Folder.ListFlags.NONE, cancellable
+            );
+        } catch (EngineError.NOT_FOUND err) {
+            debug("Queued email %s not found in outbox, ignoring: %s",
+                  id.to_string(), err.message);
+            return true;
+        }
+
+        bool mail_sent = email.email_flags.contains(EmailFlags.OUTBOX_SENT);
+        if (!mail_sent) {
+            // We immediately retry auth errors after the prompting
+            // the user, but if they get it wrong enough times or
+            // cancel we have no choice other than to stop the postie
+            uint attempts = 0;
+            while (!mail_sent && ++attempts <= Geary.Account.AUTH_ATTEMPTS_MAX) {
+                RFC822.Message message = email.get_message();
+                try {
+                    debug("Outbox postie: Sending \"%s\" (ID:%s)...",
+                          message_subject(message), email.id.to_string());
+                    yield send_email(message, cancellable);
+                    mail_sent = true;
+                } catch (Error send_err) {
+                    debug("Outbox postie send error: %s", send_err.message);
+                    if (send_err is SmtpError.AUTHENTICATION_FAILED) {
+                        if (attempts == Geary.Account.AUTH_ATTEMPTS_MAX) {
+                            throw send_err;
+                        }
+
+                        // At this point we may already have a
+                        // password in memory -- but it's incorrect.
+                        if (!yield this.account.prompt_smtp_credentials(cancellable)) {
+                            // The user cancelled and hence they don't
+                            // want to be prompted again, so bail out.
+                            throw send_err;
+                        }
+                    } else {
+                        // not much else we can do - just bail out
+                        throw send_err;
+                    }
+                }
+            }
+
+            // 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
+            if (mail_sent) {
+                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 (!mail_sent || cancellable.is_cancelled()) {
+                // try again later
+                return false;
+            }
+        }
+
+        // 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 (this.account.allow_save_sent_mail() &&
+            this.account.save_sent_mail) {
+            try {
+                debug("Outbox postie: Saving %s to sent mail", email.id.to_string());
+                yield save_sent_mail_async(email, cancellable);
+            } catch (Error err) {
+                debug("Outbox postie: Error saving sent mail: %s", err.message);
+                notify_report_problem(ProblemType.SEND_EMAIL_SAVE_FAILED, err);
+                return false;
+            }
+        }
+
+        // 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);
+
+        return true;
+    }
+
+    private async void send_email(Geary.RFC822.Message rfc822, Cancellable? cancellable)
+        throws Error {
+        Smtp.ClientSession smtp = new Geary.Smtp.ClientSession(this.endpoint);
+
+        sending_monitor.notify_start();
+
+        Error? smtp_err = null;
+        try {
+            yield smtp.login_async(
+                this.account.get_smtp_credentials(), 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_email_address(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;
+
+        email_sent(rfc822);
+    }
+
+    private async void save_sent_mail_async(Geary.Email email,
+                                            GLib.Cancellable? cancellable)
+        throws GLib.Error {
+        Geary.FolderSupport.Create? create = (
+            yield this.owner.get_required_special_folder_async(
+                Geary.SpecialFolderType.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 message = email.get_message();
+        bool open = false;
+        try {
+            yield create.open_async(Geary.Folder.OpenFlags.NO_DELAY, cancellable);
+            open = true;
+            yield create.create_email_async(message, null, null, null, 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 void notify_report_problem(ProblemType problem, Error? err) {
+        report_problem(
+            new ServiceProblemReport(problem, this.account, this.service, err)
+        );
+    }
+
+    private void on_reachable_changed() {
+        if (this.endpoint.connectivity.is_reachable.is_certain()) {
+            start_postie.begin();
+        } else {
+            stop_postie();
+        }
+    }
+
+    private void on_connectivity_error(Error error) {
+        stop_postie();
+        notify_report_problem(ProblemType.CONNECTION_ERROR, error);
+    }
+
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]