@bobfrankston/rmfmail 1.2.49 → 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.
- package/client/android-bootstrap.bundle.js +63 -2
- package/client/android-bootstrap.bundle.js.map +4 -4
- package/client/app.bundle.js +417 -63
- package/client/app.bundle.js.map +3 -3
- package/client/app.js +182 -24
- package/client/app.js.map +1 -1
- package/client/app.ts +166 -21
- package/client/components/calendar-sidebar.js +5 -5
- package/client/components/calendar-sidebar.js.map +1 -1
- package/client/components/calendar-sidebar.ts +5 -5
- package/client/components/folder-tree.js +81 -0
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +70 -0
- package/client/components/message-list.js +282 -38
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +264 -36
- package/client/compose/compose.bundle.js +70 -14
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/compose.css +28 -7
- package/client/compose/compose.js +64 -4
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +66 -4
- package/client/compose/editor.js +23 -7
- package/client/compose/editor.js.map +1 -1
- package/client/compose/editor.ts +23 -7
- package/client/help/search-help.js +4 -3
- package/client/help/search-help.js.map +1 -1
- package/client/help/search-help.ts +4 -3
- package/client/index.html +5 -1
- package/client/lib/api-client.js +4 -2
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +4 -2
- package/client/lib/mailxapi.js +2 -2
- package/client/lib/rmf-tiny.js +39 -6
- package/client/styles/components.css +6 -0
- package/docs/azure.md +58 -0
- package/docs/config-help.md +131 -0
- package/docs/edit-in-word-docx.md +98 -0
- package/docs/host-abstraction-plan.md +169 -0
- package/docs/local-first-plan.md +303 -0
- package/docs/npmglobalize-transitive-workspace-deps.md +107 -0
- package/docs/outlook.md +80 -18
- package/docs/outlook.txt +35 -0
- package/docs/rules-design.md +172 -0
- package/package.json +7 -7
- package/packages/mailx-imap/index.d.ts +48 -1
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +332 -70
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +323 -67
- package/packages/mailx-imap/microsoft-credentials.json +8 -0
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-imap/providers/outlook-api.d.ts +7 -0
- package/packages/mailx-imap/providers/outlook-api.d.ts.map +1 -0
- package/packages/mailx-imap/providers/outlook-api.js +7 -0
- package/packages/mailx-imap/providers/outlook-api.js.map +1 -0
- package/packages/mailx-service/index.d.ts +7 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +8 -8
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +8 -7
- package/packages/mailx-service/jsonrpc.js +1 -1
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +1 -1
- package/packages/mailx-settings/docs/azure.md +58 -0
- package/packages/mailx-settings/docs/config-help.md +131 -0
- package/packages/mailx-settings/docs/edit-in-word-docx.md +98 -0
- package/packages/mailx-settings/docs/host-abstraction-plan.md +169 -0
- package/packages/mailx-settings/docs/local-first-plan.md +303 -0
- package/packages/mailx-settings/docs/npmglobalize-transitive-workspace-deps.md +107 -0
- package/packages/mailx-settings/docs/outlook.md +80 -18
- package/packages/mailx-settings/docs/rules-design.md +172 -0
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +9 -8
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +9 -8
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/db.d.ts +50 -1
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +120 -6
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +126 -6
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-types/contact-rules.d.ts +1 -1
- package/packages/mailx-types/contact-rules.js +1 -1
- package/packages/mailx-types/contact-rules.js.map +1 -1
- package/packages/mailx-types/contact-rules.jsonc +1 -1
- package/packages/mailx-types/contact-rules.ts +1 -1
- 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
|
-
//
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
|
959
|
-
//
|
|
960
|
-
|
|
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 {
|
|
@@ -1339,7 +1512,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1339
1512
|
*/
|
|
1340
1513
|
async fetchServerOnlyUids(client, accountId, folderId, folder, statusMessageCount, excludeUids) {
|
|
1341
1514
|
const folderPath = folder.path;
|
|
1342
|
-
|
|
1515
|
+
// Diff against UIDs we actually have a MESSAGE ROW for (messages table),
|
|
1516
|
+
// NOT message_folders memberships. An orphan membership (member row but
|
|
1517
|
+
// no message row) would otherwise look "present" and never get its real
|
|
1518
|
+
// data fetched — leaving the message permanently missing from the list
|
|
1519
|
+
// while the folder count looks full (Bob 2026-06-20: ~1k old INBOX
|
|
1520
|
+
// messages stuck missing behind orphan memberships).
|
|
1521
|
+
const existingUids = this.db.getStoredUids(accountId, folderId);
|
|
1343
1522
|
// Small folders (Drafts, Sent, …) get a FULL UID fetch so server-side
|
|
1344
1523
|
// deletions of OLDER messages are reflected; large folders (134k INBOX)
|
|
1345
1524
|
// get a date-bounded query so we don't enumerate the whole mailbox.
|
|
@@ -1631,7 +1810,11 @@ export class ImapManager extends EventEmitter {
|
|
|
1631
1810
|
// retries instead of locking out for RECONCILE_THROTTLE_MS
|
|
1632
1811
|
// (Bob 2026-06-20: boot-time "Not connected" failure locked
|
|
1633
1812
|
// INBOX recovery out for 15 min).
|
|
1634
|
-
|
|
1813
|
+
// Compare STORED-ROW count (messages table) to the server's
|
|
1814
|
+
// count — not getMessageCount (message_folders), which orphan
|
|
1815
|
+
// memberships can inflate to match the server while real rows
|
|
1816
|
+
// are still missing.
|
|
1817
|
+
const localCount = this.db.getStoredCount(accountId, folderId);
|
|
1635
1818
|
const hasDeficit = statusMessageCount !== null && localCount < statusMessageCount;
|
|
1636
1819
|
const lastRecon = this.lastReconcileMs.get(folderId) ?? 0;
|
|
1637
1820
|
const throttleElapsed = Date.now() - lastRecon > RECONCILE_THROTTLE_MS;
|
|
@@ -2057,8 +2240,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2057
2240
|
}
|
|
2058
2241
|
/** Sync a single account — manages its own connection lifecycle */
|
|
2059
2242
|
async syncAccount(accountId, priorityOrder) {
|
|
2060
|
-
// Gmail: use REST API instead of IMAP
|
|
2061
|
-
if (this.
|
|
2243
|
+
// Gmail / Outlook: use REST API instead of IMAP
|
|
2244
|
+
if (this.isApiAccount(accountId)) {
|
|
2062
2245
|
return this.syncAccountViaApi(accountId, priorityOrder);
|
|
2063
2246
|
}
|
|
2064
2247
|
try {
|
|
@@ -2267,19 +2450,40 @@ export class ImapManager extends EventEmitter {
|
|
|
2267
2450
|
this.handleSyncError(accountId, errMsg);
|
|
2268
2451
|
}
|
|
2269
2452
|
}
|
|
2270
|
-
/** Sync a Gmail account via REST API — no IMAP connections */
|
|
2453
|
+
/** Sync a Gmail or Outlook account via REST API — no IMAP connections */
|
|
2271
2454
|
async syncAccountViaApi(accountId, priorityOrder) {
|
|
2272
2455
|
try {
|
|
2273
|
-
const api = this.
|
|
2456
|
+
const api = this.getApiProvider(accountId);
|
|
2274
2457
|
const t0 = Date.now();
|
|
2275
2458
|
// Step 1: Sync folder list via API
|
|
2276
2459
|
console.log(` [api] ${accountId}: listing labels...`);
|
|
2277
2460
|
const apiFolders = await api.listFolders();
|
|
2278
2461
|
console.log(` [api] ${accountId}: ${apiFolders.length} labels in ${Date.now() - t0}ms`);
|
|
2279
2462
|
// Store folders in DB (same as IMAP path)
|
|
2463
|
+
const serverPaths = new Set();
|
|
2280
2464
|
for (const f of apiFolders) {
|
|
2281
2465
|
const specialUse = f.specialUse || "";
|
|
2282
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
|
+
}
|
|
2283
2487
|
}
|
|
2284
2488
|
this.emit("folderCountsChanged", accountId, {});
|
|
2285
2489
|
const dbFolders = this.db.getFolders(accountId);
|
|
@@ -2552,8 +2756,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2552
2756
|
* on a large mailbox). The 5-minute STATUS poll path still runs full
|
|
2553
2757
|
* `syncFolder` so deletions and gaps eventually reconcile. */
|
|
2554
2758
|
async syncInboxNewOnly(accountId) {
|
|
2555
|
-
if (this.
|
|
2556
|
-
return; // IDLE is IMAP-only
|
|
2759
|
+
if (this.isApiAccount(accountId))
|
|
2760
|
+
return; // IDLE is IMAP-only (Gmail/Outlook poll via API)
|
|
2557
2761
|
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
2558
2762
|
if (!inbox)
|
|
2559
2763
|
return;
|
|
@@ -2601,8 +2805,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2601
2805
|
// mismatch — API uses hashed IDs, IMAP uses server-assigned
|
|
2602
2806
|
// UIDs. The IMAP reconcile then deletes every API-synced
|
|
2603
2807
|
// message because their UIDs don't appear in the IMAP list.
|
|
2604
|
-
if (this.
|
|
2605
|
-
const api = this.
|
|
2808
|
+
if (this.isApiAccount(accountId)) {
|
|
2809
|
+
const api = this.getApiProvider(accountId);
|
|
2606
2810
|
try {
|
|
2607
2811
|
await this.syncFolderViaApi(accountId, inbox, api);
|
|
2608
2812
|
}
|
|
@@ -2666,8 +2870,31 @@ export class ImapManager extends EventEmitter {
|
|
|
2666
2870
|
async quickInboxCheckAccount(accountId) {
|
|
2667
2871
|
if (this.isGmailAccount(accountId))
|
|
2668
2872
|
return this.quickGmailCheck(accountId);
|
|
2873
|
+
if (this.isOutlookAccount(accountId))
|
|
2874
|
+
return this.quickApiCheck(accountId);
|
|
2669
2875
|
return this.quickImapCheck(accountId);
|
|
2670
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
|
+
}
|
|
2671
2898
|
async quickImapCheck(accountId) {
|
|
2672
2899
|
const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
|
|
2673
2900
|
if (!inbox)
|
|
@@ -2739,21 +2966,21 @@ export class ImapManager extends EventEmitter {
|
|
|
2739
2966
|
// Gmail uses API sync, not IMAP STATUS. IMAP accounts use IDLE
|
|
2740
2967
|
// which gives instant push — the STATUS poll is just a fallback
|
|
2741
2968
|
// in case IDLE silently dropped.
|
|
2742
|
-
const
|
|
2969
|
+
const isApi = this.isApiAccount(accountId);
|
|
2743
2970
|
// IMAP accounts: IDLE gives instant push; STATUS poll is just a
|
|
2744
2971
|
// safety net for silent IDLE drops — keep it infrequent.
|
|
2745
|
-
//
|
|
2746
|
-
//
|
|
2747
|
-
//
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
//
|
|
2751
|
-
const interval =
|
|
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
|
|
2752
2979
|
const timer = setInterval(() => {
|
|
2753
2980
|
this.quickInboxCheckAccount(accountId).catch(() => { });
|
|
2754
2981
|
}, interval);
|
|
2755
2982
|
this.syncIntervals.set(`quick:${accountId}`, timer);
|
|
2756
|
-
console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${
|
|
2983
|
+
console.log(` [periodic] ${accountId}: STATUS check every ${interval / 1000}s (${isApi ? "API" : "IMAP+IDLE"})`);
|
|
2757
2984
|
}
|
|
2758
2985
|
// DEADMAN: IDLE silently dropped on 2026-05-27 at 10:07 (empty
|
|
2759
2986
|
// error message swallowed the cause) and 90 minutes of new mail
|
|
@@ -2766,8 +2993,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2766
2993
|
// the log.
|
|
2767
2994
|
const deadmanInterval = setInterval(() => {
|
|
2768
2995
|
for (const [accountId] of this.configs) {
|
|
2769
|
-
if (this.
|
|
2770
|
-
continue; // Gmail = API, no IDLE
|
|
2996
|
+
if (this.isApiAccount(accountId))
|
|
2997
|
+
continue; // Gmail/Outlook = API, no IDLE
|
|
2771
2998
|
if (this.watchers.has(accountId))
|
|
2772
2999
|
continue;
|
|
2773
3000
|
console.log(` [idle-deadman] ${accountId}: no IDLE watcher — attempting restart`);
|
|
@@ -3134,8 +3361,8 @@ export class ImapManager extends EventEmitter {
|
|
|
3134
3361
|
const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
3135
3362
|
if (!folder)
|
|
3136
3363
|
return null;
|
|
3137
|
-
// Gmail: REST API, no IMAP connection involved.
|
|
3138
|
-
if (this.
|
|
3364
|
+
// Gmail / Outlook: REST API, no IMAP connection involved.
|
|
3365
|
+
if (this.isApiAccount(accountId)) {
|
|
3139
3366
|
return this.fetchMessageBodyViaApi(accountId, folderId, uid, folder.path);
|
|
3140
3367
|
}
|
|
3141
3368
|
// IMAP: fast lane on the ops queue. One try; if the socket is stale,
|
|
@@ -3179,7 +3406,7 @@ export class ImapManager extends EventEmitter {
|
|
|
3179
3406
|
* delete the stale row locally instead of showing a generic error. */
|
|
3180
3407
|
async fetchMessageBodyViaApi(accountId, folderId, uid, folderPath) {
|
|
3181
3408
|
try {
|
|
3182
|
-
const api = this.
|
|
3409
|
+
const api = this.getApiProvider(accountId);
|
|
3183
3410
|
// Read provider_id from the local row so fetchOne can skip the
|
|
3184
3411
|
// listMessageIds pagination (the dominant Gmail rate-limit cost).
|
|
3185
3412
|
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
@@ -3331,7 +3558,7 @@ export class ImapManager extends EventEmitter {
|
|
|
3331
3558
|
const ERROR_BUDGET = 20;
|
|
3332
3559
|
const RATE_LIMIT_PAUSE_MS = 30000;
|
|
3333
3560
|
const BATCH_SIZE = 100;
|
|
3334
|
-
const
|
|
3561
|
+
const isApi = this.isApiAccount(accountId);
|
|
3335
3562
|
// Gmail still uses per-message fetch (HTTP /batch is a separate TODO in this file's
|
|
3336
3563
|
// governing unit). IMAP uses the batched `fetchBodiesBatch` path via iflow-direct —
|
|
3337
3564
|
// one SELECT + one UID FETCH per folder per tick instead of N round trips.
|
|
@@ -3368,13 +3595,13 @@ export class ImapManager extends EventEmitter {
|
|
|
3368
3595
|
announced = true;
|
|
3369
3596
|
}
|
|
3370
3597
|
let madeProgress = false;
|
|
3371
|
-
if (
|
|
3372
|
-
//
|
|
3373
|
-
//
|
|
3374
|
-
//
|
|
3375
|
-
// Note on the model: Gmail has labels, not folders. A
|
|
3376
|
-
// multiple labels gets fetched twice under current
|
|
3377
|
-
// deeper label-native redesign is
|
|
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.
|
|
3378
3605
|
const byFolder = new Map();
|
|
3379
3606
|
for (const m of missing) {
|
|
3380
3607
|
let arr = byFolder.get(m.folderId);
|
|
@@ -3385,7 +3612,7 @@ export class ImapManager extends EventEmitter {
|
|
|
3385
3612
|
arr.push(m.uid);
|
|
3386
3613
|
}
|
|
3387
3614
|
const folders = this.db.getFolders(accountId);
|
|
3388
|
-
const api = this.
|
|
3615
|
+
const api = this.getApiProvider(accountId);
|
|
3389
3616
|
try {
|
|
3390
3617
|
for (const [folderId, uidsInFolder] of byFolder) {
|
|
3391
3618
|
const folder = folders.find(f => f.id === folderId);
|
|
@@ -3786,8 +4013,8 @@ export class ImapManager extends EventEmitter {
|
|
|
3786
4013
|
// through withConnection → IMAP, which silently no-op'd for Gmail
|
|
3787
4014
|
// accounts (REST-only, no IMAP connection) and left local-only stars
|
|
3788
4015
|
// that vanished on the next full sync.
|
|
3789
|
-
if (this.
|
|
3790
|
-
const api = this.
|
|
4016
|
+
if (this.isApiAccount(accountId)) {
|
|
4017
|
+
const api = this.getApiProvider(accountId);
|
|
3791
4018
|
try {
|
|
3792
4019
|
for (const action of actions) {
|
|
3793
4020
|
const folder = folders.find(f => f.id === action.folderId);
|
|
@@ -3923,16 +4150,33 @@ export class ImapManager extends EventEmitter {
|
|
|
3923
4150
|
console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
|
|
3924
4151
|
break;
|
|
3925
4152
|
}
|
|
3926
|
-
case "flags":
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
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);
|
|
3934
4176
|
}
|
|
4177
|
+
console.log(` [sync] Updated flags UID ${action.uid} (+[${desired.join(",")}] -[${toRemove.join(",")}])`);
|
|
3935
4178
|
break;
|
|
4179
|
+
}
|
|
3936
4180
|
case "append": {
|
|
3937
4181
|
if (action.rawMessage) {
|
|
3938
4182
|
await client.appendMessage(folder.path, action.rawMessage, action.flags);
|
|
@@ -4664,10 +4908,13 @@ export class ImapManager extends EventEmitter {
|
|
|
4664
4908
|
}
|
|
4665
4909
|
if (filesToSend.length === 0)
|
|
4666
4910
|
return;
|
|
4667
|
-
//
|
|
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.
|
|
4668
4915
|
const sentDir = path.join(getConfigDir(), "sending", accountId, "sent");
|
|
4669
4916
|
fs.mkdirSync(sentDir, { recursive: true });
|
|
4670
|
-
if (this.
|
|
4917
|
+
if (this.isApiAccount(accountId)) {
|
|
4671
4918
|
const nowMs = Date.now();
|
|
4672
4919
|
for (const { dir, file } of filesToSend) {
|
|
4673
4920
|
const filePath = path.join(dir, file);
|
|
@@ -4708,9 +4955,9 @@ export class ImapManager extends EventEmitter {
|
|
|
4708
4955
|
raw = insertHeaderBeforeBody(raw, `X-Mailx-Retry: ${attempt} ${new Date().toISOString()}`);
|
|
4709
4956
|
fs.writeFileSync(claimedPath, raw, "utf-8");
|
|
4710
4957
|
try {
|
|
4711
|
-
await this.
|
|
4958
|
+
await this.sendRawForAccount(accountId, raw);
|
|
4712
4959
|
fs.renameSync(claimedPath, path.join(sentDir, file));
|
|
4713
|
-
console.log(` [outbox] Sent ${file}
|
|
4960
|
+
console.log(` [outbox] Sent ${file} → sent/ (attempt ${attempt})`);
|
|
4714
4961
|
}
|
|
4715
4962
|
catch (e) {
|
|
4716
4963
|
// Persist a next-attempt timestamp and release the claim so the
|
|
@@ -4806,6 +5053,20 @@ export class ImapManager extends EventEmitter {
|
|
|
4806
5053
|
// IMAP still unreachable — leave files for next attempt
|
|
4807
5054
|
}
|
|
4808
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
|
+
}
|
|
4809
5070
|
/** Send a raw RFC 2822 message via SMTP for a given account.
|
|
4810
5071
|
* Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
|
|
4811
5072
|
* same TCP byte-stream interface, no nodemailer dependency. */
|
|
@@ -4891,8 +5152,9 @@ export class ImapManager extends EventEmitter {
|
|
|
4891
5152
|
const outboxFolder = this.findFolder(accountId, "outbox");
|
|
4892
5153
|
if (!outboxFolder)
|
|
4893
5154
|
return;
|
|
4894
|
-
// Gmail: skip IMAP outbox
|
|
4895
|
-
|
|
5155
|
+
// Gmail/Outlook: skip IMAP outbox — sending handled by processLocalQueue
|
|
5156
|
+
// (Gmail via SMTP, Outlook via Graph /sendMail).
|
|
5157
|
+
if (this.isApiAccount(accountId))
|
|
4896
5158
|
return;
|
|
4897
5159
|
const settings = loadSettings();
|
|
4898
5160
|
const account = settings.accounts.find(a => a.id === accountId);
|
|
@@ -5239,7 +5501,7 @@ export class ImapManager extends EventEmitter {
|
|
|
5239
5501
|
return;
|
|
5240
5502
|
const tick = async () => {
|
|
5241
5503
|
for (const [accountId] of this.configs) {
|
|
5242
|
-
if (this.
|
|
5504
|
+
if (this.isApiAccount(accountId))
|
|
5243
5505
|
continue;
|
|
5244
5506
|
try {
|
|
5245
5507
|
await this.sweepSentOnce(accountId);
|