@bobfrankston/mailx 1.0.246 → 1.0.253

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.
@@ -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 */
@@ -1977,6 +1985,37 @@ export class ImapManager extends EventEmitter {
1977
1985
  async processLocalQueue(accountId) {
1978
1986
  const outboxDir = path.join(getConfigDir(), "outbox", accountId);
1979
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
+ }
1980
2019
  const filesToSend = [];
1981
2020
  for (const dir of [outboxDir, queuedDir]) {
1982
2021
  if (!fs.existsSync(dir))
@@ -1994,32 +2033,57 @@ export class ImapManager extends EventEmitter {
1994
2033
  const nowMs = Date.now();
1995
2034
  for (const { dir, file } of filesToSend) {
1996
2035
  const filePath = path.join(dir, file);
1997
- 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");
1998
2052
  // Per-message rate limit: if a prior attempt set X-Mailx-Retry-After
1999
2053
  // in the future, skip this file for now. Minimizes the race where the
2000
2054
  // SMTP server actually accepted DATA but we lost the ack and would
2001
2055
  // otherwise retry immediately on the next 10s tick.
2002
2056
  const retryInfo = parseRetryInfo(raw);
2003
- 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 */ }
2004
2063
  continue;
2064
+ }
2005
2065
  // Record this attempt: strip internal X-Mailx-Retry-After, append a new
2006
2066
  // X-Mailx-Retry: N <ISO-timestamp> line to the headers. The updated file
2007
2067
  // is written back *before* the send so a crash mid-send doesn't lose state.
2008
2068
  const attempt = retryInfo.attemptCount + 1;
2009
2069
  raw = stripHeaderField(raw, "X-Mailx-Retry-After");
2010
2070
  raw = insertHeaderBeforeBody(raw, `X-Mailx-Retry: ${attempt} ${new Date().toISOString()}`);
2011
- fs.writeFileSync(filePath, raw, "utf-8");
2071
+ fs.writeFileSync(claimedPath, raw, "utf-8");
2012
2072
  try {
2013
2073
  await this.sendRawViaSMTP(accountId, raw);
2014
- fs.renameSync(filePath, path.join(sentDir, file));
2074
+ fs.renameSync(claimedPath, path.join(sentDir, file));
2015
2075
  console.log(` [outbox] Sent ${file} via SMTP → sent/ (attempt ${attempt})`);
2016
2076
  }
2017
2077
  catch (e) {
2018
- // Persist a next-attempt timestamp so the same file won't be retried
2019
- // 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.
2020
2080
  const nextAt = new Date(nowMs + OUTBOX_RETRY_DELAY_MS).toISOString();
2021
2081
  const withDelay = insertHeaderBeforeBody(raw, `X-Mailx-Retry-After: ${nextAt}`);
2022
- 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 */ }
2023
2087
  console.error(` [outbox] Send failed for ${file} (attempt ${attempt}, retry after ${nextAt}): ${e.message}`);
2024
2088
  }
2025
2089
  }
@@ -2050,36 +2114,34 @@ export class ImapManager extends EventEmitter {
2050
2114
  // IMAP still unreachable — leave files for next attempt
2051
2115
  }
2052
2116
  }
2053
- /** 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. */
2054
2120
  async sendRawViaSMTP(accountId, raw) {
2055
2121
  const settings = loadSettings();
2056
2122
  const account = settings.accounts.find(a => a.id === accountId);
2057
2123
  if (!account?.smtp)
2058
2124
  throw new Error(`No SMTP config for ${accountId}`);
2059
- // SMTP auth: use explicit SMTP credentials, fall back to IMAP credentials
2060
- 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.
2061
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;
2062
2133
  if (smtpAuthType === "password") {
2063
- smtpAuth = {
2064
- user: account.smtp.user || account.imap?.user || account.email,
2065
- pass: account.smtp.password || account.imap?.password,
2066
- };
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 };
2067
2138
  }
2068
2139
  else if (smtpAuthType === "oauth2") {
2069
- const accessToken = await this.getOAuthToken(accountId);
2070
- if (!accessToken)
2140
+ const token = await this.getOAuthToken(accountId);
2141
+ if (!token)
2071
2142
  throw new Error("OAuth token not available");
2072
- smtpAuth = { type: "OAuth2", user: account.smtp.user || account.imap?.user || account.email, accessToken };
2143
+ auth = { method: "XOAUTH2", user: smtpUser, token };
2073
2144
  }
2074
- const { createTransport } = await import("nodemailer");
2075
- const smtpPort = account.smtp.port || SMTP_PORT_STARTTLS;
2076
- const transport = createTransport({
2077
- host: account.smtp.host || account.imap?.host,
2078
- port: smtpPort,
2079
- secure: smtpPort === SMTP_PORT_IMPLICIT_TLS, // 465 = implicit TLS, 587 = STARTTLS
2080
- auth: smtpAuth,
2081
- tls: { rejectUnauthorized: false },
2082
- });
2083
2145
  const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
