@bobfrankston/rmfmail 1.2.50 → 1.2.65

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 +6 -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 +7 -7
  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
@@ -14,6 +14,7 @@ import { EventEmitter } from "node:events";
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
16
  import { GmailApiProvider } from "./providers/gmail-api.js";
17
+ import { OutlookApiProvider } from "./providers/outlook-api.js";
17
18
  import type { MailProvider, ProviderMessage } from "./providers/types.js";
18
19
  import { SmtpClient } from "@bobfrankston/smtp-direct";
19
20
  import * as os from "node:os";
@@ -243,6 +244,16 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, client: any, labe
243
244
  }
244
245
  }
245
246
 
247
+ /** Outlook.com / Microsoft 365 detection from an account config (host or email
248
+ * domain). Shared by the addAccount tokenProvider branch and isOutlookAccount
249
+ * so the credential/scope choice and the provider-routing choice agree. */
250
+ function isOutlookCfg(account: AccountConfig): boolean {
251
+ const host = account.imap?.host?.toLowerCase() || "";
252
+ const email = account.email?.toLowerCase() || "";
253
+ if (/outlook\.|office365|hotmail|\.live\.com/.test(host)) return true;
254
+ return /@(outlook|hotmail|live|msn)\.com$/.test(email);
255
+ }
256
+
246
257
  export class ImapManager extends EventEmitter {
247
258
  private configs: Map<string, ReturnType<typeof createAutoImapConfig>> = new Map();
248
259
  private watchers: Map<string, () => void> = new Map();
@@ -503,6 +514,99 @@ export class ImapManager extends EventEmitter {
503
514
  } finally { try { await client.logout(); } catch { /* */ } }
504
515
  }
505
516
 
517
+ /** Special-use roles a folder may NOT be renamed/reparented out of. Renaming
518
+ * INBOX/Sent/Drafts/Trash/Junk/Archive would break the special-folder
519
+ * mapping (compose's draft auto-save, the trash target, sent-copy append),
520
+ * and most servers reject it outright — so we refuse before touching the
521
+ * server. */
522
+ private static readonly PROTECTED_ROLES = new Set(["inbox", "sent", "drafts", "trash", "junk", "archive"]);
523
+
524
+ /** Rename and/or reparent a folder. Both operations are a single server-side
525
+ * mailbox rename (IMAP RENAME / Gmail label PATCH / Graph move+patch) —
526
+ * messages never move, no copy-and-delete. Mirrors moveMessage's plumbing:
527
+ * validate + branch API-vs-IMAP + commit locally.
528
+ *
529
+ * - `newName` is the new LEAF name.
530
+ * - `newParentId` (optional) reparents under that folder; omit for an
531
+ * in-place rename. Reparenting and renaming share one path computation:
532
+ * newPath = (newParent ? newParent.path + delim : oldParentPrefix) + newName.
533
+ *
534
+ * Guard rails (all throw a UI-surfaced error): protected special-use
535
+ * folders, empty/whitespace names, names containing the hierarchy
536
+ * delimiter, a destination that already exists, and reparenting a folder
537
+ * under itself or its own descendant (which would orphan the subtree). */
538
+ async renameFolder(accountId: string, folderId: number, newName: string, newParentId?: number): Promise<void> {
539
+ const folders = this.db.getFolders(accountId);
540
+ const folder = folders.find(f => f.id === folderId);
541
+ if (!folder) throw new Error("Folder not found");
542
+
543
+ // Use the folder's OWN delimiter — never borrow folders[0]'s (a
544
+ // different-delimiter folder would build a wrong newPath).
545
+ const delimiter = folder.delimiter || "/";
546
+
547
+ // ── Guard rails ──────────────────────────────────────────────────────
548
+ if (folder.specialUse && ImapManager.PROTECTED_ROLES.has(folder.specialUse)) {
549
+ throw new Error(`"${folder.name}" is a special folder (${folder.specialUse}) and can't be renamed.`);
550
+ }
551
+ const leaf = (newName || "").trim();
552
+ if (!leaf) throw new Error("Folder name can't be empty.");
553
+ if (leaf.includes(delimiter)) {
554
+ throw new Error(`Folder name can't contain the "${delimiter}" hierarchy separator.`);
555
+ }
556
+
557
+ // Resolve the destination parent (reparent) or keep the existing parent
558
+ // prefix (in-place rename).
559
+ let newPath: string;
560
+ let parent: typeof folder | undefined;
561
+ if (newParentId != null) {
562
+ parent = folders.find(f => f.id === newParentId);
563
+ if (!parent) throw new Error("Target parent folder not found");
564
+ if (parent.id === folder.id) throw new Error("Can't move a folder into itself.");
565
+ // Reparenting under a descendant would orphan the moved subtree.
566
+ if (parent.path === folder.path || parent.path.startsWith(folder.path + delimiter)) {
567
+ throw new Error("Can't move a folder into one of its own subfolders.");
568
+ }
569
+ newPath = parent.path + delimiter + leaf;
570
+ } else {
571
+ const idx = folder.path.lastIndexOf(delimiter);
572
+ const parentPrefix = idx >= 0 ? folder.path.slice(0, idx + delimiter.length) : "";
573
+ newPath = parentPrefix + leaf;
574
+ }
575
+
576
+ if (newPath === folder.path) return; // no-op (same name + same parent)
577
+ if (folders.some(f => f.path === newPath)) {
578
+ throw new Error(`A folder named "${leaf}" already exists here.`);
579
+ }
580
+
581
+ // ── Server op ────────────────────────────────────────────────────────
582
+ // API accounts (Gmail labels / Outlook Graph folders) have native
583
+ // rename; IMAP accounts use RFC 3501 RENAME, which renames the whole
584
+ // subtree server-side in one command.
585
+ if (this.isApiAccount(accountId)) {
586
+ const api = this.getApiProvider(accountId) as MailProvider;
587
+ if (!api.renameFolder) {
588
+ throw new Error("This account's provider doesn't support folder rename.");
589
+ }
590
+ // Gmail wants the parent label PATH; Outlook wants the parent Graph
591
+ // id — both are the parent folder's `path` in our model.
592
+ await api.renameFolder(folder.path, leaf, parent?.path);
593
+ } else {
594
+ await this.renameFolderViaServer(accountId, folder.path, newPath);
595
+ }
596
+
597
+ // ── Local commit ─────────────────────────────────────────────────────
598
+ // All providers now use a HIERARCHICAL display path:
599
+ // - IMAP: server mailbox path (renameFolderViaServer also resyncs).
600
+ // - Gmail: label name (which is the slash-path).
601
+ // - Outlook: display-name path — the provider keeps a path↔Graph-id
602
+ // map internally, so renaming the path is safe (Graph ops resolve
603
+ // the id from the new path on the next list-sync).
604
+ // So the predicted newPath matches the server result; rewrite the row +
605
+ // descendant prefixes for an immediate tree update.
606
+ this.db.renameFolderPath(accountId, folder.path, newPath, leaf, delimiter);
607
+ this.emit("folderCountsChanged", accountId, {});
608
+ }
609
+
506
610
  /** Delete `path` on the server (tolerating already-gone). Local folder-row
507
611
  * removal stays with the caller (it knows the folderId). */
