@bobfrankston/rmfmail 1.2.50 → 1.2.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/client/android-bootstrap.bundle.js +63 -2
  2. package/client/android-bootstrap.bundle.js.map +4 -4
  3. package/client/app.bundle.js +417 -63
  4. package/client/app.bundle.js.map +3 -3
  5. package/client/app.js +182 -24
  6. package/client/app.js.map +1 -1
  7. package/client/app.ts +166 -21
  8. package/client/components/calendar-sidebar.js +5 -5
  9. package/client/components/calendar-sidebar.js.map +1 -1
  10. package/client/components/calendar-sidebar.ts +5 -5
  11. package/client/components/folder-tree.js +81 -0
  12. package/client/components/folder-tree.js.map +1 -1
  13. package/client/components/folder-tree.ts +70 -0
  14. package/client/components/message-list.js +282 -38
  15. package/client/components/message-list.js.map +1 -1
  16. package/client/components/message-list.ts +264 -36
  17. package/client/compose/compose.bundle.js +70 -14
  18. package/client/compose/compose.bundle.js.map +2 -2
  19. package/client/compose/compose.css +28 -7
  20. package/client/compose/compose.js +64 -4
  21. package/client/compose/compose.js.map +1 -1
  22. package/client/compose/compose.ts +66 -4
  23. package/client/compose/editor.js +23 -7
  24. package/client/compose/editor.js.map +1 -1
  25. package/client/compose/editor.ts +23 -7
  26. package/client/help/search-help.js +4 -3
  27. package/client/help/search-help.js.map +1 -1
  28. package/client/help/search-help.ts +4 -3
  29. package/client/index.html +5 -1
  30. package/client/lib/api-client.js +4 -2
  31. package/client/lib/api-client.js.map +1 -1
  32. package/client/lib/api-client.ts +4 -2
  33. package/client/lib/mailxapi.js +2 -2
  34. package/client/lib/rmf-tiny.js +39 -6
  35. package/client/styles/components.css +11 -0
  36. package/docs/azure.md +58 -0
  37. package/docs/config-help.md +131 -0
  38. package/docs/edit-in-word-docx.md +98 -0
  39. package/docs/host-abstraction-plan.md +169 -0
  40. package/docs/local-first-plan.md +303 -0
  41. package/docs/npmglobalize-transitive-workspace-deps.md +107 -0
  42. package/docs/outlook.md +80 -18
  43. package/docs/outlook.txt +35 -0
  44. package/docs/rules-design.md +172 -0
  45. package/package.json +9 -9
  46. package/packages/mailx-imap/index.d.ts +48 -1
  47. package/packages/mailx-imap/index.d.ts.map +1 -1
  48. package/packages/mailx-imap/index.js +320 -68
  49. package/packages/mailx-imap/index.js.map +1 -1
  50. package/packages/mailx-imap/index.ts +311 -65
  51. package/packages/mailx-imap/microsoft-credentials.json +8 -0
  52. package/packages/mailx-imap/package-lock.json +2 -2
  53. package/packages/mailx-imap/package.json +1 -1
  54. package/packages/mailx-imap/providers/outlook-api.d.ts +7 -0
  55. package/packages/mailx-imap/providers/outlook-api.d.ts.map +1 -0
  56. package/packages/mailx-imap/providers/outlook-api.js +7 -0
  57. package/packages/mailx-imap/providers/outlook-api.js.map +1 -0
  58. package/packages/mailx-service/index.d.ts +7 -1
  59. package/packages/mailx-service/index.d.ts.map +1 -1
  60. package/packages/mailx-service/index.js +8 -8
  61. package/packages/mailx-service/index.js.map +1 -1
  62. package/packages/mailx-service/index.ts +8 -7
  63. package/packages/mailx-service/jsonrpc.js +1 -1
  64. package/packages/mailx-service/jsonrpc.js.map +1 -1
  65. package/packages/mailx-service/jsonrpc.ts +1 -1
  66. package/packages/mailx-settings/docs/azure.md +58 -0
  67. package/packages/mailx-settings/docs/config-help.md +131 -0
  68. package/packages/mailx-settings/docs/edit-in-word-docx.md +98 -0
  69. package/packages/mailx-settings/docs/host-abstraction-plan.md +169 -0
  70. package/packages/mailx-settings/docs/local-first-plan.md +303 -0
  71. package/packages/mailx-settings/docs/npmglobalize-transitive-workspace-deps.md +107 -0
  72. package/packages/mailx-settings/docs/outlook.md +80 -18
  73. package/packages/mailx-settings/docs/rules-design.md +172 -0
  74. package/packages/mailx-settings/index.d.ts.map +1 -1
  75. package/packages/mailx-settings/index.js +9 -8
  76. package/packages/mailx-settings/index.js.map +1 -1
  77. package/packages/mailx-settings/index.ts +9 -8
  78. package/packages/mailx-settings/package.json +1 -1
  79. package/packages/mailx-store/db.d.ts +36 -1
  80. package/packages/mailx-store/db.d.ts.map +1 -1
  81. package/packages/mailx-store/db.js +100 -6
  82. package/packages/mailx-store/db.js.map +1 -1
  83. package/packages/mailx-store/db.ts +100 -6
  84. package/packages/mailx-store/package.json +1 -1
  85. package/packages/mailx-types/contact-rules.d.ts +1 -1
  86. package/packages/mailx-types/contact-rules.js +1 -1
  87. package/packages/mailx-types/contact-rules.js.map +1 -1
  88. package/packages/mailx-types/contact-rules.jsonc +1 -1
  89. package/packages/mailx-types/contact-rules.ts +1 -1
  90. package/packages/mailx-types/package.json +1 -1
