@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.
- package/bin/mailx.js +20 -4
- package/client/.msger-window.json +1 -1
- package/client/android.html +4 -0
- package/client/app.js +90 -22
- package/client/components/folder-tree.js +48 -0
- package/client/components/message-viewer.js +54 -16
- package/client/index.html +5 -3
- package/client/styles/components.css +37 -0
- package/client/styles/layout.css +19 -0
- package/package.json +19 -10
- package/packages/mailx-imap/index.d.ts +14 -7
- package/packages/mailx-imap/index.js +207 -87
- package/packages/mailx-imap/package.json +4 -3
- package/packages/mailx-imap/providers/gmail-api.js +24 -5
- package/packages/mailx-server/index.js +3 -0
- package/packages/mailx-store/db.js +4 -2
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/android-bootstrap.js +173 -3
- package/packages/mailx-store-web/gmail-api-web.d.ts +7 -0
- package/packages/mailx-store-web/gmail-api-web.js +12 -0
- package/packages/mailx-store-web/imap-web-provider.d.ts +9 -0
- package/packages/mailx-store-web/imap-web-provider.js +45 -8
- package/packages/mailx-store-web/package.json +4 -1
- package/packages/mailx-types/index.d.ts +7 -0
- package/test-smtp-direct.mjs +4 -0
|
@@ -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 {
|
|
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 —
|
|
107
|
-
* If
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 —
|
|
1143
|
-
* If
|
|
1144
|
-
|
|
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
|
-
/**
|
|
1147
|
-
|
|
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
|
|
1159
|
-
if (
|
|
1166
|
+
const current = await probe();
|
|
1167
|
+
if (current === null || current === "")
|
|
1160
1168
|
return;
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1215
|
+
const config = this.configs.get(accountId);
|
|
1216
|
+
if (!config?.tokenProvider)
|
|
1189
1217
|
return;
|
|
1190
|
-
this.
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
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(
|
|
2071
|
+
fs.writeFileSync(claimedPath, raw, "utf-8");
|
|
2003
2072
|
try {
|
|
2004
2073
|
await this.sendRawViaSMTP(accountId, raw);
|
|
2005
|
-
fs.renameSync(
|
|
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
|
|
2010
|
-
//
|
|
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(
|
|
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
|
-
|
|
2051
|
-
|
|
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
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
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
|
|
2061
|
-
if (!
|
|
2140
|
+
const token = await this.getOAuthToken(accountId);
|
|
2141
|
+
if (!token)
|
|
2062
2142
|
throw new Error("OAuth token not available");
|
|
2063
|
-
|
|
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;
|
|
2165
|
+
return;
|
|
2097
2166
|
}
|
|
2098
2167
|
const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
|
|
2099
2168
|
this.saveSendingCopy(accountId, rawToSend, "sent");
|
|
2100
|
-
|
|
2101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2256
|
+
if (flagsNow.includes("$PermanentFailure"))
|
|
2140
2257
|
continue;
|
|
2141
|
-
if (
|
|
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.
|
|
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/
|
|
17
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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
|
-
|
|
63
|
-
|
|
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,
|