@bobfrankston/mailx 1.0.244 → 1.0.251

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.
@@ -3,7 +3,7 @@
3
3
  * Multi-account IMAP management wrapping iflow.
4
4
  * Syncs messages to local store, emits events for new mail.
5
5
  */
6
- import { type TransportFactory } from "@bobfrankston/iflow-direct";
6
+ import type { TransportFactory } from "@bobfrankston/tcp-transport";
7
7
  import { MailxDB, FileMessageStore } from "@bobfrankston/mailx-store";
8
8
  import type { AccountConfig, MessageEnvelope, Folder } from "@bobfrankston/mailx-types";
9
9
  import { EventEmitter } from "node:events";
@@ -103,14 +103,19 @@ export declare class ImapManager extends EventEmitter {
103
103
  private handleSyncError;
104
104
  /** Sync just INBOX for each account (fast check for new mail) */
105
105
  syncInbox(): Promise<void>;
106
- /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
107
- * If message count changed, triggers inbox sync for that account. */
108
- private lastInboxCounts;
106
+ /** Quick inbox check — per-account lightweight probe.
107
+ * If the probe value changed since last time, triggers an inbox sync.
108
+ * The marker is only advanced after a successful sync so that a failed
109
+ * sync doesn't eat the "new mail" signal and make us stop retrying. */
110
+ private lastInboxMarker;
109
111
  private quickCheckRunning;
112
+ /** Shared quick-check skeleton: probe → compare → sync-if-changed → advance marker.
113
+ * `probe` returns the current marker value; `sync` runs only when it differs
114
+ * from the previously stored value. Marker is advanced only after sync resolves. */
115
+ private quickCheck;
110
116
  /** Check a single account's inbox — uses its own connection, never blocked by sync */
111
117
  quickInboxCheckAccount(accountId: string): Promise<void>;
112
- /** Quick Gmail inbox check — one lightweight API call to check for new messages */
113
- private lastGmailInboxTop;
118
+ private quickImapCheck;
114
119
  private quickGmailCheck;
115
120
  /** Check all accounts (used by legacy callers) */
116
121
  quickInboxCheck(): Promise<void>;
@@ -203,7 +208,9 @@ export declare class ImapManager extends EventEmitter {
203
208
  * directory is safe again. Any legitimate files that land there (crash
204
209
  * recovery, manual drop) will get sent. */
205
210
  private processLocalQueue;
206
- /** Send a raw RFC 2822 message via SMTP for a given account */
211
+ /** Send a raw RFC 2822 message via SMTP for a given account.
212
+ * Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
213
+ * same TCP byte-stream interface, no nodemailer dependency. */
207
214
  private sendRawViaSMTP;
208
215
  /** Process Outbox — send pending messages with flag-based interlock */
209
216
  processOutbox(accountId: string): Promise<void>;
@@ -12,7 +12,9 @@ import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { simpleParser } from "mailparser";
14
14
  import { GmailApiProvider } from "./providers/gmail-api.js";
15
+ import { SmtpClient } from "@bobfrankston/smtp-direct";
15
16
  import * as os from "node:os";
17
+ import { fileURLToPath } from "node:url";
16
18
  // Well-known ports — no magic numbers
17
19
  const SMTP_PORT_STARTTLS = 587;
18
20
  const SMTP_PORT_IMPLICIT_TLS = 465;
@@ -367,7 +369,10 @@ export class ImapManager extends EventEmitter {
367
369
  let credPath = path.join(getConfigDir(), "google-credentials.json");
368
370
  if (!fs.existsSync(credPath)) {
369
371
  try {
370
- const pkgDir = path.dirname(import.meta.resolve("@bobfrankston/iflow-direct").replace("file:///", "").replace("file://", ""));
372
+ // Use fileURLToPath, NOT string-replace on "file://" — on Linux,
373
+ // file:///usr/local/... loses its leading slash via .replace("file:///",
374
+ // "") and becomes relative, so fs.existsSync silently fails.
375
+ const pkgDir = path.dirname(fileURLToPath(import.meta.resolve("@bobfrankston/iflow-direct")));
371
376
  for (const name of ["iflow-credentials.json"]) {
372
377
  const p = path.join(pkgDir, name);
373
378
  if (fs.existsSync(p)) {
@@ -758,6 +763,7 @@ export class ImapManager extends EventEmitter {
758
763
  // Use recalcFolderCounts — single SQL query instead of fetching all messages
759
764
  this.db.recalcFolderCounts(folderId);
760
765
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
766
+ const syncedAt = Date.now();
761
767
  // Notify client to refresh if anything changed
762
768
  if (newCount > 0 || deletedCount > 0) {
763
769
  const updatedFolder = this.db.getFolders(accountId).find(f => f.id === folderId);
@@ -765,7 +771,8 @@ export class ImapManager extends EventEmitter {
765
771
  [folderId]: { total: updatedFolder?.totalCount || 0, unread: updatedFolder?.unreadCount || 0 }
766
772
  });
767
773
  }
768
- this.db.updateLastSync(accountId, Date.now());
774
+ this.emit("folderSynced", accountId, folderId, syncedAt);
775
+ this.db.updateLastSync(accountId, syncedAt);
769
776
  return newCount;
770
777
  }
771
778
  /** Sync all folders for all accounts */
@@ -1014,6 +1021,7 @@ export class ImapManager extends EventEmitter {
1014
1021
  }
1015
1022
  this.db.recalcFolderCounts(folder.id);
1016
1023
  this.emit("folderCountsChanged", accountId, {});
1024
+ this.emit("folderSynced", accountId, folder.id, Date.now());
1017
1025
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
1018
1026
  }
1019
1027
  /** Store API-fetched messages to DB */
@@ -1139,39 +1147,61 @@ export class ImapManager extends EventEmitter {
1139
1147
  this.inboxSyncing = false;
1140
1148
  }
1141
1149
  }
1142
- /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
1143
- * If message count changed, triggers inbox sync for that account. */
1144
- lastInboxCounts = new Map();
1150
+ /** Quick inbox check — per-account lightweight probe.
1151
+ * If the probe value changed since last time, triggers an inbox sync.
1152
+ * The marker is only advanced after a successful sync so that a failed
1153
+ * sync doesn't eat the "new mail" signal and make us stop retrying. */
1154
+ lastInboxMarker = new Map();
1145
1155
  quickCheckRunning = new Set(); // per-account guard
1146
- /** Check a single account's inbox uses its own connection, never blocked by sync */
1147
- async quickInboxCheckAccount(accountId) {
1156
+ /** Shared quick-check skeleton: probe compare sync-if-changed advance marker.
1157
+ * `probe` returns the current marker value; `sync` runs only when it differs
1158
+ * from the previously stored value. Marker is advanced only after sync resolves. */
1159
+ async quickCheck(accountId, probe, sync) {
1148
1160
  if (this.quickCheckRunning.has(accountId))
1149
1161
  return;
1150
1162
  if (this.reauthenticating.has(accountId))
1151
1163
  return;
1152
- if (this.isGmailAccount(accountId)) {
1153
- return this.quickGmailCheck(accountId);
1154
- }
1155
1164
  this.quickCheckRunning.add(accountId);
1156
- let client = null;
1157
1165
  try {
1158
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1159
- if (!inbox)
1166
+ const current = await probe();
1167
+ if (current === null || current === "")
1160
1168
  return;
1161
- client = this.newClient(accountId);
1162
- const count = await client.getMessagesCount("INBOX");
1163
- const prev = this.lastInboxCounts.get(accountId) ?? count;
1164
- this.lastInboxCounts.set(accountId, count);
1165
- if (count !== prev) {
1166
- console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
1167
- await this.syncFolder(accountId, inbox.id, client);
1169
+ const prev = this.lastInboxMarker.get(accountId);
1170
+ if (prev === undefined || current !== prev) {
1171
+ await sync(current, prev);
1168
1172
  }
1169
- await client.logout();
1170
- client = null;
1173
+ // Only advance after sync succeeds — a thrown error skips this line
1174
+ // and the next tick will see the same delta and retry.
1175
+ this.lastInboxMarker.set(accountId, current);
1171
1176
  }
1172
1177
  catch {
1173
1178
  // Lightweight check — silently ignore errors
1174
1179
  }
1180
+ finally {
1181
+ this.quickCheckRunning.delete(accountId);
1182
+ }
1183
+ }
1184
+ /** Check a single account's inbox — uses its own connection, never blocked by sync */
1185
+ async quickInboxCheckAccount(accountId) {
1186
+ if (this.isGmailAccount(accountId))
1187
+ return this.quickGmailCheck(accountId);
1188
+ return this.quickImapCheck(accountId);
1189
+ }
1190
+ async quickImapCheck(accountId) {
1191
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1192
+ if (!inbox)
1193
+ return;
1194
+ let client = null;
1195
+ try {
1196
+ await this.quickCheck(accountId, async () => {
1197
+ client = this.newClient(accountId);
1198
+ return await client.getMessagesCount("INBOX");
1199
+ }, async (count, prev) => {
1200
+ if (prev !== undefined)
1201
+ console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
1202
+ await this.syncFolder(accountId, inbox.id, client);
1203
+ });
1204
+ }
1175
1205
  finally {
1176
1206
  if (client) {
1177
1207
  try {
@@ -1179,44 +1209,31 @@ export class ImapManager extends EventEmitter {
1179
1209
  }
1180
1210
  catch { /* */ }
1181
1211
  }
1182
- this.quickCheckRunning.delete(accountId);
1183
1212
  }
1184
1213
  }
1185
- /** Quick Gmail inbox check — one lightweight API call to check for new messages */
1186
- lastGmailInboxTop = new Map();
1187
1214
  async quickGmailCheck(accountId) {
1188
- if (this.quickCheckRunning.has(accountId))
1215
+ const config = this.configs.get(accountId);
1216
+ if (!config?.tokenProvider)
1189
1217
  return;
1190
- this.quickCheckRunning.add(accountId);
1191
- try {
1192
- const config = this.configs.get(accountId);
1193
- if (!config?.tokenProvider)
1194
- return;
1218
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1219
+ if (!inbox)
1220
+ return;
1221
+ await this.quickCheck(accountId, async () => {
1195
1222
  const token = await config.tokenProvider();
1196
- // Single API call: get just the first message ID
1197
1223
  const res = await globalThis.fetch(`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=in:inbox&maxResults=1`, { headers: { "Authorization": `Bearer ${token}` } });
1198
1224
  if (!res.ok)
1199
- return;
1225
+ return null;
1200
1226
  const data = await res.json();
1201
- const topId = data.messages?.[0]?.id || "";
1202
- const prev = this.lastGmailInboxTop.get(accountId) ?? topId;
1203
- this.lastGmailInboxTop.set(accountId, topId);
1204
- if (topId && topId !== prev) {
1227
+ return data.messages?.[0]?.id || null;
1228
+ }, async (_topId, prev) => {
1229
+ if (prev !== undefined)
1205
1230
  console.log(` [check] ${accountId} INBOX: new message detected`);
1206
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
1207
- if (inbox) {
1208
- const api = this.getGmailProvider(accountId);
1209
- await this.syncFolderViaApi(accountId, inbox, api);
1210
- this.db.recalcFolderCounts(inbox.id);
1211
- this.emit("folderCountsChanged", accountId, {});
1212
- await api.close();
1213
- }
1214
- }
1215
- }
1216
- catch { /* lightweight — ignore errors */ }
1217
- finally {
1218
- this.quickCheckRunning.delete(accountId);
1219
- }
1231
+ const api = this.getGmailProvider(accountId);
1232
+ await this.syncFolderViaApi(accountId, inbox, api);
1233
+ this.db.recalcFolderCounts(inbox.id);
1234
+ this.emit("folderCountsChanged", accountId, {});
1235
+ await api.close();
1236
+ });
1220
1237
  }
1221
1238
  /** Check all accounts (used by legacy callers) */
1222
1239
  async quickInboxCheck() {
@@ -1968,6 +1985,37 @@ export class ImapManager extends EventEmitter {
1968
1985
  async processLocalQueue(accountId) {
1969
1986
  const outboxDir = path.join(getConfigDir(), "outbox", accountId);
1970
1987
  const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
1988
+ // Recovery sweep: any *.sending-<host>-<pid> on THIS host whose PID is
1989
+ // dead (process crashed mid-send) gets unclaimed so the next tick can
1990
+ // retry. Foreign hosts are left alone — we have no way to know if their
1991
+ // process is alive. Cross-host stale recovery is the IMAP-folder path's
1992
+ // job (sweeper looks at server-side claim flags, not local files).
1993
+ for (const dir of [outboxDir, queuedDir]) {
1994
+ if (!fs.existsSync(dir))
1995
+ continue;
1996
+ for (const f of fs.readdirSync(dir)) {
1997
+ const m = f.match(/^(.+)\.sending-([^-]+)-(\d+)$/);
1998
+ if (!m)
1999
+ continue;
2000
+ const [, original, host, pidStr] = m;
2001
+ if (host !== this.hostname)
2002
+ continue;
2003
+ const pid = parseInt(pidStr);
2004
+ let alive = false;
2005
+ try {
2006
+ process.kill(pid, 0);
2007
+ alive = true;
2008
+ }
2009
+ catch { /* dead */ }
2010
+ if (alive)
2011
+ continue; // live claim — owner (sibling or self) still has it
2012
+ try {
2013
+ fs.renameSync(path.join(dir, f), path.join(dir, original));
2014
+ console.log(` [outbox] Recovered stale claim ${f} → ${original}`);
2015
+ }
2016
+ catch { /* ignore */ }
2017
+ }
2018
+ }
1971
2019
  const filesToSend = [];
1972
2020
  for (const dir of [outboxDir, queuedDir]) {
1973
2021
  if (!fs.existsSync(dir))
@@ -1985,32 +2033,57 @@ export class ImapManager extends EventEmitter {
1985
2033
  const nowMs = Date.now();
1986
2034
  for (const { dir, file } of filesToSend) {
1987
2035
  const filePath = path.join(dir, file);
1988
- let raw = fs.readFileSync(filePath, "utf-8");
2036
+ // Atomic claim: rename to <file>.sending-<host>-<pid> so a sibling
2037
+ // process scanning the same dir can't grab the same .ltr. Filesystem
2038
+ // rename is atomic; loser sees ENOENT and skips. Without this, two
2039
+ // mailx instances on one machine (or two ticks within one process)
2040
+ // could both pass the Message-ID dedup check and both call SMTP.
2041
+ const claimSuffix = `.sending-${this.hostname}-${process.pid}`;
2042
+ const claimedPath = filePath + claimSuffix;
2043
+ try {
2044
+ fs.renameSync(filePath, claimedPath);
2045
+ }
2046
+ catch (e) {
2047
+ if (e.code === "ENOENT")
2048
+ continue; // another process won
2049
+ throw e;
2050
+ }
2051
+ let raw = fs.readFileSync(claimedPath, "utf-8");
1989
2052
  // Per-message rate limit: if a prior attempt set X-Mailx-Retry-After
1990
2053
  // in the future, skip this file for now. Minimizes the race where the
1991
2054
  // SMTP server actually accepted DATA but we lost the ack and would
1992
2055
  // otherwise retry immediately on the next 10s tick.
1993
2056
  const retryInfo = parseRetryInfo(raw);
1994
- if (retryInfo.nextAttemptAt > nowMs)
2057
+ if (retryInfo.nextAttemptAt > nowMs) {
2058
+ // Release claim — let next tick reconsider
2059
+ try {
2060
+ fs.renameSync(claimedPath, filePath);
2061
+ }
2062
+ catch { /* ignore */ }
1995
2063
  continue;
2064
+ }
1996
2065
  // Record this attempt: strip internal X-Mailx-Retry-After, append a new
1997
2066
  // X-Mailx-Retry: N <ISO-timestamp> line to the headers. The updated file
1998
2067
  // is written back *before* the send so a crash mid-send doesn't lose state.
1999
2068
  const attempt = retryInfo.attemptCount + 1;
2000
2069
  raw = stripHeaderField(raw, "X-Mailx-Retry-After");
2001
2070
  raw = insertHeaderBeforeBody(raw, `X-Mailx-Retry: ${attempt} ${new Date().toISOString()}`);
2002
- fs.writeFileSync(filePath, raw, "utf-8");
2071
+ fs.writeFileSync(claimedPath, raw, "utf-8");
2003
2072
  try {
2004
2073
  await this.sendRawViaSMTP(accountId, raw);
2005
- fs.renameSync(filePath, path.join(sentDir, file));
2074
+ fs.renameSync(claimedPath, path.join(sentDir, file));
2006
2075
  console.log(` [outbox] Sent ${file} via SMTP → sent/ (attempt ${attempt})`);
2007
2076
  }
2008
2077
  catch (e) {
2009
- // Persist a next-attempt timestamp so the same file won't be retried
2010
- // until RETRY_DELAY_MS later gives the server time to settle.
2078
+ // Persist a next-attempt timestamp and release the claim so the
2079
+ // file is visible to the scan loop again.
2011
2080
  const nextAt = new Date(nowMs + OUTBOX_RETRY_DELAY_MS).toISOString();
2012
2081
  const withDelay = insertHeaderBeforeBody(raw, `X-Mailx-Retry-After: ${nextAt}`);
2013
- fs.writeFileSync(filePath, withDelay, "utf-8");
2082
+ fs.writeFileSync(claimedPath, withDelay, "utf-8");
2083
+ try {
2084
+ fs.renameSync(claimedPath, filePath);
2085
+ }
2086
+ catch { /* file stays claimed; recovery sweeper will handle */ }
2014
2087
  console.error(` [outbox] Send failed for ${file} (attempt ${attempt}, retry after ${nextAt}): ${e.message}`);
2015
2088
  }
2016
2089
  }
@@ -2041,36 +2114,34 @@ export class ImapManager extends EventEmitter {
2041
2114
  // IMAP still unreachable — leave files for next attempt
2042
2115
  }
2043
2116
  }
2044
- /** Send a raw RFC 2822 message via SMTP for a given account */
2117
+ /** Send a raw RFC 2822 message via SMTP for a given account.
2118
+ * Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
2119
+ * same TCP byte-stream interface, no nodemailer dependency. */
2045
2120
  async sendRawViaSMTP(accountId, raw) {
2046
2121
  const settings = loadSettings();
2047
2122
  const account = settings.accounts.find(a => a.id === accountId);
2048
2123
  if (!account?.smtp)
2049
2124
  throw new Error(`No SMTP config for ${accountId}`);
2050
- // SMTP auth: use explicit SMTP credentials, fall back to IMAP credentials
2051
- let smtpAuth;
2125
+ const smtpPort = account.smtp.port || SMTP_PORT_STARTTLS;
2126
+ const smtpHost = account.smtp.host || account.imap?.host;
2127
+ if (!smtpHost)
2128
+ throw new Error(`No SMTP host for ${accountId}`);
2129
+ // SMTP auth: explicit SMTP creds, fall back to IMAP creds for password auth.
2052
2130
  const smtpAuthType = account.smtp.auth || (account.imap?.password ? "password" : undefined);
2131
+ const smtpUser = account.smtp.user || account.imap?.user || account.email;
2132
+ let auth;
2053
2133
  if (smtpAuthType === "password") {
2054
- smtpAuth = {
2055
- user: account.smtp.user || account.imap?.user || account.email,
2056
- pass: account.smtp.password || account.imap?.password,
2057
- };
2134
+ const pass = account.smtp.password || account.imap?.password;
2135
+ if (!pass)
2136
+ throw new Error("SMTP password not configured");
2137
+ auth = { method: "PLAIN", user: smtpUser, pass };
2058
2138
  }
2059
2139
  else if (smtpAuthType === "oauth2") {
2060
- const accessToken = await this.getOAuthToken(accountId);
2061
- if (!accessToken)
2140
+ const token = await this.getOAuthToken(accountId);
2141
+ if (!token)
2062
2142
  throw new Error("OAuth token not available");
2063
- smtpAuth = { type: "OAuth2", user: account.smtp.user || account.imap?.user || account.email, accessToken };
2143
+ auth = { method: "XOAUTH2", user: smtpUser, token };
2064
2144
  }
2065
- const { createTransport } = await import("nodemailer");
2066
- const smtpPort = account.smtp.port || SMTP_PORT_STARTTLS;
2067
- const transport = createTransport({
2068
- host: account.smtp.host || account.imap?.host,
2069
- port: smtpPort,
2070
- secure: smtpPort === SMTP_PORT_IMPLICIT_TLS, // 465 = implicit TLS, 587 = STARTTLS
2071
- auth: smtpAuth,
2072
- tls: { rejectUnauthorized: false },
2073
- });
2074
2145
  const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
2075
2146
  const toMatch = raw.match(/^To:\s*(.+)$/mi);
2076
2147
  const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
@@ -2088,17 +2159,33 @@ export class ImapManager extends EventEmitter {
2088
2159
  throw new Error("No recipients");
2089
2160
  // Dedup: skip if this Message-ID has already been sent. Prevents the
2090
2161
  // outbox from re-sending the same file across crash/restart cycles.
2091
- // Without this, a queued .ltr that was mid-delivery when mailx crashed
2092
- // would be re-sent on every startup until the rename loop completed.
2093
2162
  const messageId = messageIdMatch ? messageIdMatch[1] : "";
2094
2163
  if (messageId && this.db.hasSentMessage(messageId)) {
2095
2164
  console.log(` [smtp] ${accountId}: SKIP ${messageId} — already in sent_log`);
2096
- return; // caller will move the file to sent/ without re-sending
2165
+ return;
2097
2166
  }
2098
2167
  const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
2099
2168
  this.saveSendingCopy(accountId, rawToSend, "sent");
2100
- await transport.sendMail({ envelope: { from: sender, to: recipients }, raw: rawToSend });
2101
- // Record the successful send so future attempts dedupe against it.
2169
+ const smtp = new SmtpClient({
2170
+ host: smtpHost,
2171
+ port: smtpPort,
2172
+ secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
2173
+ auth,
2174
+ localname: os.hostname(),
2175
+ }, this.transportFactory);
2176
+ try {
2177
+ await smtp.connect();
2178
+ const result = await smtp.sendMail({ from: sender, to: recipients }, rawToSend);
2179
+ if (result.rejected.length > 0) {
2180
+ console.log(` [smtp] ${accountId}: ${result.rejected.length} recipient(s) rejected: ${result.rejected.map(r => `${r.address} (${r.code})`).join(", ")}`);
2181
+ }
2182
+ }
2183
+ finally {
2184
+ try {
2185
+ await smtp.quit();
2186
+ }
2187
+ catch { /* ignore */ }
2188
+ }
2102
2189
  if (messageId) {
2103
2190
  this.db.recordSent(messageId, accountId, subjectMatch?.[1]?.trim() || "", recipients);
2104
2191
  }
@@ -2130,21 +2217,54 @@ export class ImapManager extends EventEmitter {
2130
2217
  catch { }
2131
2218
  return;
2132
2219
  }
2133
- const sendingFlag = `$Sending-${this.hostname}`;
2220
+ // Stale-claim recovery: if a peer (or our prior incarnation) crashed
2221
+ // mid-send, the $Sending-<host>-<ts> flag would otherwise pin the
2222
+ // message forever. Sweep flags older than STALE_CLAIM_MS first.
2223
+ const STALE_CLAIM_MS = 3600_000; // 1 hour — far longer than any reasonable SMTP send
2224
+ const nowSec = Math.floor(Date.now() / 1000);
2225
+ // Encode our claim with a seconds-since-epoch timestamp so peers
2226
+ // (and our own restart sweeper) can identify stale entries.
2227
+ const sendingFlag = `$Sending-${this.hostname}-${nowSec}`;
2134
2228
  for (const uid of uids) {
2135
2229
  // Check flags — skip if already being sent or permanently failed
2136
2230
  const flags = await client.getFlags(outboxFolder.path, uid);
2137
- if (flags.some((f) => f.startsWith("$Sending")))
2231
+ // Sweep stale claims. New form: $Sending-<host>-<sec>. Old form
2232
+ // ($Sending-<host>, no timestamp) is treated as stale on first
2233
+ // encounter — safe because if its owner is alive, it'll re-claim
2234
+ // with a fresh timestamped flag on its next tick.
2235
+ const claimFlags = flags.filter((f) => f.startsWith("$Sending"));
2236
+ for (const cf of claimFlags) {
2237
+ const m = cf.match(/^\$Sending-(.+?)(?:-(\d+))?$/);
2238
+ if (!m)
2239
+ continue;
2240
+ const tsSec = m[2] ? parseInt(m[2]) : 0;
2241
+ const ageSec = nowSec - tsSec;
2242
+ if (ageSec * 1000 > STALE_CLAIM_MS) {
2243
+ try {
2244
+ await client.removeFlags(outboxFolder.path, uid, [cf]);
2245
+ console.log(` [outbox] Swept stale claim ${cf} on UID ${uid} (age ${Math.round(ageSec / 60)}m)`);
2246
+ }
2247
+ catch { /* ignore */ }
2248
+ }
2249
+ }
2250
+ // Re-read flags after sweep
2251
+ const flagsNow = (claimFlags.length > 0)
2252
+ ? await client.getFlags(outboxFolder.path, uid)
2253
+ : flags;
2254
+ if (flagsNow.some((f) => f.startsWith("$Sending")))
2138
2255
  continue;
2139
- if (flags.includes("$PermanentFailure"))
2256
+ if (flagsNow.includes("$PermanentFailure"))
2140
2257
  continue;
2141
- if (flags.includes("$Failed")) {
2258
+ if (flagsNow.includes("$Failed")) {
2142
2259
  // Retry: remove failed flag
2143
2260
  await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
2144
2261
  }
2145
2262
  // Claim this message
2146
2263
  await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
2147
- // Re-check — did we win the race?
2264
+ // Re-check — did we win the race? (TOCTOU window: two devices
2265
+ // both reaching addFlags concurrently both see ≥2 sending flags
2266
+ // and both back off — fails safe; nobody sends this tick, next
2267
+ // tick one wins.)
2148
2268
  const flagsAfter = await client.getFlags(outboxFolder.path, uid);
2149
2269
  const sendingFlags = flagsAfter.filter((f) => f.startsWith("$Sending"));
2150
2270
  if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -13,8 +13,9 @@
13
13
  "@bobfrankston/mailx-settings": "file:../mailx-settings",
14
14
  "@bobfrankston/mailx-store": "file:../mailx-store",
15
15
  "@bobfrankston/iflow-direct": "file:../../../MailApps/iflow-direct",
16
- "@bobfrankston/oauthsupport": "file:../../../../projects/oauth/oauthsupport",
17
- "nodemailer": "^7.0.0"
16
+ "@bobfrankston/tcp-transport": "file:../../../MailApps/tcp-transport",
17
+ "@bobfrankston/smtp-direct": "file:../../../MailApps/smtp-direct",
18
+ "@bobfrankston/oauthsupport": "file:../../../../projects/oauth/oauthsupport"
18
19
  },
19
20
  "repository": {
20
21
  "type": "git",
@@ -45,7 +45,11 @@ export class GmailApiProvider {
45
45
  }
46
46
  async fetch(path, options = {}) {
47
47
  const token = await this.tokenProvider();
48
- for (let attempt = 0; attempt < 3; attempt++) {
48
+ const maxAttempts = 6;
49
+ const baseDelayMs = 1000;
50
+ const maxDelayMs = 60_000;
51
+ let lastStatus = 0;
52
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
49
53
  const res = await globalThis.fetch(`${API}${path}`, {
50
54
  ...options,
51
55
  headers: {
@@ -55,9 +59,24 @@ export class GmailApiProvider {
55
59
  },
56
60
  });
57
61
  if (res.status === 429 || res.status >= 500) {
58
- // Rate limited or server error — back off and retry
59
- const delay = (attempt + 1) * 2000;
60
- console.log(` [gmail] ${res.status} error, waiting ${delay / 1000}s...`);
62
+ lastStatus = res.status;
63
+ // Honor Retry-After if present (seconds or HTTP-date)
64
+ const retryAfter = res.headers.get("Retry-After");
65
+ let delay = baseDelayMs * Math.pow(2, attempt);
66
+ if (retryAfter) {
67
+ const asInt = parseInt(retryAfter, 10);
68
+ if (!isNaN(asInt))
69
+ delay = asInt * 1000;
70
+ else {
71
+ const when = Date.parse(retryAfter);
72
+ if (!isNaN(when))
73
+ delay = Math.max(0, when - Date.now());
74
+ }
75
+ }
76
+ // Full jitter to avoid synchronized retries
77
+ delay = Math.min(maxDelayMs, delay);
78
+ delay = Math.floor(delay * (0.5 + Math.random() * 0.5));
79
+ console.log(` [gmail] ${res.status} (attempt ${attempt + 1}/${maxAttempts}), waiting ${(delay / 1000).toFixed(1)}s${retryAfter ? ` (Retry-After: ${retryAfter})` : ""}...`);
61
80
  await new Promise(r => setTimeout(r, delay));
62
81
  continue;
63
82
  }
@@ -67,7 +86,7 @@ export class GmailApiProvider {
67
86
  }
68
87
  return res.json();
69
88
  }
70
- throw new Error("Gmail API: failed after 3 retries");
89
+ throw new Error(`Gmail API: failed after ${maxAttempts} retries (last status ${lastStatus})`);
71
90
  }
72
91
  async listFolders() {
73
92
  const data = await this.fetch("/labels");
@@ -230,6 +230,9 @@ imapManager.on("syncProgress", (accountId, phase, progress) => {
230
230
  imapManager.on("folderCountsChanged", (accountId, counts) => {
231
231
  broadcast({ type: "folderCountsChanged", accountId, counts });
232
232
  });
233
+ imapManager.on("folderSynced", (accountId, folderId, syncedAt) => {
234
+ broadcast({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt }] });
235
+ });
233
236
  imapManager.on("syncError", (accountId, error) => {
234
237
  broadcast({ type: "error", message: `${accountId}: ${error}` });
235
238
  });
@@ -59,8 +59,10 @@ const SCHEMA = `
59
59
  CREATE INDEX IF NOT EXISTS idx_messages_message_id
60
60
  ON messages(message_id);
61
61
 
62
- CREATE INDEX IF NOT EXISTS idx_messages_thread_id
63
- ON messages(account_id, thread_id);
62
+ -- Note: idx_messages_thread_id is created by the addColumnIfMissing migration
63
+ -- in the constructor, AFTER thread_id is guaranteed to exist. Including it
64
+ -- here would crash startup on any pre-thread_id DB because exec(SCHEMA) runs
65
+ -- before the column-add migration.
64
66
 
65
67
  CREATE TABLE IF NOT EXISTS sent_log (
66
68
  message_id TEXT PRIMARY KEY,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",