@@ -11,6 +11,7 @@ import { EventEmitter } from "node:events";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { GmailApiProvider } from "./providers/gmail-api.js";
14
+ import { OutlookApiProvider } from "./providers/outlook-api.js";
14
15
  import { SmtpClient } from "@bobfrankston/smtp-direct";
15
16
  import * as os from "node:os";
16
17
  import { fileURLToPath } from "node:url";
@@ -180,6 +181,16 @@ async function withTimeout(promise, ms, client, label) {
180
181
  clearTimeout(timer);
181
182
  }
182
183
  }
184
+ /** Outlook.com / Microsoft 365 detection from an account config (host or email
185
+ * domain). Shared by the addAccount tokenProvider branch and isOutlookAccount
186
+ * so the credential/scope choice and the provider-routing choice agree. */
187
+ function isOutlookCfg(account) {
188
+ const host = account.imap?.host?.toLowerCase() || "";
189
+ const email = account.email?.toLowerCase() || "";
190
+ if (/outlook\.|office365|hotmail|\.live\.com/.test(host))
191
+ return true;
192
+ return /@(outlook|hotmail|live|msn)\.com$/.test(email);
193
+ }
183
194
  export class ImapManager extends EventEmitter {
184
195
  configs = new Map();
185
196
  watchers = new Map();
@@ -454,6 +465,98 @@ export class ImapManager extends EventEmitter {
454
465
  catch { /* */ }
455
466
  }
456
467
  }
468
+ /** Special-use roles a folder may NOT be renamed/reparented out of. Renaming
469
+ * INBOX/Sent/Drafts/Trash/Junk/Archive would break the special-folder
470
+ * mapping (compose's draft auto-save, the trash target, sent-copy append),
471
+ * and most servers reject it outright — so we refuse before touching the
472
+ * server. */
473
+ static PROTECTED_ROLES = new Set(["inbox", "sent", "drafts", "trash", "junk", "archive"]);
474
+ /** Rename and/or reparent a folder. Both operations are a single server-side
475
+ * mailbox rename (IMAP RENAME / Gmail label PATCH / Graph move+patch) —
476
+ * messages never move, no copy-and-delete. Mirrors moveMessage's plumbing:
477
+ * validate + branch API-vs-IMAP + commit locally.
478
+ *
479
+ * - `newName` is the new LEAF name.
480
+ * - `newParentId` (optional) reparents under that folder; omit for an
481
+ * in-place rename. Reparenting and renaming share one path computation:
482
+ * newPath = (newParent ? newParent.path + delim : oldParentPrefix) + newName.
483
+ *
484
+ * Guard rails (all throw a UI-surfaced error): protected special-use
485
+ * folders, empty/whitespace names, names containing the hierarchy
486
+ * delimiter, a destination that already exists, and reparenting a folder
487
+ * under itself or its own descendant (which would orphan the subtree). */
488
+ async renameFolder(accountId, folderId, newName, newParentId) {
489
+ const folders = this.db.getFolders(accountId);
490
+ const folder = folders.find(f => f.id === folderId);
491
+ if (!folder)
492
+ throw new Error("Folder not found");
493
+ // Use the folder's OWN delimiter — never borrow folders[0]'s (a
494
+ // different-delimiter folder would build a wrong newPath).
495
+ const delimiter = folder.delimiter || "/";
496
+ // ── Guard rails ──────────────────────────────────────────────────────
497
+ if (folder.specialUse && ImapManager.PROTECTED_ROLES.has(folder.specialUse)) {
498
+ throw new Error(`"${folder.name}" is a special folder (${folder.specialUse}) and can't be renamed.`);
499
+ }
500
+ const leaf = (newName || "").trim();
501
+ if (!leaf)
502
+ throw new Error("Folder name can't be empty.");
503
+ if (leaf.includes(delimiter)) {
504
+ throw new Error(`Folder name can't contain the "${delimiter}" hierarchy separator.`);
505
+ }
506
+ // Resolve the destination parent (reparent) or keep the existing parent
507
+ // prefix (in-place rename).
508
+ let newPath;
509
+ let parent;
510
+ if (newParentId != null) {
511
+ parent = folders.find(f => f.id === newParentId);
512
+ if (!parent)
513
+ throw new Error("Target parent folder not found");
514
+ if (parent.id === folder.id)
515
+ throw new Error("Can't move a folder into itself.");
516
+ // Reparenting under a descendant would orphan the moved subtree.
517
+ if (parent.path === folder.path || parent.path.startsWith(folder.path + delimiter)) {
518
+ throw new Error("Can't move a folder into one of its own subfolders.");
519
+ }
520
+ newPath = parent.path + delimiter + leaf;
521
+ }
522
+ else {
523
+ const idx = folder.path.lastIndexOf(delimiter);
524
+ const parentPrefix = idx >= 0 ? folder.path.slice(0, idx + delimiter.length) : "";
525
+ newPath = parentPrefix + leaf;
526
+ }
527
+ if (newPath === folder.path)
528
+ return; // no-op (same name + same parent)
529
+ if (folders.some(f => f.path === newPath)) {
530
+ throw new Error(`A folder named "${leaf}" already exists here.`);
531
+ }
532
+ // ── Server op ────────────────────────────────────────────────────────
533
+ // API accounts (Gmail labels / Outlook Graph folders) have native
534
+ // rename; IMAP accounts use RFC 3501 RENAME, which renames the whole
535
+ // subtree server-side in one command.
536
+ if (this.isApiAccount(accountId)) {
537
+ const api = this.getApiProvider(accountId);
538
+ if (!api.renameFolder) {
539
+ throw new Error("This account's provider doesn't support folder rename.");
540
+ }
541
+ // Gmail wants the parent label PATH; Outlook wants the parent Graph
542
+ // id — both are the parent folder's `path` in our model.
543
+ await api.renameFolder(folder.path, leaf, parent?.path);
544
+ }
545
+ else {
546
+ await this.renameFolderViaServer(accountId, folder.path, newPath);
547
+ }
548
+ // ── Local commit ─────────────────────────────────────────────────────
549
+ // All providers now use a HIERARCHICAL display path:
550
+ // - IMAP: server mailbox path (renameFolderViaServer also resyncs).
551
+ // - Gmail: label name (which is the slash-path).
552
+ // - Outlook: display-name path — the provider keeps a path↔Graph-id
553
+ // map internally, so renaming the path is safe (Graph ops resolve
554
+ // the id from the new path on the next list-sync).
555
+ // So the predicted newPath matches the server result; rewrite the row +
556
+ // descendant prefixes for an immediate tree update.
557
+ this.db.renameFolderPath(accountId, folder.path, newPath, leaf, delimiter);
558
+ this.emit("folderCountsChanged", accountId, {});
559
+ }
457
560
  /** Delete `path` on the server (tolerating already-gone). Local folder-row
458
561
  * removal stays with the caller (it knows the folderId). */
