Repository: mustang-im/mustang
Pull request #574: SMTP: Use mailAccount to link to IMAP/POP3 account - fixes #155
Author: login: benbucksch
Created: 2025-04-22T22:17:06Z | Merged: 2025-04-22T23:38:20Z

Part of #155. Followup to #573.

Linked issue #155: Dependent/main accounts
### Situation
When the user sets up an Office365, or Google, or Mustang account, the mail account also comes with address book sync, calendar sync, file sharing, and video conference. We are creating corresponding "accounts" in Mustang, one for each address book, one for each calendar etc.

### Problem

Their login is directly dependent on the login of the main mail account. I.e. if I didn't log in to the mail account, then saving a contact to the server will fail.

### Solution

#### Link

Create an explicit "dependent" and "main" account link. I.e. the address book has a "mainAccount" property which points to the mail account. And the mail account has a "dependentAccounts" array, which points to the address books, calendars, file sharing, video conference accounts.

#### isLoggedIn
The address book's `isLoggedIn` will simply return `this.mainAccount.isLoggedIn`. For that to work, the address book needs to subscribe to the main account changes on load (in the moment when the `mainAccount` property is set), so that listeners to the address book `isLoggedIn` state are updated as soon as the mail account login state changes.

#### Login
Likewise, the address book's `login()` function will simply delegate to `this.mainAccount.login()`.

When a contact needs to be saved to the server, and the main account is not logged in, the dependent account needs to trigger the login. Whether an interactive login is requested depends on whether the user himself started the modification directly, or whether it's a background process.

#### Save link on disk

The link needs to be saved on disk, and restored on load.
Linked issue comments: user: login: NeilRashbrook
created_at: 2024-07-23T15:35:55Z
body: `isLoggedIn` is a getter, not a state, so there are no notifications for it.
author_association: COLLABORATOR
user: login: benbucksch
created_at: 2024-07-23T16:04:00Z
body: Notifications are on the object level, not property level. The notifications happen for any object property which has the `notifyChangedProperty` decorator on it. So, if the `isLoggedIn` is based on a property of the object, the observer should be called.
author_association: COLLABORATOR
user: login: NeilRashbrook
created_at: 2024-07-23T16:27:17Z
body: `isLoggedIn` isn't a property (as I already said) so it wouldn't have the `notifyChangedProperty` decorator on it either. But even if it had the `notifyChangedAccessor` decorator, you wouldn't be able to change it, as there's no setter for it to override (the call to invoke the setter would throw; ideally `notifyChangedAccessor` would assert `decorator.set` exists so that you would notice your mistake earlier).
author_association: COLLABORATOR
user: login: benbucksch
created_at: 2024-07-23T16:34:00Z
body: I know. But:
* OWAAccount has `hasLoggedIn` (which was missing the `notifyChangedProperty`, but I added it recently). Likewise,
* `IMAPAccount` has the `_connection` property, which will be set on login and cleared on known connection failure.
* Same for `fatalError`.
* For `EWSAccount` with OAuth, we are actively subscribing to the `this.oAuth2` object when it's being set, so if `this.oAuth2.isLoggedIn` changes, its observers will fire, which then fires the observers of `EWSAccount`, which tells observers to update `isLoggedIn`. 
* All else failing, you can manually call `notifyObservers()` (see `Observable`). But it's typically better to make a property `this._isLoggedIn` or `connection` or other, with `notifyChangedProperty` decorator, and set that property on login and failure.

In other words, if the login state changes and the observers don't fire, that's an implementation error of the account. The account must ensure that the observers fire in this case.
author_association: COLLABORATOR
user: login: benbucksch
created_at: 2024-07-23T16:34:59Z
body: Here, in this case, the dependent account will be notified of login changes of the main account, because the following part of Solution takes care of it:
"For that to work, the address book needs to subscribe to the main account changes on load (in the moment when the mainAccount property is set), so that listeners to the address book isLoggedIn state are updated as soon as the mail account login state changes."
author_association: COLLABORATOR
user: login: benbucksch
created_at: 2024-10-05T19:06:04Z
body: #226 has a band-aid for one aspect of this which causes a bug.
author_association: COLLABORATOR
user: login: benbucksch
created_at: 2025-04-20T17:46:59Z
body: Step 1: PR #566
author_association: COLLABORATOR

Diff:
diff --git a/app/TODO.md b/app/TODO.md
index 590350d6a..f1e21cb1f 100644
--- a/app/TODO.md
+++ b/app/TODO.md
@@ -5,10 +5,6 @@
     * Results miss From - Include from/to (both content of messages, and name of recipients/sender)
       * Email
       * Chat
-  * Main/Dependent accounts
-    * Calendar
-    * SMTP
-    * OAuth2
   * Actions (Filters)
     * Run
   * Calendar