2084
2146
  const toMatch = raw.match(/^To:\s*(.+)$/mi);
2085
2147
  const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
@@ -2097,17 +2159,33 @@ export class ImapManager extends EventEmitter {
2097
2159
  throw new Error("No recipients");
2098
2160
  // Dedup: skip if this Message-ID has already been sent. Prevents the
2099
2161
  // outbox from re-sending the same file across crash/restart cycles.
2100
- // Without this, a queued .ltr that was mid-delivery when mailx crashed
2101
- // would be re-sent on every startup until the rename loop completed.
2102
2162
  const messageId = messageIdMatch ? messageIdMatch[1] : "";
2103
2163
  if (messageId && this.db.hasSentMessage(messageId)) {
2104
2164
  console.log(` [smtp] ${accountId}: SKIP ${messageId} — already in sent_log`);
2105
- return; // caller will move the file to sent/ without re-sending
2165
+ return;
2106
2166
  }
2107
2167
  const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
2108
2168
  this.saveSendingCopy(accountId, rawToSend, "sent");
2109
- await transport.sendMail({ envelope: { from: sender, to: recipients }, raw: rawToSend });
2110
- // 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
+ }
2111
2189
  if (messageId) {
2112
2190
  this.db.recordSent(messageId, accountId, subjectMatch?.[1]?.trim() || "", recipients);
2113
2191
  }
@@ -2139,21 +2217,54 @@ export class ImapManager extends EventEmitter {
2139
2217
  catch { }
2140
2218
  return;
2141
2219
  }