459
562
  async deleteFolderViaServer(accountId, path) {
@@ -928,25 +1031,56 @@ export class ImapManager extends EventEmitter {
928
1031
  // createAutoImapConfig auto-detects Gmail from server/username and sets up OAuth
929
1032
  // For OAuth accounts, provide a tokenProvider using oauthsupport
930
1033
  let tokenProvider;
931
- if (account.imap.auth === "oauth2" || (!account.imap.password && account.imap.host?.includes("gmail"))) {
932
- // Find Google OAuth credentials check ~/.mailx first, then iflow-direct package
933
- let credPath = path.join(getConfigDir(), "google-credentials.json");
934
- if (!fs.existsSync(credPath)) {
935
- try {
936
- // Use fileURLToPath, NOT string-replace on "file://" — on Linux,
937
- // file:///usr/local/... loses its leading slash via .replace("file:///",
938
- // "") and becomes relative, so fs.existsSync silently fails.
939
- const pkgDir = path.dirname(fileURLToPath(import.meta.resolve("@bobfrankston/iflow-direct")));
940
- for (const name of ["iflow-credentials.json"]) {
941
- const p = path.join(pkgDir, name);
942
- if (fs.existsSync(p)) {
943
- credPath = p;
944
- break;
1034
+ if (account.imap.auth === "oauth2" || (!account.imap.password && account.imap.host?.includes("gmail")) || (!account.imap.password && isOutlookCfg(account))) {
1035
+ // Pick the OAuth provider by account kind. Outlook accounts use
1036
+ // Microsoft Graph credentials + scopes; everything else is Google.
1037
+ // Both flow through the same provider-agnostic oauthsupport code —
1038
+ // only the creds file and scope string differ (see docs/outlook.md).
1039
+ const outlook = isOutlookCfg(account);
1040
+ let credPath;
1041
+ if (outlook) {
1042
+ // Microsoft app registration creds. Check the per-machine
1043
+ // override (~/.rmfmail) first, then fall back to the file
1044
+ // BUNDLED IN THIS PACKAGE — same model as Google's creds
1045
+ // shipping inside iflow-direct. Because the client is PUBLIC
1046
+ // (PKCE, no secret), the bundled file holds only the public
1047
+ // client_id and is safe to commit/ship. So a fresh install
1048
+ // needs NO manual creds placement (see docs/outlook.md §A/B).
1049
+ credPath = path.join(getConfigDir(), "microsoft-credentials.json");
1050
+ if (!fs.existsSync(credPath)) {
1051
+ const bundled = path.join(path.dirname(fileURLToPath(import.meta.url)), "microsoft-credentials.json");
1052
+ if (fs.existsSync(bundled))
1053
+ credPath = bundled;
1054
+ }
1055
+ }
1056
+ else {
1057
+ // Find Google OAuth credentials — check ~/.rmfmail first, then iflow-direct package
1058
+ credPath = path.join(getConfigDir(), "google-credentials.json");
1059
+ if (!fs.existsSync(credPath)) {
1060
+ try {
1061
+ // Use fileURLToPath, NOT string-replace on "file://" — on Linux,
1062
+ // file:///usr/local/... loses its leading slash via .replace("file:///",
1063
+ // "") and becomes relative, so fs.existsSync silently fails.
1064
+ const pkgDir = path.dirname(fileURLToPath(import.meta.resolve("@bobfrankston/iflow-direct")));
1065
+ for (const name of ["iflow-credentials.json"]) {
1066
+ const p = path.join(pkgDir, name);
1067
+ if (fs.existsSync(p)) {
1068
+ credPath = p;
1069
+ break;
1070
+ }
945
1071
  }
946
1072
  }
1073
+ catch { /* iflow-direct not resolvable */ }
947
1074
  }
948
- catch { /* iflow-direct not resolvable */ }
949
1075
  }
1076
+ // Scope set by provider. Google: full two-way sync of all
1077
+ // mailx-managed stores. Microsoft Graph: mail read/write + send +
1078
+ // offline refresh. (Graph and Outlook-IMAP scopes are DIFFERENT
1079
+ // audiences — we mint Graph because Outlook syncs/sends via Graph,
1080
+ // not IMAP/SMTP. See docs/outlook.md "Decision".)
1081
+ const oauthScope = outlook
1082
+ ? "https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/Mail.Send offline_access openid profile"
1083
+ : "https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/drive";
950
1084
  const tokenDir = path.join(getConfigDir(), "tokens", tokenDirName(account.imap.user));
951
1085
  tokenProvider = async () => {
952
1086
  // Wall-clock timeout on OAuth. Use a longer timeout when no
@@ -955,11 +1089,9 @@ export class ImapManager extends EventEmitter {
955
1089
  const hasToken = fs.existsSync(path.join(tokenDir, "oauth-token.json"));
956
1090
  const TOKEN_FETCH_TIMEOUT_MS = hasToken ? 30_000 : 120_000;
957
1091
  const authPromise = authenticateOAuth(credPath, {
958
- // Scope set covers two-way sync of all mailx-managed local
959
- // stores: mail (mail.google.com), contacts (full, not
960
- // readonly — we write edits back), calendar (full), tasks
961
- // (full), drive (for shared accounts.jsonc).
962
- scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/drive",
1092
+ // Scope set chosen per provider above (Google full-store
1093
+ // sync, or Microsoft Graph mail).
1094
+ scope: oauthScope,
963
1095
  tokenDirectory: tokenDir,
964
1096
  credentialsKey: "installed",
965
1097
  loginHint: account.imap.user,
@@ -1004,6 +1136,15 @@ export class ImapManager extends EventEmitter {
1004
1136
  // Gmail-via-IMAP responds instantly, no need to bump it there.
1005
1137
  greetingTimeout: isGmail ? 10000 : 30000,
1006
1138
  });
1139
+ // createAutoImapConfig only threads the tokenProvider through for GMAIL
1140
+ // hosts — its non-Gmail branch drops it (it assumes non-Gmail == password
1141
+ // IMAP). Outlook is OAuth-but-not-Gmail: it never opens IMAP (it syncs via
1142
+ // Graph), but getOutlookProvider reads config.tokenProvider. So re-attach
1143
+ // it here for any OAuth account whose host createAutoImapConfig didn't
1144
+ // recognize. Without this, an Outlook account silently has no provider →
1145
+ // "No tokenProvider for outlook" on every sync (Bob 2026-06-21).
1146
+ if (tokenProvider && !config.tokenProvider)
1147
+ config.tokenProvider = tokenProvider;
1007
1148
  this.configs.set(account.id, config);
1008
1149
  // Register account in DB
1009
1150
  this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
@@ -1043,6 +1184,38 @@ export class ImapManager extends EventEmitter {
1043
1184
  throw new Error(`No tokenProvider for ${accountId}`);
1044
1185
  return new GmailApiProvider(config.tokenProvider);
1045
1186
  }
1187
+ /** Check if an account is Outlook.com / Microsoft 365 (should use Graph API
1188
+ * instead of IMAP). Detection mirrors isGmailAccount: host or email domain.
1189
+ * office365 IMAP front-ends and consumer outlook.com both route here. */
1190
+ isOutlookAccount(accountId) {
1191
+ const settings = loadSettings();
1192
+ const account = settings.accounts.find(a => a.id === accountId);
1193
+ if (!account)
1194
+ return false;
1195
+ return isOutlookCfg(account);
1196
+ }
1197
+ /** True for any account that syncs via a REST provider (Gmail or Outlook)
1198
+ * rather than IMAP. The branch points that used to ask isGmailAccount to
1199
+ * mean "API, not IMAP" ask this instead. */
1200
+ isApiAccount(accountId) {
1201
+ return this.isGmailAccount(accountId) || this.isOutlookAccount(accountId);
1202
+ }
1203
+ /** Get a Microsoft Graph provider for an account (reuses the tokenProvider —
1204
+ * which mints Graph-scoped tokens for Outlook accounts, see addAccount). */
1205
+ getOutlookProvider(accountId) {
1206
+ const config = this.configs.get(accountId);
1207
+ if (!config?.tokenProvider)
1208
+ throw new Error(`No tokenProvider for ${accountId}`);
1209
+ return new OutlookApiProvider(config.tokenProvider);
1210
+ }
1211
+ /** Get the right REST provider (Gmail or Outlook) for an API account.
1212
+ * Both implement MailProvider plus the setFlags/trashMessage/moveMessage/
1213
+ * fetchBodiesBatch surface the dispatcher uses, so callers stay generic. */
1214
+ getApiProvider(accountId) {
1215
+ if (this.isOutlookAccount(accountId))
1216
+ return this.getOutlookProvider(accountId);
1217
+ return this.getGmailProvider(accountId);
1218
+ }
1046
1219
  /** Convert ProviderMessage to the shape expected by storeMessages/upsertMessage */
1047
1220
  providerMsgToLocal(msg) {
1048
1221
  return {
@@ -2067,8 +2240,8 @@ export class ImapManager extends EventEmitter {
2067
2240
  }
2068
2241
  /** Sync a single account — manages its own connection lifecycle */
2069
2242
  async syncAccount(accountId, priorityOrder) {
2070
- // Gmail: use REST API instead of IMAP
2071
- if (this.isGmailAccount(accountId)) {
2243
+ // Gmail / Outlook: use REST API instead of IMAP
2244
+ if (this.isApiAccount(accountId)) {
2072
2245
  return this.syncAccountViaApi(accountId, priorityOrder);
2073
2246
  }
2074
2247
  try {
@@ -2277,19 +2450,40 @@ export class ImapManager extends EventEmitter {
2277
2450
  this.handleSyncError(accountId, errMsg);
2278
2451
  }
2279
2452
  }
2280
- /** Sync a Gmail account via REST API — no IMAP connections */
2453
+ /** Sync a Gmail or Outlook account via REST API — no IMAP connections */
2281
2454
  async syncAccountViaApi(accountId, priorityOrder) {
2282
2455
  try {
2283
- const api = this.getGmailProvider(accountId);
2456
+ const api = this.getApiProvider(accountId);
2284
2457
  const t0 = Date.now();
2285
2458
  // Step 1: Sync folder list via API
2286
2459
  console.log(` [api] ${accountId}: listing labels...`);
2287
2460
  const apiFolders = await api.listFolders();
2288
2461
  console.log(` [api] ${accountId}: ${apiFolders.length} labels in ${Date.now() - t0}ms`);
2289
2462
  // Store folders in DB (same as IMAP path)
2463
+ const serverPaths = new Set();
2290
2464
  for (const f of apiFolders) {
2291
2465
  const specialUse = f.specialUse || "";
2292
2466
  this.db.upsertFolder(accountId, f.path, f.name, specialUse, f.delimiter);
2467
+ serverPaths.add(f.path);
2468
+ }
2469
+ // Prune local folders the provider no longer returns. The IMAP path
2470
+ // (syncFolders) already does this; the API path didn't, so a folder
2471
+ // deleted on the server — OR whose path SCHEME changed (e.g. the
2472
+ // Outlook id→hierarchical-path migration) — lingered forever as a
2473
+ // stale/duplicate row. Same guards as syncFolders: only prune on a
2474
+ // non-empty list (empty == transient error, not "all gone") and
2475
+ // never drop INBOX.
2476
+ if (apiFolders.length > 0) {
2477
+ const stale = this.db.getFolders(accountId).filter(f => !serverPaths.has(f.path) && f.specialUse !== "inbox");
2478
+ for (const f of stale) {
2479
+ console.log(` [api] ${accountId}: pruning stale folder "${f.path}" (id=${f.id}) — not in provider list`);
2480
+ try {
2481
+ this.db.deleteFolder(f.id);
2482
+ }
2483
+ catch (e) {
2484
+ console.error(` [api] ${accountId}: prune failed for "${f.path}": ${e.message}`);
2485
+ }
2486
+ }
2293
2487
  }
2294
2488
  this.emit("folderCountsChanged", accountId, {});
2295
2489
  const dbFolders = this.db.getFolders(accountId);
@@ -2562,8 +2756,8 @@ export class ImapManager extends EventEmitter {
2562
2756
  * on a large mailbox). The 5-minute STATUS poll path still runs full
2563
2757
  * `syncFolder` so deletions and gaps eventually reconcile. */
2564
2758
  async syncInboxNewOnly(accountId) {
2565
- if (this.isGmailAccount(accountId))
2566
- return; // IDLE is IMAP-only
2759
+ if (this.isApiAccount(accountId))
2760
+ return; // IDLE is IMAP-only (Gmail/Outlook poll via API)
2567
2761
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
2568
2762
  if (!inbox)
2569
2763
  return;
@@ -2611,8 +2805,8 @@ export class ImapManager extends EventEmitter {
2611
2805
  // mismatch — API uses hashed IDs, IMAP uses server-assigned
2612
2806
  // UIDs. The IMAP reconcile then deletes every API-synced
2613
2807
  // message because their UIDs don't appear in the IMAP list.
2614
- if (this.isGmailAccount(accountId)) {
2615
- const api = this.getGmailProvider(accountId);
2808
+ if (this.isApiAccount(accountId)) {
2809
+ const api = this.getApiProvider(accountId);
2616
2810
  try {
2617
2811
  await this.syncFolderViaApi(accountId, inbox, api);
2618
2812
  }
@@ -2676,8 +2870,31 @@ export class ImapManager extends EventEmitter {
2676
2870
  async quickInboxCheckAccount(accountId) {
2677
2871
  if (this.isGmailAccount(accountId))
2678
2872
  return this.quickGmailCheck(accountId);
2873
+ if (this.isOutlookAccount(accountId))
2874
+ return this.quickApiCheck(accountId);
2679
2875
  return this.quickImapCheck(accountId);
2680
2876
  }
2877
+ /** Lightweight inbox refresh for a generic API account (Outlook). Unlike
2878
+ * quickGmailCheck there's no cheap "top message id" probe — a single
2879
+ * incremental syncFolderViaApi is itself cheap (Graph delta-ish page), so
2880
+ * we just run it and recalc counts. */
2881
+ async quickApiCheck(accountId) {
2882
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
2883
+ if (!inbox)
2884
+ return;
2885
+ const api = this.getApiProvider(accountId);
2886
+ try {
2887
+ await this.syncFolderViaApi(accountId, inbox, api);
2888
+ this.db.recalcFolderCounts(inbox.id);
2889
+ this.emit("folderCountsChanged", accountId, {});
2890
+ }
2891
+ finally {
2892
+ try {
2893
+ await api.close();
2894
+ }
2895
+ catch { /* ignore */ }
2896
+ }
2897
+ }
2681
2898
  async quickImapCheck(accountId) {
2682
2899
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
2683
2900
  if (!inbox)
@@ -2749,21 +2966,21 @@ export class ImapManager extends EventEmitter {
2749
2966
  // Gmail uses API sync, not IMAP STATUS. IMAP accounts use IDLE
2750
2967
  // which gives instant push — the STATUS poll is just a fallback
2751
2968
  // in case IDLE silently dropped.
2752
- const isGmail = this.isGmailAccount(accountId);
2969
+ const isApi = this.isApiAccount(accountId);
2753
2970
  // IMAP accounts: IDLE gives instant push; STATUS poll is just a
2754
2971
  // safety net for silent IDLE drops — keep it infrequent.
2755
- // Gmail accounts: no IDLE (Gmail API doesn't expose it), so the
2756
- // quick poll IS the primary path to new-mail latency. Drop to 30s
2757
- // so Gmail mail appears in ~15s average. Gmail quota budget is
2758
- // huge (250 units/sec per user, 1.2B/day) 120 polls/hour × 5
2759
- // units 600/hour, trivial. Dovecot accounts stay at 5min to
2760
- // respect connection limits (each poll = fresh connection).
2761
- const interval = isGmail ? 30000 : 300000; // Gmail: 30s; IMAP: 5min
2972
+ // API accounts (Gmail/Outlook): no IDLE, so the quick poll IS the
2973
+ // primary path to new-mail latency. Drop to 30s so mail appears in
2974
+ // ~15s average. Both providers have ample request budget and their
2975
+ // own rate-limiters, so a fresh poll is cheap. Dovecot accounts
2976
+ // stay at 5min to respect connection limits (each poll = fresh
2977
+ // connection).
2978
+ const interval = isApi ? 30000 : 300000; // API: 30s; IMAP: 5min
2762
2979
  const timer = setInterval(() => {
2763
2980
  this.quickInboxCheckAccount(accountId).catch(() => { });
2764
2981
  }, interval);
2765
2982
  this.syncIntervals.set(`quick:${accountId}`, timer);
2766
- console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${isGmail ? "API" : "IMAP+IDLE"})`);
2983
+ console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${isApi ? "API" : "IMAP+IDLE"})`);
2767
2984
  }
2768
2985
  // DEADMAN: IDLE silently dropped on 2026-05-27 at 10:07 (empty
2769
2986
  // error message swallowed the cause) and 90 minutes of new mail
@@ -2776,8 +2993,8 @@ export class ImapManager extends EventEmitter {
2776
2993
  // the log.
2777
2994
  const deadmanInterval = setInterval(() => {
2778
2995
  for (const [accountId] of this.configs) {
2779
- if (this.isGmailAccount(accountId))
2780
- continue; // Gmail = API, no IDLE
2996
+ if (this.isApiAccount(accountId))
2997
+ continue; // Gmail/Outlook = API, no IDLE
2781
2998
  if (this.watchers.has(accountId))
2782
2999
  continue;
2783
3000
  console.log(` [idle-deadman] ${accountId}: no IDLE watcher — attempting restart`);
@@ -3144,8 +3361,8 @@ export class ImapManager extends EventEmitter {
3144
3361
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
3145
3362
  if (!folder)
3146
3363
  return null;
3147
- // Gmail: REST API, no IMAP connection involved.
3148
- if (this.isGmailAccount(accountId)) {
3364
+ // Gmail / Outlook: REST API, no IMAP connection involved.
3365
+ if (this.isApiAccount(accountId)) {
3149
3366
  return this.fetchMessageBodyViaApi(accountId, folderId, uid, folder.path);
3150
3367
  }
3151
3368
  // IMAP: fast lane on the ops queue. One try; if the socket is stale,
@@ -3189,7 +3406,7 @@ export class ImapManager extends EventEmitter {
3189
3406
  * delete the stale row locally instead of showing a generic error. */
3190
3407
  async fetchMessageBodyViaApi(accountId, folderId, uid, folderPath) {
3191
3408
  try {
3192
- const api = this.getGmailProvider(accountId);
3409
+ const api = this.getApiProvider(accountId);
3193
3410
  // Read provider_id from the local row so fetchOne can skip the
3194
3411
  // listMessageIds pagination (the dominant Gmail rate-limit cost).
3195
3412
  const env = this.db.getMessageByUid(accountId, uid, folderId);
@@ -3341,7 +3558,7 @@ export class ImapManager extends EventEmitter {
3341
3558
  const ERROR_BUDGET = 20;
3342
3559
  const RATE_LIMIT_PAUSE_MS = 30000;
3343
3560
  const BATCH_SIZE = 100;
3344
- const isGmail = this.isGmailAccount(accountId);
3561
+ const isApi = this.isApiAccount(accountId);
3345
3562
  // Gmail still uses per-message fetch (HTTP /batch is a separate TODO in this file's
3346
3563
  // governing unit). IMAP uses the batched `fetchBodiesBatch` path via iflow-direct —
3347
3564
  // one SELECT + one UID FETCH per folder per tick instead of N round trips.
@@ -3378,13 +3595,13 @@ export class ImapManager extends EventEmitter {
3378
3595
  announced = true;
3379
3596
  }
3380
3597
  let madeProgress = false;
3381
- if (isGmail) {
3382
- // Gmail batch path: group by label (what mailx calls "folder"),
3383
- // list once per label, bounded-concurrency fetch. Far fewer
3384
- // HTTP round trips than the old one-listMessageIds-per-body path.
3385
- // Note on the model: Gmail has labels, not folders. A message in
3386
- // multiple labels gets fetched twice under current grouping. A
3387
- // deeper label-native redesign is tracked as a separate TODO.
3598
+ if (isApi) {
3599
+ // API batch path (Gmail/Outlook): group by folder/label, list
3600
+ // once per folder, bounded-concurrency fetch. Far fewer HTTP
3601
+ // round trips than the old one-list-per-body path.
3602
+ // Note on the Gmail model: Gmail has labels, not folders. A
3603
+ // message in multiple labels gets fetched twice under current
3604
+ // grouping. A deeper label-native redesign is a separate TODO.
3388
3605
  const byFolder = new Map();
3389
3606
  for (const m of missing) {
3390
3607
  let arr = byFolder.get(m.folderId);
@@ -3395,7 +3612,7 @@ export class ImapManager extends EventEmitter {
3395
3612
  arr.push(m.uid);
3396
3613
  }
3397
3614
  const folders = this.db.getFolders(accountId);
3398
- const api = this.getGmailProvider(accountId);
3615
+ const api = this.getApiProvider(accountId);
3399
3616
  try {
3400
3617
  for (const [folderId, uidsInFolder] of byFolder) {
3401
3618
  const folder = folders.find(f => f.id === folderId);
@@ -3796,8 +4013,8 @@ export class ImapManager extends EventEmitter {
3796
4013
  // through withConnection → IMAP, which silently no-op'd for Gmail
3797
4014
  // accounts (REST-only, no IMAP connection) and left local-only stars
3798
4015
  // that vanished on the next full sync.
3799
- if (this.isGmailAccount(accountId)) {
3800
- const api = this.getGmailProvider(accountId);
4016
+ if (this.isApiAccount(accountId)) {
4017
+ const api = this.getApiProvider(accountId);
3801
4018
  try {
3802
4019
  for (const action of actions) {
3803
4020
  const folder = folders.find(f => f.id === action.folderId);
@@ -3933,16 +4150,33 @@ export class ImapManager extends EventEmitter {
3933
4150
  console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
3934
4151
  break;
3935
4152
  }
3936
- case "flags":
3937
- if (action.flags.length > 0) {
3938
- await client.addFlags(folder.path, action.uid, action.flags.filter(f => !f.startsWith("-")));
3939
- const toRemove = action.flags.filter(f => f.startsWith("-")).map(f => f.slice(1));
3940
- if (toRemove.length > 0) {
3941
- await client.removeFlags(folder.path, action.uid, toRemove);
3942
- }
3943
- console.log(` [sync] Updated flags UID ${action.uid}`);
4153
+ case "flags": {
4154
+ // action.flags is the ABSOLUTE desired flag set: the UI
4155
+ // sends the whole array with the toggled flag added or
4156
+ // simply OMITTED it does NOT use the `-remove` convention.
4157
+ // The old code only ever called addFlags (additive), so
4158
+ // CLEARING a star never reached the server and the flag came
4159
+ // back on the next sync — "I shut off the stars and they
4160
+ // suddenly come back, still flagged on the server" (Bob
4161
+ // 2026-06-26). The old `length > 0` guard also made clearing
4162
+ // the last remaining flag a complete no-op. Reconcile to the
4163
+ // absolute state instead: add the desired flags, and remove
4164
+ // any flag mailx manages (\Seen / \Flagged) that's no longer
4165
+ // present. Mirrors the Gmail provider's add+remove approach.
4166
+ const desired = action.flags.filter(f => !f.startsWith("-"));
4167
+ const explicitRemove = action.flags.filter(f => f.startsWith("-")).map(f => f.slice(1));
4168
+ const MANAGED = ["\\Seen", "\\Flagged"];
4169
+ const impliedRemove = MANAGED.filter(f => !desired.includes(f));
4170
+ const toRemove = [...new Set([...explicitRemove, ...impliedRemove])];
4171
+ if (desired.length > 0) {
4172
+ await client.addFlags(folder.path, action.uid, desired);
4173
+ }
4174
+ if (toRemove.length > 0) {
4175
+ await client.removeFlags(folder.path, action.uid, toRemove);
3944
4176
  }
4177
+ console.log(` [sync] Updated flags UID ${action.uid} (+[${desired.join(",")}] -[${toRemove.join(",")}])`);
3945
4178
  break;
4179
+ }
3946
4180
  case "append": {
3947
4181
  if (action.rawMessage) {
3948
4182
  await client.appendMessage(folder.path, action.rawMessage, action.flags);
@@ -4674,10 +4908,13 @@ export class ImapManager extends EventEmitter {
4674
4908
  }
4675
4909
  if (filesToSend.length === 0)
4676
4910
  return;
4677
- // Gmail/API accounts: send directly via SMTP from local queue (no IMAP outbox)
4911
+ // API accounts (Gmail/Outlook): send directly from the local queue (no
4912
+ // IMAP outbox). Gmail goes via SMTP+XOAUTH2 (mail.google.com scope
4913
+ // covers SMTP); Outlook goes via Graph /sendMail (its token is Graph-
4914
+ // scoped, not SMTP) — sendRawForAccount picks the right transport.
4678
4915
  const sentDir = path.join(getConfigDir(), "sending", accountId, "sent");
4679
4916
  fs.mkdirSync(sentDir, { recursive: true });
4680
- if (this.isGmailAccount(accountId)) {
4917
+ if (this.isApiAccount(accountId)) {
4681
4918
  const nowMs = Date.now();
4682
4919
  for (const { dir, file } of filesToSend) {
4683
4920
  const filePath = path.join(dir, file);
@@ -4718,9 +4955,9 @@ export class ImapManager extends EventEmitter {
4718
4955
  raw = insertHeaderBeforeBody(raw, `X-Mailx-Retry: ${attempt} ${new Date().toISOString()}`);
4719
4956
  fs.writeFileSync(claimedPath, raw, "utf-8");
4720
4957
  try {
4721
- await this.sendRawViaSMTP(accountId, raw);
4958
+ await this.sendRawForAccount(accountId, raw);
4722
4959
  fs.renameSync(claimedPath, path.join(sentDir, file));
4723
- console.log(` [outbox] Sent ${file} via SMTP → sent/ (attempt ${attempt})`);
4960
+ console.log(` [outbox] Sent ${file} → sent/ (attempt ${attempt})`);
4724
4961
  }
4725
4962
  catch (e) {
4726
4963
  // Persist a next-attempt timestamp and release the claim so the
@@ -4816,6 +5053,20 @@ export class ImapManager extends EventEmitter {
4816
5053
  // IMAP still unreachable — leave files for next attempt
4817
5054
  }
4818
5055
  }
5056
+ /** Send a raw RFC 2822 message for an account, choosing the transport by
5057
+ * provider. Outlook accounts have a Graph-scoped OAuth token (no SMTP
5058
+ * audience), so they send via Graph POST /sendMail; Gmail and IMAP
5059
+ * accounts send via SMTP (Gmail's mail.google.com token covers XOAUTH2
5060
+ * SMTP). Graph /sendMail auto-files a copy in Sent, so no append needed. */
5061
+ async sendRawForAccount(accountId, raw) {
5062
+ if (this.isOutlookAccount(accountId)) {
5063
+ const api = this.getOutlookProvider(accountId);
5064
+ await api.sendRaw(raw);
5065
+ console.log(` [graph] ${accountId}: sent via /sendMail`);
5066
+ return;
5067
+ }
5068
+ await this.sendRawViaSMTP(accountId, raw);
5069
+ }
4819
5070
  /** Send a raw RFC 2822 message via SMTP for a given account.
4820
5071
  * Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
4821
5072
  * same TCP byte-stream interface, no nodemailer dependency. */
@@ -4901,8 +5152,9 @@ export class ImapManager extends EventEmitter {
4901
5152
  const outboxFolder = this.findFolder(accountId, "outbox");
4902
5153
  if (!outboxFolder)
4903
5154
  return;
4904
- // Gmail: skip IMAP outbox check — sending handled by processLocalQueue which sends directly via SMTP
4905
- if (this.isGmailAccount(accountId))
5155
+ // Gmail/Outlook: skip IMAP outbox — sending handled by processLocalQueue
5156
+ // (Gmail via SMTP, Outlook via Graph /sendMail).
5157
+ if (this.isApiAccount(accountId))
4906
5158
  return;
4907
5159
  const settings = loadSettings();
4908
5160
  const account = settings.accounts.find(a => a.id === accountId);
@@ -5249,7 +5501,7 @@ export class ImapManager extends EventEmitter {
5249
5501
  return;
5250
5502
  const tick = async () => {
5251
5503
  for (const [accountId] of this.configs) {
5252
- if (this.isGmailAccount(accountId))
5504
+ if (this.isApiAccount(accountId))
5253
5505
  continue;
5254
5506
  try {
5255
5507
  await this.sweepSentOnce(accountId);