diff --git a/app/logic/Abstract/Account.ts b/app/logic/Abstract/Account.ts
index cc833c1a9..3193466ea 100644
--- a/app/logic/Abstract/Account.ts
+++ b/app/logic/Abstract/Account.ts
@@ -32,30 +32,22 @@ export class Account extends Observable {
   oAuth2: OAuth2 = null;
   @notifyChangedProperty
   password: string | null = null;
-  /**
-   * If the main account is loaded, this child account should be loaded automatically after.
-   * If the main account is deleted, then this account should be deleted as well.
-   * Child accounts share the OAuth2 login with their main accounts.
-   * If this is e.g. a calendar that belongs to an email account,
-   * this property references the email account.
-   * Also used for SMTP accounts to reference the IMAP or POP3 account.
-   */
-  mainAccount: Account | null = null;
-  /** Internal. Used only during load. */
-  _mainAccountID: string | null = null;
   @notifyChangedProperty
   workspace: Workspace | null = null;
   @notifyChangedProperty
   realname: string = appGlobal.me?.name;
+  /** @see `mainAccount` */
+  _mainAccount: Account | null = null;
+  /** Internal. Used only during load. */
+  _mainAccountID: string | null = null;
   @notifyChangedProperty
   acceptBrokenTLSCerts = false;
   @notifyChangedProperty
   loginOnStartup = true;
   /** Error that broke the server connection, unrecoverable, including login failures. */
-  fatalError: Error = null;
+  fatalError: Error | null = null;
   /** Non-fatal errors, including when processing a single email */
   readonly errors = new ArrayColl<Error>();
-
   /** Will be called, when there are errors on the connection
    * which cannot be attributed directly to an API function called,
    * e.g. errors while processing server messages. */
@@ -96,6 +88,20 @@ export class Account extends Observable {
   async logout(): Promise<void> {
   }
 
+  /**
+   * If the main account is loaded, this child account should be loaded automatically after.
+   * If the main account is deleted, then this account should be deleted as well.
+   * Child accounts share the OAuth2 login with their main accounts.
+   * If this is e.g. a calendar that belongs to an email account,
+   * this property references the email account.
+   * Also used for SMTP accounts to reference the IMAP or POP3 account.
+   */
+  get mainAccount(): Account {
+    return this._mainAccount;
+  }
+  set mainAccount(val: Account) {
+    this._mainAccount = val;
+  }
   dependentAccounts(): Collection<Account> {
     return getAllAccounts().filter(acc => acc.mainAccount == this);
   }
@@ -104,14 +110,18 @@ export class Account extends Observable {
   /** Saves the config in this account to disk.
    * Does not save the contents, e.g. messages. */
   async save(): Promise<void> {
-    throw new AbstractFunction();
+    for (let dependent of this.dependentAccounts()) {
+      await dependent.save();
+    }
   }
 
   /** Deletes this account from the configuration,
    * and likely deletes all local information from this account.
    * Does not delete the account on the server. */
   async deleteIt(): Promise<void> {
-    throw new AbstractFunction();
+    for (let dependent of this.dependentAccounts()) {
+      await dependent.deleteIt();
+    }
   }
 
   fromConfigJSON(json: any) {
@@ -123,7 +133,6 @@ export class Account extends Observable {
     this.url = sanitize.url(json.url, null);
     this.realname = sanitize.label(json.realname, appGlobal.me.name ?? "");
     this.name = sanitize.label(json.name, this.username);
-
     this.acceptBrokenTLSCerts = sanitize.boolean(json.acceptBrokenTLSCerts, false);
     this.loginOnStartup = sanitize.boolean(json.loginOnStartup, this.loginOnStartup);
     this.color = sanitize.nonemptystring(json.color, this.color);
diff --git a/app/logic/Calendar/Calendar.ts b/app/logic/Calendar/Calendar.ts
index 3fe9a32cc..e8a714a3b 100644
--- a/app/logic/Calendar/Calendar.ts
+++ b/app/logic/Calendar/Calendar.ts
@@ -31,10 +31,12 @@ export class Calendar extends Account {
   }
 
   async save(): Promise<void> {
+    await super.save();
     await this.storage?.saveCalendar(this);
   }
 
   async deleteIt(): Promise<void> {
+    await super.deleteIt();
     await this.storage?.deleteCalendar(this);
     appGlobal.calendars.remove(this);
   }
diff --git a/app/logic/Chat/ChatAccount.ts b/app/logic/Chat/ChatAccount.ts
index 6e5ce77f0..3b3319bc4 100644
--- a/app/logic/Chat/ChatAccount.ts
+++ b/app/logic/Chat/ChatAccount.ts
@@ -28,10 +28,12 @@ export class ChatAccount extends TCPAccount {
   }
 
   async save(): Promise<void> {
+    await super.save();
     await this.storage?.saveAccount(this);
   }
 
   async deleteIt(): Promise<void> {
+    await super.deleteIt();
     await this.storage?.deleteAccount(this);
     appGlobal.chatAccounts.remove(this);
   }
diff --git a/app/logic/Contacts/Addressbook.ts b/app/logic/Contacts/Addressbook.ts
index f77895795..89a63ef4c 100644
--- a/app/logic/Contacts/Addressbook.ts
+++ b/app/logic/Contacts/Addressbook.ts
@@ -21,10 +21,12 @@ export class Addressbook extends Account {
   }
 
   async save(): Promise<void> {
+    await super.save();
     await this.storage?.saveAddressbook(this);
   }
 
   async deleteIt(): Promise<void> {
+    await super.deleteIt();
     await this.storage?.deleteAddressbook(this);
     appGlobal.addressbooks.remove(this);
   }
diff --git a/app/logic/Mail/AccountsList/MailAccounts.ts b/app/logic/Mail/AccountsList/MailAccounts.ts
index 5d2da49b0..03b09bfb3 100644
--- a/app/logic/Mail/AccountsList/MailAccounts.ts
+++ b/app/logic/Mail/AccountsList/MailAccounts.ts
@@ -16,7 +16,6 @@ import { OWAAccount } from '../OWA/OWAAccount';
 import { ActiveSyncAccount } from '../ActiveSync/ActiveSyncAccount';
 import { GraphAccount } from '../Graph/GraphAccount';
 // #endif
-import { getAllAccounts } from '../../Abstract/Account';
 import { setStorage } from '../Store/setStorage';
 import { NotReached } from '../../util/util';
 import type { Collection } from 'svelte-collections';
diff --git a/app/logic/Mail/ActiveSync/ActiveSyncAccount.ts b/app/logic/Mail/ActiveSync/ActiveSyncAccount.ts
index 6fa5aa629..73d065590 100644
--- a/app/logic/Mail/ActiveSync/ActiveSyncAccount.ts
+++ b/app/logic/Mail/ActiveSync/ActiveSyncAccount.ts
@@ -441,8 +441,7 @@ export class ActiveSyncAccount extends MailAccount {
             break;
           case FolderType.Calendar:
           case FolderType.UserCalendar:
-            // TODO compare calendar ID
-            let calendar = appGlobal.calendars.find((calendar: ActiveSyncCalendar) => calendar.mainAccount == this) as ActiveSyncCalendar | null;
+            let calendar = appGlobal.calendars.find(calendar => calendar.mainAccount == this && calendar.url == url.toString()) as ActiveSyncCalendar | null;
             console.log("found the ActS cal again", calendar?.name);
             if (calendar) {
               calendar.name = change.DisplayName;
@@ -450,6 +449,7 @@ export class ActiveSyncAccount extends MailAccount {
               calendar = newCalendarForProtocol("calendar-activesync") as ActiveSyncCalendar;
               calendar.name = change.DisplayName;
               calendar.url = url.toString();
+              // TODO save calendar ID
               calendar.username = this.username;
               calendar.workspace = this.workspace;
               calendar.mainAccount = this;
@@ -458,8 +458,7 @@ export class ActiveSyncAccount extends MailAccount {
             break;
           case FolderType.Contacts:
           case FolderType.UserContacts:
-            // TODO compare addressbook ID
-            let addressbook = appGlobal.addressbooks.find((addressbook: ActiveSyncAddressbook) => addressbook.mainAccount == this) as ActiveSyncAddressbook | null;
+            let addressbook = appGlobal.addressbooks.find(addressbook => addressbook.mainAccount == this && addressbook.url == url.toString()) as ActiveSyncAddressbook | null;
             console.log("found the ActS AB again", addressbook?.name);
             if (addressbook) {
               addressbook.name = change.DisplayName;
@@ -467,6 +466,7 @@ export class ActiveSyncAccount extends MailAccount {
               addressbook = newAddressbookForProtocol("addressbook-activesync") as ActiveSyncAddressbook;
               addressbook.name = change.DisplayName;
               addressbook.url = url.toString();
+              // TODO save addressbook ID
               addressbook.username = this.username;
               addressbook.workspace = this.workspace;
               addressbook.mainAccount = this;
@@ -488,12 +488,12 @@ export class ActiveSyncAccount extends MailAccount {
           }
           let url = new URL(this.url);
           url.searchParams.set("serverID", deletion.ServerId);
-          let addressbook = appGlobal.addressbooks.find((addressbook: ActiveSyncAddressbook) => addressbook.protocol == "addressbook-activesync" && addressbook.url == url.toString() && addressbook.username == this.username) as ActiveSyncAddressbook | void;
+          let addressbook = appGlobal.addressbooks.find(addressbook => addressbook.mainAccount == this && addressbook.url == url.toString()) as ActiveSyncAddressbook | void;
           if (addressbook) {
             this.removePingable(addressbook);
             addressbook.deleteIt();
           }
-          let calendar = appGlobal.calendars.find((calendar: ActiveSyncCalendar) => calendar.protocol == "calendar-activesync" && calendar.url == url.toString() && calendar.username == this.username) as ActiveSyncCalendar | void;
+          let calendar = appGlobal.calendars.find(calendar => calendar.mainAccount == this && calendar.url == url.toString()) as ActiveSyncCalendar | void;
           if (calendar) {
             this.removePingable(calendar);
             calendar.deleteIt();
diff --git a/app/logic/Mail/AutoConfig/guessConfig.ts b/app/logic/Mail/AutoConfig/guessConfig.ts
index a99343aca..0442cbcac 100644
--- a/app/logic/Mail/AutoConfig/guessConfig.ts
+++ b/app/logic/Mail/AutoConfig/guessConfig.ts
@@ -4,6 +4,7 @@ import { SMTPAccount } from "../SMTP/SMTPAccount";
 import { AuthMethod } from "../../Abstract/Account";
 import { TLSSocketType } from "../../Abstract/TCPAccount";
 import { kStandardPorts } from "./configInfo";
+import { newAccountForProtocol } from "../AccountsList/MailAccounts";
 import { getMX } from "./fetchConfig";
 import { PriorityAbortable, ParallelAbortable } from "../../util/Abortable";
 import { getBaseDomainFromHost } from "../../util/netUtil";
@@ -88,11 +89,10 @@ async function tryMXDomain(domain: string, emailAddress: string, abort: AbortCon
 }
 
 async function tryIMAP(hostname: string, tls: TLSSocketType, abort: AbortController): Promise<IMAPAccount | POP3Account> {
-  let port = kStandardPorts.find(p => p.protocol == "imap" && p.tls == tls)?.port;
+  let port = kStandardPorts.find(p => p.protocol == "imap" && p.tls == tls)?.port ?? null;
   assert(port, "Need port");
-  const acceptBrokenTLSCerts = false;
 
-  let config = new IMAPAccount();
+  let config = newAccountForProtocol("imap");
   config.hostname = hostname;
   config.port = port;
   config.tls = tls;
@@ -115,11 +115,10 @@ async function tryIMAP(hostname: string, tls: TLSSocketType, abort: AbortControl
 }
 
 async function trySMTP(hostname: string, tls: TLSSocketType, port: number | null, abort: AbortController): Promise<SMTPAccount> {
-  port = port ?? kStandardPorts.find(p => p.protocol == "smtp" && p.tls == tls)?.port;
+  port = port ?? kStandardPorts.find(p => p.protocol == "smtp" && p.tls == tls)?.port ?? null;
   assert(port, "Need port");
-  const acceptBrokenTLSCerts = false;
 
-  let config = new SMTPAccount();
+  let config = newAccountForProtocol("smtp");
   config.hostname = hostname;
   config.port = port;
   config.tls = tls;
diff --git a/app/logic/Mail/AutoConfig/manualConfig.ts b/app/logic/Mail/AutoConfig/manualConfig.ts
index 8fe297170..a0d3268b3 100644
--- a/app/logic/Mail/AutoConfig/manualConfig.ts
+++ b/app/logic/Mail/AutoConfig/manualConfig.ts
@@ -1,12 +1,12 @@
 import type { MailAccount } from "../MailAccount";
+import type { SMTPAccount } from "../SMTP/SMTPAccount";
 import { AuthMethod } from "../../Abstract/Account";
-import { IMAPAccount } from "../IMAP/IMAPAccount";
-import { SMTPAccount } from "../SMTP/SMTPAccount";
+import { newAccountForProtocol } from "../AccountsList/MailAccounts";
 import { getDomainForEmailAddress } from "../../util/netUtil";
 
 export function makeManualConfig(emailAddress: string, password: string): MailAccount {
   let domain = getDomainForEmailAddress(emailAddress)
-  let config = new IMAPAccount();
+  let config = newAccountForProtocol("imap");
   config.emailAddress = emailAddress;
   config.username = emailAddress;
   config.password = password;
@@ -16,7 +16,7 @@ export function makeManualConfig(emailAddress: string, password: string): MailAc
   config.authMethod = AuthMethod.Password;
   config.source = "manual";
 
-  let outgoing = new SMTPAccount();
+  let outgoing = newAccountForProtocol("smtp") as any as SMTPAccount;
   outgoing.emailAddress = emailAddress;
   outgoing.username = emailAddress;
   outgoing.password = password;
diff --git a/app/logic/Mail/EWS/EWSAccount.ts b/app/logic/Mail/EWS/EWSAccount.ts
index c67f6bcdb..1ed17641d 100644
--- a/app/logic/Mail/EWS/EWSAccount.ts
+++ b/app/logic/Mail/EWS/EWSAccount.ts
@@ -63,7 +63,7 @@ export class EWSAccount extends MailAccount {
     await this.listFolders();
 
     // Link (until #155) or create the default address book.
-    // TODO: Support user-added address books.
+    // TODO: Support user-added address books. Compare addressbook ID.
     let addressbook = appGlobal.addressbooks.find(addressbook => addressbook.mainAccount == this) as EWSAddressbook | null;
     console.log("found the EWS AB again", addressbook?.name);
     if (!addressbook) {
@@ -78,7 +78,7 @@ export class EWSAccount extends MailAccount {
     await addressbook.listContacts();
 
     // Link (until #155) or create the default calendar.
-    // TODO: Support user-added calendars.
+    // TODO: Support user-added calendars. Compare calendar ID.
     let calendar = appGlobal.calendars.find(calendar => calendar.mainAccount == this) as EWSCalendar | null;
     console.log("found the EWS cal again", calendar?.name);
     if (!calendar) {
diff --git a/app/logic/Mail/IMAP/IMAPAccount.ts b/app/logic/Mail/IMAP/IMAPAccount.ts
index 38f16fc1a..c4e846577 100644
--- a/app/logic/Mail/IMAP/IMAPAccount.ts
+++ b/app/logic/Mail/IMAP/IMAPAccount.ts
@@ -331,9 +331,6 @@ export class IMAPAccount extends MailAccount {
     if (!this.isLoggedIn) {
       await this.login(true);
     }
-    // Assume that SMTP and IMAP server have the same oAuth2 login
-    this.outgoing.oAuth2 = this.oAuth2;
-
     await this.outgoing.send(email);
     await this.saveSent(email);
   };
diff --git a/app/logic/Mail/Import/Thunderbird/TBProfile.ts b/app/logic/Mail/Import/Thunderbird/TBProfile.ts
index aea6f062a..6c34ca337 100644
--- a/app/logic/Mail/Import/Thunderbird/TBProfile.ts
+++ b/app/logic/Mail/Import/Thunderbird/TBProfile.ts
@@ -59,7 +59,7 @@ export class ThunderbirdProfile {
         }
         if (account.username == smtp.username &&
             (account instanceof IMAPAccount || account instanceof POP3Account)) {
-          account.outgoing = smtp as any as MailAccount;
+          account.outgoing = smtp;
           smtp.id = account.id + "-" + smtp.id.replace("tb-", "");
         }
       }
@@ -181,7 +181,7 @@ export class ThunderbirdProfile {
         if (id) {
           let smtp = this.readSMTPServer(id);
           if (smtp) {
-            account.outgoing = smtp as any as MailAccount;
+            account.outgoing = smtp;
             smtp.id = sanitize.nonemptystring(account.id + "-" + smtp.id.replace("tb-", ""));
           }
         }
@@ -195,7 +195,7 @@ export class ThunderbirdProfile {
 
   readSMTPServer(serverID: string): SMTPAccount | null {
     try {
-      let acc = new SMTPAccount();
+      let acc = newAccountForProtocol("smtp") as any as SMTPAccount;
       acc.id = "tb-" + serverID;
       let prefBranch = `mail.smtpserver.${serverID}`;
       acc.hostname = this.prefs[`${prefBranch}.hostname`];
diff --git a/app/logic/Mail/MailAccount.ts b/app/logic/Mail/MailAccount.ts
index 34c225f64..7462b08fe 100644
--- a/app/logic/Mail/MailAccount.ts
+++ b/app/logic/Mail/MailAccount.ts
@@ -2,6 +2,7 @@ import { TCPAccount } from "../Abstract/TCPAccount";
 import { MailIdentity } from "./MailIdentity";
 import { Folder, SpecialFolder } from "./Folder";
 import type { EMail } from "./EMail";
+import type { SMTPAccount } from "./SMTP/SMTPAccount";
 import { Event } from "../Calendar/Event";
 import { Participant } from "../Calendar/Participant";
 import { InvitationResponse, type InvitationResponseInMessage } from "../Calendar/Invitation";
@@ -20,11 +21,7 @@ export class MailAccount extends TCPAccount {
   /** SMTP server
    * Only set for IMAP and POP3, but null for JMAP, Exchange etc. */
   @notifyChangedProperty
-  outgoing: MailAccount = null;
-  /** Only for reading from DB. SMTP server ID. */
-  outgoingAccountID: string | null = null;
-  /** Error that broke the server connection, unrecoverable, including login failures. */
-  fatalError: Error | null = null;
+  outgoing: SMTPAccount = null;
   spamStrategy: DeleteStrategy = DeleteStrategy.MoveToTrash;
   protected _inbox: Folder;
   /** Where we got the config from, during setup */
@@ -118,13 +115,16 @@ export class MailAccount extends TCPAccount {
   }
 
   async save(): Promise<void> {
+    if (this.outgoing) {
+      this.outgoing.mainAccount = this;
+    }
+    console.log("mail account save", this.protocol, this.name, this, "outgoing", this.outgoing, "mainaccount", this.mainAccount, "storage", this.storage);
+    await super.save();
     await this.storage?.saveAccount(this);
   }
 
   async deleteIt(): Promise<void> {
-    if (this.outgoing) {
-      await this.storage?.deleteAccount(this.outgoing);
-    }
+    await super.deleteIt();
     await this.storage?.deleteAccount(this);
     appGlobal.emailAccounts.remove(this);
   }
@@ -152,7 +152,6 @@ export class MailAccount extends TCPAccount {
   fromConfigJSON(json: any) {
     super.fromConfigJSON(json);
     this.emailAddress = sanitize.emailAddress(json.emailAddress);
-    this.outgoingAccountID = sanitize.alphanumdash(json.outgoingAccountID, null);
     this.identities.clear();
     this.identities.addAll(sanitize.array(json.identities, []).map(json =>
       MailIdentity.fromConfigJSON(json, this)));
@@ -179,7 +178,6 @@ export class MailAccount extends TCPAccount {
     json.identities = this.identities.contents.map(id => id.toConfigJSON());
     json.filterRuleActions = this.filterRuleActions.contents.map(rule => rule.toJSON());
     json.oAuth2 = this.oAuth2 ? this.oAuth2.toConfigJSON() : undefined;
-    json.outgoingAccountID = this.outgoing?.id;
     json.emailAddress = this.emailAddress;
     return json;
   }
diff --git a/app/logic/Mail/OWA/OWAAccount.ts b/app/logic/Mail/OWA/OWAAccount.ts
index 6599123b3..839fac613 100644
--- a/app/logic/Mail/OWA/OWAAccount.ts
+++ b/app/logic/Mail/OWA/OWAAccount.ts
@@ -106,7 +106,7 @@ export class OWAAccount extends MailAccount {
     this.hasLoggedIn = true;
 
     // Link (until #155) or create the default address book.
-    // TODO: Support user-added address books.
+    // TODO: Support user-added address books. Compare addressbook ID.
     let addressbook = appGlobal.addressbooks.find(addressbook => addressbook.mainAccount == this) as OWAAddressbook | null;
     console.log("found the OWA AB again", addressbook?.name);
     if (!addressbook) {
@@ -120,8 +120,7 @@ export class OWAAccount extends MailAccount {
     await addressbook.listContacts();
 
     // Link (until #155) or create the default calendar.
-    // TODO: Support user-added calendars.
-    // TODO: Compare ID
+    // TODO: Support user-added calendars. Compare calendar ID.
     let calendar = appGlobal.calendars.find(calendar => calendar.mainAccount == this) as OWACalendar | null;
     console.log("found the OWA cal again", calendar?.name);
     if (!calendar) {
diff --git a/app/logic/Mail/POP3/POP3Account.ts b/app/logic/Mail/POP3/POP3Account.ts
index 9e67243b5..a0814c86c 100644
--- a/app/logic/Mail/POP3/POP3Account.ts
+++ b/app/logic/Mail/POP3/POP3Account.ts
@@ -14,6 +14,9 @@ export class POP3Account extends MailAccount {
 
   async send(email: EMail): Promise<void> {
     assert(this.outgoing, "SMTP server is not set up for POP3 account " + this.name);
+    if (this.oAuth2 && !this.oAuth2.isLoggedIn) {
+      await this.oAuth2.login(true);
+    }
     await this.outgoing.send(email);
     await this.saveSent(email);
   };
diff --git a/app/logic/Mail/SMTP/SMTPAccount.ts b/app/logic/Mail/SMTP/SMTPAccount.ts
index 5c8150140..47693cb5c 100644
--- a/app/logic/Mail/SMTP/SMTPAccount.ts
+++ b/app/logic/Mail/SMTP/SMTPAccount.ts
@@ -9,6 +9,14 @@ import { assert } from "../../util/util";
 export class SMTPAccount extends MailAccount {
   readonly protocol: string = "smtp";
 
+  get mainAccount(): MailAccount {
+    return super.mainAccount as MailAccount;
+  }
+  set mainAccount(imap: MailAccount) {
+    super.mainAccount = imap;
+    imap.outgoing = this;
+  }
+
   protected getTransportOptions() {
     // Auth method
     let usePassword = [
@@ -20,7 +28,9 @@ export class SMTPAccount extends MailAccount {
       AuthMethod.OAuth2,
     ].includes(this.authMethod);
     if (useOAuth2) {
-      assert(this.oAuth2?.accessToken, `${this.name} SMTP: Need OAuth2 login from IMAP`);
+      assert(this.mainAccount, `${this.name} SMTP: Need main account`);
+      this.oAuth2 = this.mainAccount.oAuth2;
+      assert(this.oAuth2?.accessToken, `${this.mainAccount.name} SMTP: Need OAuth2 login from IMAP`);
     }
     let noAuth = this.authMethod == AuthMethod.None;
 
@@ -78,11 +88,4 @@ export class SMTPAccount extends MailAccount {
       throw ex;
     }
   }
-
-  fromConfigJSON(json: any) {
-    super.fromConfigJSON(json);
-    if (this.mainAccount) {
-      (this.mainAccount as MailAccount).outgoing = this;
-    }
-  }
 }
diff --git a/app/logic/Mail/SQL/Account/SQLAccount.ts b/app/logic/Mail/SQL/Account/SQLAccount.ts
index 4e7ccbe5c..5eb50c4c8 100644
--- a/app/logic/Mail/SQL/Account/SQLAccount.ts
+++ b/app/logic/Mail/SQL/Account/SQLAccount.ts
@@ -8,10 +8,6 @@ import sql from "../../../../../lib/rs-sqlite";
 
 export class SQLAccount {
   static async save(acc: Account, type: AccountType) {
-    if (acc instanceof MailAccount && acc.outgoing) {
-      acc.outgoing.emailAddress ??= acc.emailAddress;
-      await SQLAccount.save(acc.outgoing as any as Account, type);
-    }
     let json = acc.toConfigJSON();
     json.passwordEncrypted = await passwordEncrypt(acc.password);
     // acc.oAuth2.refreshToken changes every few minutes, so should not save in configJSON
@@ -46,9 +42,6 @@ export class SQLAccount {
   /** Also deletes all folders and messages in this account */
   static async deleteIt(account: Account) {
     assert(account.dbID, "Need account DB ID to delete");
-    for (let dependent of account.dependentAccounts()) {
-      await dependent.deleteIt();
-    }
     await (await getDatabase()).run(sql`
       DELETE FROM account
       WHERE idStr = ${account.id}
diff --git a/app/logic/Mail/SQL/SQLMailAccount.ts b/app/logic/Mail/SQL/SQLMailAccount.ts
index 5edfd447c..ad17131a7 100644
--- a/app/logic/Mail/SQL/SQLMailAccount.ts
+++ b/app/logic/Mail/SQL/SQLMailAccount.ts
@@ -4,17 +4,11 @@ import { AccountType, SQLAccount, type AccountDBRow } from "./Account/SQLAccount
 import { newAccountForProtocol } from "../AccountsList/MailAccounts";
 import { SQLMailStorage } from "./SQLMailStorage";
 import { backgroundError } from "../../../frontend/Util/error";
-import { sanitize } from "../../../../lib/util/sanitizeDatatypes";
 import { ArrayColl } from "svelte-collections";
 import sql from "../../../../lib/rs-sqlite";
 
 export class SQLMailAccount {
   static async save(acc: MailAccount) {
-    if (acc.outgoing) {
-      acc.outgoing.emailAddress ??= acc.emailAddress;
-      await SQLMailAccount.save(acc.outgoing);
-    }
-
     await SQLAccount.save(acc, AccountType.Mail);
 
     if (!acc.dbID) {
@@ -85,13 +79,10 @@ export class SQLMailAccount {
         backgroundError(ex);
       }
     }
-    // Set SMTP accounts
-    for (let account of accounts) {
-      let id = account.outgoingAccountID;
-      delete account.outgoingAccountID;
-      if (id) {
-        account.outgoing = smtpAccounts.find(acc => acc.id == id);
-      }
+    // Need to special-case it here, because SMTP accounts
+    // aren't in `appGlobal.emailAccounts`, so `setMainAccounts()` won't find them.
+    for (let smtp of smtpAccounts) {
+      smtp.mainAccount = accounts.find(acc => acc.id == smtp._mainAccountID);
     }
     if (accounts.isEmpty) {
       await getDatabase(); // for migration only
diff --git a/app/logic/Meet/MeetAccount.ts b/app/logic/Meet/MeetAccount.ts
index 09533abf6..6973ab167 100644
--- a/app/logic/Meet/MeetAccount.ts
+++ b/app/logic/Meet/MeetAccount.ts
@@ -26,10 +26,12 @@ export class MeetAccount extends Account {
   }
 
   async save(): Promise<void> {
+    await super.save();
     await this.storage?.saveAccount(this);
   }
 
   async deleteIt(): Promise<void> {
+    await super.deleteIt();
     await this.storage?.deleteAccount(this);
     appGlobal.meetAccounts.remove(this);
   }


Reviews:
user: login: NeilRashbrook
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T22:30:30Z
user: login: benbucksch
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T22:35:00Z
user: login: benbucksch
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T23:10:17Z
user: login: NeilRashbrook
body: I had originally set up an EWS account using a previous version of this patch (commit 3c5aa46), which continued to work, however for rest of the review I used master (commit 844ab27).

When I tried to create an OWA account I got "FOREIGN KEY constraint failed". After cancelling setup I noticed that the calendar and addressbook had already been added, although the mail account had not actually been saved at this point.

(I was also confused by there being two logins, one using the EWS/ActiveSync webview, one using the OWA popup.)

I was then able to create an ActiveSync account, which seemed to be working normally, e.g. all of the calendars were discovered as expected.

I then tried to create an IMAP account and also a second EWS account but each time I also got "FOREIGN KEY constraint failed". In the case of the EWS account a phantom address book had been created (I couldn't delete it but it went away after I restarted).

I was then able to create a second ActiveSync account, which also seemed to be working normally.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T21:26:54Z
user: login: NeilRashbrook
body: I've since found that ActiveSync works because it has a custom `verifyLogin` which doesn't try to create and save any dependent accounts, but EWS and OWA fail because `verifyLogin` just defaults to calling `login(true);` which tries to create dependent addressbook and calendar accounts, and this fails because the main account hasn't been saved yet. (Presumably under the old code, these accounts would simply be saved, and would linger if you cancelled account setup at the last second.)
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T18:59:52Z

Review comments:
diff_hunk: @@ -441,15 +441,15 @@ export class ActiveSyncAccount extends MailAccount {
             break;
           case FolderType.Calendar:
           case FolderType.UserCalendar:
-            // TODO compare calendar ID
-            let calendar = appGlobal.calendars.find((calendar: ActiveSyncCalendar) => calendar.mainAccount == this) as ActiveSyncCalendar | null;
+            let calendar = appGlobal.calendars.find(calendar => calendar.mainAccount == this && calendar.url == url.toString()) as ActiveSyncCalendar | null;
path: app/logic/Mail/ActiveSync/ActiveSyncAccount.ts
user: login: NeilRashbrook
body: Aha, I had been wondering about this!
created_at: 2025-04-22T22:30:30Z
author_association: COLLABORATOR
diff_hunk: @@ -441,15 +441,15 @@ export class ActiveSyncAccount extends MailAccount {
             break;
           case FolderType.Calendar:
           case FolderType.UserCalendar:
-            // TODO compare calendar ID
-            let calendar = appGlobal.calendars.find((calendar: ActiveSyncCalendar) => calendar.mainAccount == this) as ActiveSyncCalendar | null;
+            let calendar = appGlobal.calendars.find(calendar => calendar.mainAccount == this && calendar.url == url.toString()) as ActiveSyncCalendar | null;
path: app/logic/Mail/ActiveSync/ActiveSyncAccount.ts
user: login: benbucksch
body: Yes, putting the serverID into the URL was mighty subtle!

We should put the ID explicitly into a separate field. It wasn't possible before, but now with the JSON properties, we can store arbitrary data for each account.
created_at: 2025-04-22T22:35:00Z
author_association: COLLABORATOR
diff_hunk: @@ -441,15 +441,15 @@ export class ActiveSyncAccount extends MailAccount {
             break;
           case FolderType.Calendar:
           case FolderType.UserCalendar:
-            // TODO compare calendar ID
-            let calendar = appGlobal.calendars.find((calendar: ActiveSyncCalendar) => calendar.mainAccount == this) as ActiveSyncCalendar | null;
+            let calendar = appGlobal.calendars.find(calendar => calendar.mainAccount == this && calendar.url == url.toString()) as ActiveSyncCalendar | null;
path: app/logic/Mail/ActiveSync/ActiveSyncAccount.ts
user: login: benbucksch
body: Thanks for having noticed this. This is the level of review that I needed.
created_at: 2025-04-22T23:10:17Z
author_association: COLLABORATOR
diff_hunk: @@ -16,7 +16,7 @@ export function makeManualConfig(emailAddress: string, password: string): MailAc
   config.authMethod = AuthMethod.Password;
   config.source = "manual";
 
-  let outgoing = new SMTPAccount();
+  let outgoing = newAccountForProtocol("smtp") as any as SMTPAccount;
path: app/logic/Mail/AutoConfig/manualConfig.ts
user: login: NeilRashbrook
body: Was account setup broken because it wasn't saving the account or something? (I'm worried that these changes relate to the "FOREIGN KEY constraint failed" errors I was seeing.)

(The `as any` shouldn't be necessary, but I've had similar problems before, so I'll take your word for it.)
created_at: 2025-04-23T21:25:54Z
author_association: COLLABORATOR
diff_hunk: @@ -85,13 +79,10 @@ export class SQLMailAccount {
         backgroundError(ex);
       }
     }
-    // Set SMTP accounts
-    for (let account of accounts) {
-      let id = account.outgoingAccountID;
-      delete account.outgoingAccountID;
-      if (id) {
-        account.outgoing = smtpAccounts.find(acc => acc.id == id);
-      }
+    // Need to special-case it here, because SMTP accounts
+    // aren't in `appGlobal.emailAccounts`, so `setMainAccounts()` won't find them.
+    for (let smtp of smtpAccounts) {
+      smtp.mainAccount = accounts.find(acc => acc.id == smtp._mainAccountID);
path: app/logic/Mail/SQL/SQLMailAccount.ts
user: login: NeilRashbrook
body: (I was wondering how this worked with other cases, but I think what happens is that this causes the account to be the outgoing account of its main account, at which point it will start appearing in `allAccounts()`.)
created_at: 2025-04-23T21:26:20Z
author_association: COLLABORATOR
diff_hunk: @@ -118,13 +115,16 @@ export class MailAccount extends TCPAccount {
   }
 
   async save(): Promise<void> {
+    if (this.outgoing) {
+      this.outgoing.mainAccount = this;
+    }
+    console.log("mail account save", this.protocol, this.name, this, "outgoing", this.outgoing, "mainaccount", this.mainAccount, "storage", this.storage);
+    await super.save();
     await this.storage?.saveAccount(this);
path: app/logic/Mail/MailAccount.ts
user: login: NeilRashbrook
body: The problem here is that `super.save` tries to save all dependent accounts, which doesn't work until this account has been saved. This causes the creation of IMAP and POP3 accounts to fail, because they try to save the SMTP account first.
created_at: 2025-04-24T18:59:20Z
author_association: COLLABORATOR

PR comments:
user: login: benbucksch
created_at: 2025-04-22T23:38:06Z
body: @NeilRashbrook Please review
author_association: COLLABORATOR
user: login: sentry[bot]
created_at: 2025-04-24T19:38:35Z
body: ## Suspect Issues
This pull request was deployed and Sentry observed the following issues:

- ‼️ **Error: jermy SMTP: Need OAuth2 login from IMAP** `assert(../../../../app/logic/util/util.ts)` [View Issue](https://mustang-jq.sentry.io/issues/38467059/?referrer=github-pr-bot)

<sub>Did you find this useful? React with a 👍 or 👎</sub>
author_association: NONE
user: login: benbucksch
created_at: 2025-04-25T17:43:06Z
body: > I've since found that ActiveSync works because it has a custom `verifyLogin` which doesn't try to create and save any dependent accounts, but EWS and OWA fail because `verifyLogin` just defaults to calling `login(true);` which tries to create dependent addressbook and calendar accounts, and this fails because the main account hasn't been saved yet. (Presumably under the old code, these accounts would simply be saved, and would linger if you cancelled account setup at the last second.)

Can you please make a PR with a fix?

UPDATE: PR was already done in #578. Could you please follow up your comments with the PR link, so that there's the link in both directions?
author_association: COLLABORATOR
user: login: NeilRashbrook
created_at: 2025-04-28T16:05:47Z
body: I'm used to GitHub auto-linking in both directions automatically (`<user> mentioned this pull request <date>`), but I assume you meant in email too?
author_association: COLLABORATOR