508
612
  async deleteFolderViaServer(accountId: string, path: string): Promise<void> {
@@ -907,21 +1011,50 @@ export class ImapManager extends EventEmitter {
907
1011
  // createAutoImapConfig auto-detects Gmail from server/username and sets up OAuth
908
1012
  // For OAuth accounts, provide a tokenProvider using oauthsupport
909
1013
  let tokenProvider: (() => Promise<string>) | undefined;
910
- if (account.imap.auth === "oauth2" || (!account.imap.password && account.imap.host?.includes("gmail"))) {
911
- // Find Google OAuth credentials check ~/.mailx first, then iflow-direct package
912
- let credPath = path.join(getConfigDir(), "google-credentials.json");
913
- if (!fs.existsSync(credPath)) {
914
- try {
915
- // Use fileURLToPath, NOT string-replace on "file://" — on Linux,
916
- // file:///usr/local/... loses its leading slash via .replace("file:///",
917
- // "") and becomes relative, so fs.existsSync silently fails.
918
- const pkgDir = path.dirname(fileURLToPath(import.meta.resolve("@bobfrankston/iflow-direct")));
919
- for (const name of ["iflow-credentials.json"]) {
920
- const p = path.join(pkgDir, name);
921
- if (fs.existsSync(p)) { credPath = p; break; }
922
- }
923
- } catch { /* iflow-direct not resolvable */ }
1014
+ if (account.imap.auth === "oauth2" || (!account.imap.password && account.imap.host?.includes("gmail")) || (!account.imap.password && isOutlookCfg(account))) {
1015
+ // Pick the OAuth provider by account kind. Outlook accounts use
1016
+ // Microsoft Graph credentials + scopes; everything else is Google.
1017
+ // Both flow through the same provider-agnostic oauthsupport code —
1018
+ // only the creds file and scope string differ (see docs/outlook.md).
1019
+ const outlook = isOutlookCfg(account);
1020
+ let credPath: string;
1021
+ if (outlook) {
1022
+ // Microsoft app registration creds. Check the per-machine
1023
+ // override (~/.rmfmail) first, then fall back to the file
1024
+ // BUNDLED IN THIS PACKAGE — same model as Google's creds
1025
+ // shipping inside iflow-direct. Because the client is PUBLIC
1026
+ // (PKCE, no secret), the bundled file holds only the public
1027
+ // client_id and is safe to commit/ship. So a fresh install
1028
+ // needs NO manual creds placement (see docs/outlook.md §A/B).
1029
+ credPath = path.join(getConfigDir(), "microsoft-credentials.json");
1030
+ if (!fs.existsSync(credPath)) {
1031
+ const bundled = path.join(path.dirname(fileURLToPath(import.meta.url)), "microsoft-credentials.json");
1032
+ if (fs.existsSync(bundled)) credPath = bundled;
1033
+ }
1034
+ } else {
1035
+ // Find Google OAuth credentials — check ~/.rmfmail first, then iflow-direct package
1036
+ credPath = path.join(getConfigDir(), "google-credentials.json");
1037
+ if (!fs.existsSync(credPath)) {
1038
+ try {
1039
+ // Use fileURLToPath, NOT string-replace on "file://" — on Linux,
1040
+ // file:///usr/local/... loses its leading slash via .replace("file:///",
1041
+ // "") and becomes relative, so fs.existsSync silently fails.
1042
+ const pkgDir = path.dirname(fileURLToPath(import.meta.resolve("@bobfrankston/iflow-direct")));
1043
+ for (const name of ["iflow-credentials.json"]) {
1044
+ const p = path.join(pkgDir, name);
1045
+ if (fs.existsSync(p)) { credPath = p; break; }
1046
+ }
1047
+ } catch { /* iflow-direct not resolvable */ }
1048
+ }
924
1049
  }
1050
+ // Scope set by provider. Google: full two-way sync of all
1051
+ // mailx-managed stores. Microsoft Graph: mail read/write + send +
1052
+ // offline refresh. (Graph and Outlook-IMAP scopes are DIFFERENT
1053
+ // audiences — we mint Graph because Outlook syncs/sends via Graph,
1054
+ // not IMAP/SMTP. See docs/outlook.md "Decision".)
1055
+ const oauthScope = outlook
1056
+ ? "https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/Mail.Send offline_access openid profile"
1057
+ : "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";
925
1058
  const tokenDir = path.join(getConfigDir(), "tokens", tokenDirName(account.imap.user));
926
1059
  tokenProvider = async () => {
927
1060
  // Wall-clock timeout on OAuth. Use a longer timeout when no
@@ -930,11 +1063,9 @@ export class ImapManager extends EventEmitter {
930
1063
  const hasToken = fs.existsSync(path.join(tokenDir, "oauth-token.json"));
931
1064
  const TOKEN_FETCH_TIMEOUT_MS = hasToken ? 30_000 : 120_000;
932
1065
  const authPromise = authenticateOAuth(credPath, {
933
- // Scope set covers two-way sync of all mailx-managed local
934
- // stores: mail (mail.google.com), contacts (full, not
935
- // readonly — we write edits back), calendar (full), tasks
936
- // (full), drive (for shared accounts.jsonc).
937
- 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",
1066
+ // Scope set chosen per provider above (Google full-store
1067
+ // sync, or Microsoft Graph mail).
1068
+ scope: oauthScope,
938
1069
  tokenDirectory: tokenDir,
939
1070
  credentialsKey: "installed",
940
1071
  loginHint: account.imap.user,
@@ -980,6 +1111,15 @@ export class ImapManager extends EventEmitter {
980
1111
  greetingTimeout: isGmail ? 10000 : 30000,
981
1112
  });
982
1113
 
1114
+ // createAutoImapConfig only threads the tokenProvider through for GMAIL
1115
+ // hosts — its non-Gmail branch drops it (it assumes non-Gmail == password
1116
+ // IMAP). Outlook is OAuth-but-not-Gmail: it never opens IMAP (it syncs via
1117
+ // Graph), but getOutlookProvider reads config.tokenProvider. So re-attach
1118
+ // it here for any OAuth account whose host createAutoImapConfig didn't
1119
+ // recognize. Without this, an Outlook account silently has no provider →
1120
+ // "No tokenProvider for outlook" on every sync (Bob 2026-06-21).
1121
+ if (tokenProvider && !config.tokenProvider) config.tokenProvider = tokenProvider;
1122
+
983
1123
  this.configs.set(account.id, config);
984
1124
 
985
1125
  // Register account in DB
@@ -1020,6 +1160,39 @@ export class ImapManager extends EventEmitter {
1020
1160
  return new GmailApiProvider(config.tokenProvider);
1021
1161
  }
1022
1162
 
1163
+ /** Check if an account is Outlook.com / Microsoft 365 (should use Graph API
1164
+ * instead of IMAP). Detection mirrors isGmailAccount: host or email domain.
1165
+ * office365 IMAP front-ends and consumer outlook.com both route here. */
1166
+ private isOutlookAccount(accountId: string): boolean {
1167
+ const settings = loadSettings();
1168
+ const account = settings.accounts.find(a => a.id === accountId);
1169
+ if (!account) return false;
1170
+ return isOutlookCfg(account);
1171
+ }
1172
+
1173
+ /** True for any account that syncs via a REST provider (Gmail or Outlook)
1174
+ * rather than IMAP. The branch points that used to ask isGmailAccount to
1175
+ * mean "API, not IMAP" ask this instead. */
1176
+ private isApiAccount(accountId: string): boolean {
1177
+ return this.isGmailAccount(accountId) || this.isOutlookAccount(accountId);
1178
+ }
1179
+
1180
+ /** Get a Microsoft Graph provider for an account (reuses the tokenProvider —
1181
+ * which mints Graph-scoped tokens for Outlook accounts, see addAccount). */
1182
+ private getOutlookProvider(accountId: string): OutlookApiProvider {
1183
+ const config = this.configs.get(accountId);
1184
+ if (!config?.tokenProvider) throw new Error(`No tokenProvider for ${accountId}`);
1185
+ return new OutlookApiProvider(config.tokenProvider);
1186
+ }
1187
+
1188
+ /** Get the right REST provider (Gmail or Outlook) for an API account.
1189
+ * Both implement MailProvider plus the setFlags/trashMessage/moveMessage/
1190
+ * fetchBodiesBatch surface the dispatcher uses, so callers stay generic. */
1191
+ private getApiProvider(accountId: string): MailProvider {
1192
+ if (this.isOutlookAccount(accountId)) return this.getOutlookProvider(accountId);
1193
+ return this.getGmailProvider(accountId);
1194
+ }
1195
+
1023
1196
  /** Convert ProviderMessage to the shape expected by storeMessages/upsertMessage */
1024
1197
  private providerMsgToLocal(msg: ProviderMessage): any {
1025
1198
  return {
@@ -2085,8 +2258,8 @@ export class ImapManager extends EventEmitter {
2085
2258
 
2086
2259
  /** Sync a single account — manages its own connection lifecycle */
2087
2260
  private async syncAccount(accountId: string, priorityOrder: string[]): Promise<void> {
2088
- // Gmail: use REST API instead of IMAP
2089
- if (this.isGmailAccount(accountId)) {
2261
+ // Gmail / Outlook: use REST API instead of IMAP
2262
+ if (this.isApiAccount(accountId)) {
2090
2263
  return this.syncAccountViaApi(accountId, priorityOrder);
2091
2264
  }
2092
2265
 
@@ -2298,10 +2471,10 @@ export class ImapManager extends EventEmitter {
2298
2471
  }
2299
2472
  }
2300
2473
 
2301
- /** Sync a Gmail account via REST API — no IMAP connections */
2474
+ /** Sync a Gmail or Outlook account via REST API — no IMAP connections */
2302
2475
  private async syncAccountViaApi(accountId: string, priorityOrder: string[]): Promise<void> {
2303
2476
  try {
2304
- const api = this.getGmailProvider(accountId);
2477
+ const api = this.getApiProvider(accountId);
2305
2478
  const t0 = Date.now();
2306
2479
 
2307
2480
  // Step 1: Sync folder list via API
@@ -2310,9 +2483,28 @@ export class ImapManager extends EventEmitter {
2310
2483
  console.log(` [api] ${accountId}: ${apiFolders.length} labels in ${Date.now() - t0}ms`);
2311
2484
 
2312
2485
  // Store folders in DB (same as IMAP path)
2486
+ const serverPaths = new Set<string>();
2313
2487
  for (const f of apiFolders) {
2314
2488
  const specialUse = f.specialUse || "";
2315
2489
  this.db.upsertFolder(accountId, f.path, f.name, specialUse, f.delimiter);
2490
+ serverPaths.add(f.path);
2491
+ }
2492
+ // Prune local folders the provider no longer returns. The IMAP path
2493
+ // (syncFolders) already does this; the API path didn't, so a folder
2494
+ // deleted on the server — OR whose path SCHEME changed (e.g. the
2495
+ // Outlook id→hierarchical-path migration) — lingered forever as a
2496
+ // stale/duplicate row. Same guards as syncFolders: only prune on a
2497
+ // non-empty list (empty == transient error, not "all gone") and
2498
+ // never drop INBOX.
2499
+ if (apiFolders.length > 0) {
2500
+ const stale = this.db.getFolders(accountId).filter(f =>
2501
+ !serverPaths.has(f.path) && f.specialUse !== "inbox");
2502
+ for (const f of stale) {
2503
+ console.log(` [api] ${accountId}: pruning stale folder "${f.path}" (id=${f.id}) — not in provider list`);
2504
+ try { this.db.deleteFolder(f.id); } catch (e: any) {
2505
+ console.error(` [api] ${accountId}: prune failed for "${f.path}": ${e.message}`);
2506
+ }
2507
+ }
2316
2508
  }
2317
2509
  this.emit("folderCountsChanged", accountId, {});
2318
2510
  const dbFolders = this.db.getFolders(accountId);
@@ -2577,7 +2769,7 @@ export class ImapManager extends EventEmitter {
2577
2769
  * on a large mailbox). The 5-minute STATUS poll path still runs full
2578
2770
  * `syncFolder` so deletions and gaps eventually reconcile. */
2579
2771
  async syncInboxNewOnly(accountId: string): Promise<void> {
2580
- if (this.isGmailAccount(accountId)) return; // IDLE is IMAP-only
2772
+ if (this.isApiAccount(accountId)) return; // IDLE is IMAP-only (Gmail/Outlook poll via API)
2581
2773
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
2582
2774
  if (!inbox) return;
2583
2775
  try {
@@ -2621,8 +2813,8 @@ export class ImapManager extends EventEmitter {
2621
2813
  // mismatch — API uses hashed IDs, IMAP uses server-assigned
2622
2814
  // UIDs. The IMAP reconcile then deletes every API-synced
2623
2815
  // message because their UIDs don't appear in the IMAP list.
2624
- if (this.isGmailAccount(accountId)) {
2625
- const api = this.getGmailProvider(accountId);
2816
+ if (this.isApiAccount(accountId)) {
2817
+ const api = this.getApiProvider(accountId);
2626
2818
  try {
2627
2819
  await this.syncFolderViaApi(accountId, inbox, api);
2628
2820
  } finally {
@@ -2680,9 +2872,27 @@ export class ImapManager extends EventEmitter {
2680
2872
  /** Check a single account's inbox — uses its own connection, never blocked by sync */
2681
2873
  async quickInboxCheckAccount(accountId: string): Promise<void> {
2682
2874
  if (this.isGmailAccount(accountId)) return this.quickGmailCheck(accountId);
2875
+ if (this.isOutlookAccount(accountId)) return this.quickApiCheck(accountId);
2683
2876
  return this.quickImapCheck(accountId);
2684
2877
  }
2685
2878
 
2879
+ /** Lightweight inbox refresh for a generic API account (Outlook). Unlike
2880
+ * quickGmailCheck there's no cheap "top message id" probe — a single
2881
+ * incremental syncFolderViaApi is itself cheap (Graph delta-ish page), so
2882
+ * we just run it and recalc counts. */
2883
+ private async quickApiCheck(accountId: string): Promise<void> {
2884
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
2885
+ if (!inbox) return;
2886
+ const api = this.getApiProvider(accountId);
2887
+ try {
2888
+ await this.syncFolderViaApi(accountId, inbox, api);
2889
+ this.db.recalcFolderCounts(inbox.id);
2890
+ this.emit("folderCountsChanged", accountId, {});
2891
+ } finally {
2892
+ try { await api.close(); } catch { /* ignore */ }
2893
+ }
2894
+ }
2895
+
2686
2896
  private async quickImapCheck(accountId: string): Promise<void> {
2687
2897
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
2688
2898
  if (!inbox) return;
@@ -2757,21 +2967,21 @@ export class ImapManager extends EventEmitter {
2757
2967
  // Gmail uses API sync, not IMAP STATUS. IMAP accounts use IDLE
2758
2968
  // which gives instant push — the STATUS poll is just a fallback
2759
2969
  // in case IDLE silently dropped.
2760
- const isGmail = this.isGmailAccount(accountId);
2970
+ const isApi = this.isApiAccount(accountId);
2761
2971
  // IMAP accounts: IDLE gives instant push; STATUS poll is just a
2762
2972
  // safety net for silent IDLE drops — keep it infrequent.
2763
- // Gmail accounts: no IDLE (Gmail API doesn't expose it), so the
2764
- // quick poll IS the primary path to new-mail latency. Drop to 30s
2765
- // so Gmail mail appears in ~15s average. Gmail quota budget is
2766
- // huge (250 units/sec per user, 1.2B/day) 120 polls/hour × 5
2767
- // units 600/hour, trivial. Dovecot accounts stay at 5min to
2768
- // respect connection limits (each poll = fresh connection).
2769
- const interval = isGmail ? 30000 : 300000; // Gmail: 30s; IMAP: 5min
2973
+ // API accounts (Gmail/Outlook): no IDLE, so the quick poll IS the
2974
+ // primary path to new-mail latency. Drop to 30s so mail appears in
2975
+ // ~15s average. Both providers have ample request budget and their
2976
+ // own rate-limiters, so a fresh poll is cheap. Dovecot accounts
2977
+ // stay at 5min to respect connection limits (each poll = fresh
2978
+ // connection).
2979
+ const interval = isApi ? 30000 : 300000; // API: 30s; IMAP: 5min
2770
2980
  const timer = setInterval(() => {
2771
2981
  this.quickInboxCheckAccount(accountId).catch(() => {});
2772
2982
  }, interval);
2773
2983
  this.syncIntervals.set(`quick:${accountId}`, timer);
2774
- console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${isGmail ? "API" : "IMAP+IDLE"})`);
2984
+ console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${isApi ? "API" : "IMAP+IDLE"})`);
2775
2985
  }
2776
2986
 
2777
2987
  // DEADMAN: IDLE silently dropped on 2026-05-27 at 10:07 (empty
@@ -2785,7 +2995,7 @@ export class ImapManager extends EventEmitter {
2785
2995
  // the log.
2786
2996
  const deadmanInterval = setInterval(() => {
2787
2997
  for (const [accountId] of this.configs) {
2788
- if (this.isGmailAccount(accountId)) continue; // Gmail = API, no IDLE
2998
+ if (this.isApiAccount(accountId)) continue; // Gmail/Outlook = API, no IDLE
2789
2999
  if (this.watchers.has(accountId)) continue;
2790
3000
  console.log(` [idle-deadman] ${accountId}: no IDLE watcher — attempting restart`);
2791
3001
  this.startWatching().catch(e =>
@@ -3149,8 +3359,8 @@ export class ImapManager extends EventEmitter {
3149
3359
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
3150
3360
  if (!folder) return null;
3151
3361
 
3152
- // Gmail: REST API, no IMAP connection involved.
3153
- if (this.isGmailAccount(accountId)) {
3362
+ // Gmail / Outlook: REST API, no IMAP connection involved.
3363
+ if (this.isApiAccount(accountId)) {
3154
3364
  return this.fetchMessageBodyViaApi(accountId, folderId, uid, folder.path);
3155
3365
  }
3156
3366
 
@@ -3188,7 +3398,7 @@ export class ImapManager extends EventEmitter {
3188
3398
  * delete the stale row locally instead of showing a generic error. */
3189
3399
  private async fetchMessageBodyViaApi(accountId: string, folderId: number, uid: number, folderPath: string): Promise<Buffer | null> {
3190
3400
  try {
3191
- const api = this.getGmailProvider(accountId);
3401
+ const api = this.getApiProvider(accountId);
3192
3402
  // Read provider_id from the local row so fetchOne can skip the
3193
3403
  // listMessageIds pagination (the dominant Gmail rate-limit cost).
3194
3404
  const env = this.db.getMessageByUid(accountId, uid, folderId);
@@ -3332,7 +3542,7 @@ export class ImapManager extends EventEmitter {
3332
3542
  const ERROR_BUDGET = 20;
3333
3543
  const RATE_LIMIT_PAUSE_MS = 30000;
3334
3544
  const BATCH_SIZE = 100;
3335
- const isGmail = this.isGmailAccount(accountId);
3545
+ const isApi = this.isApiAccount(accountId);
3336
3546
  // Gmail still uses per-message fetch (HTTP /batch is a separate TODO in this file's
3337
3547
  // governing unit). IMAP uses the batched `fetchBodiesBatch` path via iflow-direct —
3338
3548
  // one SELECT + one UID FETCH per folder per tick instead of N round trips.
@@ -3369,13 +3579,13 @@ export class ImapManager extends EventEmitter {
3369
3579
  }
3370
3580
  let madeProgress = false;
3371
3581
 
3372
- if (isGmail) {
3373
- // Gmail batch path: group by label (what mailx calls "folder"),
3374
- // list once per label, bounded-concurrency fetch. Far fewer
3375
- // HTTP round trips than the old one-listMessageIds-per-body path.
3376
- // Note on the model: Gmail has labels, not folders. A message in
3377
- // multiple labels gets fetched twice under current grouping. A
3378
- // deeper label-native redesign is tracked as a separate TODO.
3582
+ if (isApi) {
3583
+ // API batch path (Gmail/Outlook): group by folder/label, list
3584
+ // once per folder, bounded-concurrency fetch. Far fewer HTTP
3585
+ // round trips than the old one-list-per-body path.
3586
+ // Note on the Gmail model: Gmail has labels, not folders. A
3587
+ // message in multiple labels gets fetched twice under current
3588
+ // grouping. A deeper label-native redesign is a separate TODO.
3379
3589
  const byFolder = new Map<number, number[]>();
3380
3590
  for (const m of missing) {
3381
3591
  let arr = byFolder.get(m.folderId);
@@ -3383,7 +3593,7 @@ export class ImapManager extends EventEmitter {
3383
3593
  arr.push(m.uid);
3384
3594
  }
3385
3595
  const folders = this.db.getFolders(accountId);
3386
- const api = this.getGmailProvider(accountId);
3596
+ const api = this.getApiProvider(accountId) as any;
3387
3597
  try {
3388
3598
  for (const [folderId, uidsInFolder] of byFolder) {
3389
3599
  const folder = folders.find(f => f.id === folderId);
@@ -3754,8 +3964,8 @@ export class ImapManager extends EventEmitter {
3754
3964
  // through withConnection → IMAP, which silently no-op'd for Gmail
3755
3965
  // accounts (REST-only, no IMAP connection) and left local-only stars
3756
3966
  // that vanished on the next full sync.
3757
- if (this.isGmailAccount(accountId)) {
3758
- const api = this.getGmailProvider(accountId) as any;
3967
+ if (this.isApiAccount(accountId)) {
3968
+ const api = this.getApiProvider(accountId) as any;
3759
3969
  try {
3760
3970
  for (const action of actions) {
3761
3971
  const folder = folders.find(f => f.id === action.folderId);
@@ -3879,16 +4089,33 @@ export class ImapManager extends EventEmitter {
3879
4089
  break;
3880
4090
  }
3881
4091
 
3882
- case "flags":
3883
- if (action.flags.length > 0) {
3884
- await client.addFlags(folder.path, action.uid, action.flags.filter(f => !f.startsWith("-")));
3885
- const toRemove = action.flags.filter(f => f.startsWith("-")).map(f => f.slice(1));
3886
- if (toRemove.length > 0) {
3887
- await client.removeFlags(folder.path, action.uid, toRemove);
3888
- }
3889
- console.log(` [sync] Updated flags UID ${action.uid}`);
4092
+ case "flags": {
4093
+ // action.flags is the ABSOLUTE desired flag set: the UI
4094
+ // sends the whole array with the toggled flag added or
4095
+ // simply OMITTED it does NOT use the `-remove` convention.
4096
+ // The old code only ever called addFlags (additive), so
4097
+ // CLEARING a star never reached the server and the flag came
4098
+ // back on the next sync — "I shut off the stars and they
4099
+ // suddenly come back, still flagged on the server" (Bob
4100
+ // 2026-06-26). The old `length > 0` guard also made clearing
4101
+ // the last remaining flag a complete no-op. Reconcile to the
4102
+ // absolute state instead: add the desired flags, and remove
4103
+ // any flag mailx manages (\Seen / \Flagged) that's no longer
4104
+ // present. Mirrors the Gmail provider's add+remove approach.
4105
+ const desired = action.flags.filter(f => !f.startsWith("-"));
4106
+ const explicitRemove = action.flags.filter(f => f.startsWith("-")).map(f => f.slice(1));
4107
+ const MANAGED = ["\\Seen", "\\Flagged"];
4108
+ const impliedRemove = MANAGED.filter(f => !desired.includes(f));
4109
+ const toRemove = [...new Set([...explicitRemove, ...impliedRemove])];
4110
+ if (desired.length > 0) {
4111
+ await client.addFlags(folder.path, action.uid, desired);
4112
+ }
4113
+ if (toRemove.length > 0) {
4114
+ await client.removeFlags(folder.path, action.uid, toRemove);
3890
4115
  }
4116
+ console.log(` [sync] Updated flags UID ${action.uid} (+[${desired.join(",")}] -[${toRemove.join(",")}])`);
3891
4117
  break;
4118
+ }
3892
4119
 
3893
4120
  case "append": {
3894
4121
  if (action.rawMessage) {
@@ -4572,11 +4799,14 @@ export class ImapManager extends EventEmitter {
4572
4799
  }
4573
4800
  if (filesToSend.length === 0) return;
4574
4801
 
4575
- // Gmail/API accounts: send directly via SMTP from local queue (no IMAP outbox)
4802
+ // API accounts (Gmail/Outlook): send directly from the local queue (no
4803
+ // IMAP outbox). Gmail goes via SMTP+XOAUTH2 (mail.google.com scope
4804
+ // covers SMTP); Outlook goes via Graph /sendMail (its token is Graph-
4805
+ // scoped, not SMTP) — sendRawForAccount picks the right transport.
4576
4806
  const sentDir = path.join(getConfigDir(), "sending", accountId, "sent");
4577
4807
  fs.mkdirSync(sentDir, { recursive: true });
4578
4808
 
4579
- if (this.isGmailAccount(accountId)) {
4809
+ if (this.isApiAccount(accountId)) {
4580
4810
  const nowMs = Date.now();
4581
4811
  for (const { dir, file } of filesToSend) {
4582
4812
  const filePath = path.join(dir, file);
@@ -4617,9 +4847,9 @@ export class ImapManager extends EventEmitter {
4617
4847
  fs.writeFileSync(claimedPath, raw, "utf-8");
4618
4848
 
4619
4849
  try {
4620
- await this.sendRawViaSMTP(accountId, raw);
4850
+ await this.sendRawForAccount(accountId, raw);
4621
4851
  fs.renameSync(claimedPath, path.join(sentDir, file));
4622
- console.log(` [outbox] Sent ${file} via SMTP → sent/ (attempt ${attempt})`);
4852
+ console.log(` [outbox] Sent ${file} → sent/ (attempt ${attempt})`);
4623
4853
  } catch (e: any) {
4624
4854
  // Persist a next-attempt timestamp and release the claim so the
4625
4855
  // file is visible to the scan loop again.
@@ -4701,6 +4931,21 @@ export class ImapManager extends EventEmitter {
4701
4931
  }
4702
4932
  }
4703
4933
 
4934
+ /** Send a raw RFC 2822 message for an account, choosing the transport by
4935
+ * provider. Outlook accounts have a Graph-scoped OAuth token (no SMTP
4936
+ * audience), so they send via Graph POST /sendMail; Gmail and IMAP
4937
+ * accounts send via SMTP (Gmail's mail.google.com token covers XOAUTH2
4938
+ * SMTP). Graph /sendMail auto-files a copy in Sent, so no append needed. */
4939
+ private async sendRawForAccount(accountId: string, raw: string): Promise<void> {
4940
+ if (this.isOutlookAccount(accountId)) {
4941
+ const api = this.getOutlookProvider(accountId);
4942
+ await api.sendRaw(raw);
4943
+ console.log(` [graph] ${accountId}: sent via /sendMail`);
4944
+ return;
4945
+ }
4946
+ await this.sendRawViaSMTP(accountId, raw);
4947
+ }
4948
+
4704
4949
  /** Send a raw RFC 2822 message via SMTP for a given account.
4705
4950
  * Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
4706
4951
  * same TCP byte-stream interface, no nodemailer dependency. */
@@ -4784,8 +5029,9 @@ export class ImapManager extends EventEmitter {
4784
5029
  const outboxFolder = this.findFolder(accountId, "outbox");
4785
5030
  if (!outboxFolder) return;
4786
5031
 
4787
- // Gmail: skip IMAP outbox check — sending handled by processLocalQueue which sends directly via SMTP
4788
- if (this.isGmailAccount(accountId)) return;
5032
+ // Gmail/Outlook: skip IMAP outbox — sending handled by processLocalQueue
5033
+ // (Gmail via SMTP, Outlook via Graph /sendMail).
5034
+ if (this.isApiAccount(accountId)) return;
4789
5035
 
4790
5036
  const settings = loadSettings();
4791
5037
  const account = settings.accounts.find(a => a.id === accountId);
@@ -5119,7 +5365,7 @@ export class ImapManager extends EventEmitter {
5119
5365
  if (this.sentSweepInterval) return;
5120
5366
  const tick = async (): Promise<void> => {
5121
5367
  for (const [accountId] of this.configs) {
5122
- if (this.isGmailAccount(accountId)) continue;
5368
+ if (this.isApiAccount(accountId)) continue;
5123
5369
  try { await this.sweepSentOnce(accountId); }
5124
5370
  catch (e: any) { console.error(` [sent-sweep] ${accountId}: ${e?.message || e}`); }
5125
5371
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "installed": {
3
+ "client_id": "f0df4236-5d26-4aa3-a243-47f043b9db36",
4
+ "auth_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
5
+ "token_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
6
+ "redirect_uris": ["http://localhost:9327"]
7
+ }
8
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.106",
3
+ "version": "0.1.108",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.106",
9
+ "version": "0.1.108",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/iflow-direct": "^0.1.27",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.106",
3
+ "version": "0.1.108",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Back-compat re-export. Canonical Outlook provider lives in
3
+ * @bobfrankston/mailx-sync. Same Provider abstraction as Gmail + IMAP — when
4
+ * Android adds Outlook support, both platforms share this single class.
5
+ */
6
+ export { OutlookApiProvider } from "@bobfrankston/mailx-sync";
7
+ //# sourceMappingURL=outlook-api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outlook-api.d.ts","sourceRoot":"","sources":["outlook-api.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Back-compat re-export. Canonical Outlook provider lives in
3
+ * @bobfrankston/mailx-sync. Same Provider abstraction as Gmail + IMAP — when
4
+ * Android adds Outlook support, both platforms share this single class.
5
+ */
6
+ export { OutlookApiProvider } from "@bobfrankston/mailx-sync";
7
+ //# sourceMappingURL=outlook-api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outlook-api.js","sourceRoot":"","sources":["outlook-api.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC"}