2142
- 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}`;
2143
2228
  for (const uid of uids) {
2144
2229
  // Check flags — skip if already being sent or permanently failed
2145
2230
  const flags = await client.getFlags(outboxFolder.path, uid);
2146
- 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")))
2147
2255
  continue;
2148
- if (flags.includes("$PermanentFailure"))
2256
+ if (flagsNow.includes("$PermanentFailure"))
2149
2257
  continue;
2150
- if (flags.includes("$Failed")) {
2258
+ if (flagsNow.includes("$Failed")) {
2151
2259
  // Retry: remove failed flag
2152
2260
  await client.removeFlags(outboxFolder.path, uid, ["$Failed"]);
2153
2261
  }
2154
2262
  // Claim this message
2155
2263
  await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
2156
- // 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.)
2157
2268
  const flagsAfter = await client.getFlags(outboxFolder.path, uid);
2158
2269
  const sendingFlags = flagsAfter.filter((f) => f.startsWith("$Sending"));
2159
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",
@@ -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",
@@ -17,6 +17,8 @@ import { WebMailxService } from "./web-service.js";
17
17
  import { loadAccounts, loadAccountsFromCloud, saveAccounts, clearSettings, getDeviceId, setGDriveTokenProvider, setGDriveFolderId } from "./web-settings.js";
18
18
  import { GmailApiWebProvider } from "./gmail-api-web.js";
19
19
  import { ImapWebProvider } from "./imap-web-provider.js";
20
+ import { SmtpClient } from "@bobfrankston/smtp-direct";
21
+ import { BridgeTcpTransport } from "@bobfrankston/tcp-transport";
20
22
  // ── State ──
21
23
  let db;
22
24
  let bodyStore;
@@ -121,7 +123,12 @@ class AndroidSyncManager {
121
123
  return 1;
122
124
  return 0;
123
125
  });
124
- for (const folder of sorted.slice(0, 5)) {
126
+ // Sync every folder, not just the first five — the old slice(0, 5)
127
+ // meant subfolders past the cutoff (e.g. _spam, custom labels)
128
+ // never picked up moves made on other clients, and those moves
129
+ // also stayed visible in the source folder because reconcile
130
+ // (below in syncFolder) never ran for the target.
131
+ for (const folder of sorted) {
125
132
  try {
126
133
  await this.syncFolder(account.id, folder.id);
127
134
  }
@@ -185,6 +192,48 @@ class AndroidSyncManager {
185
192
  this.db.recalcFolderCounts(folderId);
186
193
  emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
187
194
  }
195
+ // Reconcile deletions — messages present locally but no longer on the
196
+ // server (moved away, deleted on another client). Without this, the
197
+ // Android client never drops removed rows: e.g., moves to _spam from
198
+ // another client showed up in _spam (next time it synced) but never
199
+ // disappeared from INBOX.
200
+ //
201
+ // Same safety guards as the desktop reconcile path:
202
+ // - Skip if the server list is empty but local has messages (likely
203
+ // a transient API failure that returned []).
204
+ // - Refuse to delete more than 50% of local in one pass — better to
205
+ // keep phantoms than to wipe a folder on a sync bug. Rebuild local
206
+ // cache fixes a stuck state.
207
+ try {
208
+ const serverUidsArr = await provider.getUids(folder.path);
209
+ const serverUids = new Set(serverUidsArr);
210
+ const localUids = this.db.getUidsForFolder(accountId, folderId);
211
+ if (serverUidsArr.length === 0 && localUids.length > 0) {
212
+ console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`);
213
+ }
214
+ else {
215
+ const toDelete = localUids.filter(uid => !serverUids.has(uid));
216
+ const RECONCILE_DELETE_THRESHOLD = 0.5;
217
+ if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
218
+ console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`);
219
+ }
220
+ else {
221
+ for (const uid of toDelete) {
222
+ this.db.deleteMessage(accountId, uid);
223
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
224
+ }
225
+ if (toDelete.length > 0) {
226
+ console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`);
227
+ this.db.recalcFolderCounts(folderId);
228
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
229
+ }
230
+ }
231
+ }
232
+ }
233
+ catch (e) {
234
+ console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`);
235
+ }
236
+ emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] });
188
237
  emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
189
238
  }
190
239
  storeProviderMessages(accountId, folderId, messages) {
@@ -286,8 +335,122 @@ class AndroidSyncManager {
286
335
  async undeleteMessage(accountId, uid, folderId) {
287
336
  this.db.queueSyncAction(accountId, "undelete", uid, folderId);
288
337
  }
289
- queueOutgoingLocal(accountId, _rawMessage) {
290
- console.log(`[send] Queued outgoing for ${accountId}`);
338
+ queueOutgoingLocal(accountId, rawMessage) {
339
+ // Two paths, both real (no stubs that pretend success — see programming.md
340
+ // rule "Stubs MUST NOT appear successful"):
341
+ // - Gmail accounts: POST to users.messages.send (Gmail handles SMTP +
342
+ // auto-files into Sent label).
343
+ // - Non-Gmail accounts: smtp-direct over BridgeTransport (mailxapi.tcp).
344
+ // Caller (web-service.send) is sync-returning; we kick off the network
345
+ // request and surface success/failure via events. Compose UI listens for
346
+ // sendError/sendComplete.
347
+ const provider = this.getProvider(accountId);
348
+ if (provider && typeof provider.sendRaw === "function") {
349
+ provider.sendRaw(rawMessage)
350
+ .then((result) => {
351
+ console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
352
+ emitEvent({ type: "sendComplete", accountId, messageId: result.id });
353
+ })
354
+ .catch((e) => {
355
+ console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
356
+ emitEvent({ type: "sendError", accountId, error: e.message });
357
+ });
358
+ return;
359
+ }
360
+ // Non-Gmail: use smtp-direct + BridgeTransport. Pull SMTP config from the
361
+ // stored account JSON.
362
+ const accounts = db.getAccountConfigs();
363
+ const row = accounts.find(a => a.id === accountId);
364
+ if (!row) {
365
+ const e = "Unknown account";
366
+ console.error(`[send] ${accountId}: ${e}`);
367
+ emitEvent({ type: "sendError", accountId, error: e });
368
+ throw new Error(e);
369
+ }
370
+ let account;
371
+ try {
372
+ account = JSON.parse(row.configJson);
373
+ }
374
+ catch {
375
+ const e = "Account config malformed";
376
+ emitEvent({ type: "sendError", accountId, error: e });
377
+ throw new Error(e);
378
+ }
379
+ if (!account.smtp) {
380
+ const e = "No SMTP config for this account";
381
+ console.error(`[send] ${accountId}: ${e}`);
382
+ emitEvent({ type: "sendError", accountId, error: e });
383
+ throw new Error(e);
384
+ }
385
+ // Fire async — same pattern as Gmail path above.
386
+ this.sendViaSmtpDirect(accountId, account, rawMessage)
387
+ .then((result) => {
388
+ console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
389
+ emitEvent({ type: "sendComplete", accountId });
390
+ })
391
+ .catch((e) => {
392
+ console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
393
+ emitEvent({ type: "sendError", accountId, error: e.message });
394
+ });
395
+ }
396
+ /** Build SMTP config from account, send via smtp-direct over BridgeTransport. */
397
+ async sendViaSmtpDirect(accountId, account, raw) {
398
+ const SMTP_PORT_STARTTLS = 587;
399
+ const SMTP_PORT_IMPLICIT_TLS = 465;
400
+ const smtp = account.smtp;
401
+ const smtpPort = smtp.port || SMTP_PORT_STARTTLS;
402
+ const smtpHost = smtp.host || account.imap?.host;
403
+ if (!smtpHost)
404
+ throw new Error("No SMTP host");
405
+ // Auth: password → PLAIN; oauth2 → XOAUTH2 (token from this account's provider)
406
+ const smtpUser = smtp.user || account.imap?.user || account.email;
407
+ const authType = smtp.auth || (account.imap?.password ? "password" : undefined);
408
+ let auth;
409
+ if (authType === "password") {
410
+ const pass = smtp.password || account.imap?.password;
411
+ if (!pass)
412
+ throw new Error("SMTP password not configured");
413
+ auth = { method: "PLAIN", user: smtpUser, pass };
414
+ }
415
+ else if (authType === "oauth2") {
416
+ const tp = this.tokenProviders.get(accountId);
417
+ if (!tp)
418
+ throw new Error("OAuth token provider not registered");
419
+ const token = await tp();
420
+ auth = { method: "XOAUTH2", user: smtpUser, token };
421
+ }
422
+ // Recipients from headers
423
+ const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
424
+ const toMatch = raw.match(/^To:\s*(.+)$/mi);
425
+ const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
426
+ const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
427
+ const fromMatch = raw.match(/^From:\s*(.+)$/mi);
428
+ const recipients = [
429
+ ...(toMatch ? parseAddrs(toMatch[1]) : []),
430
+ ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
431
+ ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
432
+ ];
433
+ const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
434
+ if (recipients.length === 0)
435
+ throw new Error("No recipients");
436
+ const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
437
+ const client = new SmtpClient({
438
+ host: smtpHost,
439
+ port: smtpPort,
440
+ secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
441
+ auth,
442
+ localname: "mailx-android",
443
+ }, () => new BridgeTcpTransport());
444
+ try {
445
+ await client.connect();
446
+ return await client.sendMail({ from: sender, to: recipients }, rawToSend);
447
+ }
448
+ finally {
449
+ try {
450
+ await client.quit();
451
+ }
452
+ catch { /* ignore */ }
453
+ }
291
454
  }
292
455
  async saveDraft(_accountId, _raw, _prevUid, _draftId) {
293
456
  return null;
@@ -24,6 +24,13 @@ export declare class GmailApiWebProvider implements MailProvider {
24
24
  fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
25
25
  getUids(folder: string): Promise<number[]>;
26
26
  close(): Promise<void>;
27
+ /** Send an RFC 2822 message via Gmail API users.messages.send. The server
28
+ * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
29
+ * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
30
+ sendRaw(rawRfc822: string): Promise<{
31
+ id: string;
32
+ threadId: string;
33
+ }>;
27
34
  private folderToLabel;
28
35
  private formatDate;
29
36
  }
@@ -227,6 +227,18 @@ export class GmailApiWebProvider {
227
227
  return ids.map(idToUid);
228
228
  }
229
229
  async close() { }
230
+ /** Send an RFC 2822 message via Gmail API users.messages.send. The server
231
+ * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
232
+ * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
233
+ async sendRaw(rawRfc822) {
234
+ const b64 = btoa(unescape(encodeURIComponent(rawRfc822)))
235
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
236
+ const data = await this.apiFetch("/messages/send", {
237
+ method: "POST",
238
+ body: JSON.stringify({ raw: b64 }),
239
+ });
240
+ return { id: data.id, threadId: data.threadId };
241
+ }
230
242
  folderToLabel(path) {
231
243
  const lower = path.toLowerCase();
232
244
  if (lower === "inbox")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store-web",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -10,6 +10,9 @@
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "file:../mailx-types",
13
+ "@bobfrankston/iflow-direct": "file:../../../MailApps/iflow-direct",
14
+ "@bobfrankston/tcp-transport": "file:../../../MailApps/tcp-transport",
15
+ "@bobfrankston/smtp-direct": "file:../../../MailApps/smtp-direct",
13
16
  "sql.js": "^1.14.1"
14
17
  },
15
18
  "repository": {
@@ -159,6 +159,13 @@ export type WsEvent = {
159
159
  total: number;
160
160
  unread: number;
161
161
  }>;
162
+ } | {
163
+ type: "folderSynced";
164
+ accountId: string;
165
+ entries: {
166
+ folderId: number;
167
+ syncedAt: number;
168
+ }[];
162
169
  } | {
163
170
  type: "syncProgress";
164
171
  accountId: string;
@@ -0,0 +1,4 @@
1
+ // Removed after one-shot smtp-direct test on 2026-04-13.
2
+ // Original sent a test message to test1@bob.ma via iecc submission.
3
+ // Result: 250 Accepted message qp 15437 (server queued for delivery).
4
+ // File overwritten because it had a plaintext password; safe to delete.