@bobfrankston/mailx 1.0.213 → 1.0.215
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.215",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"postinstall": "node bin/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.7",
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.277",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -74,11 +74,11 @@
|
|
|
74
74
|
},
|
|
75
75
|
".transformedSnapshot": {
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
77
|
+
"@bobfrankston/iflow-direct": "^0.1.7",
|
|
78
78
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
79
79
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
80
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
-
"@bobfrankston/msger": "^0.1.
|
|
81
|
+
"@bobfrankston/msger": "^0.1.277",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -16,6 +16,32 @@ import * as os from "node:os";
|
|
|
16
16
|
// Well-known ports — no magic numbers
|
|
17
17
|
const SMTP_PORT_STARTTLS = 587;
|
|
18
18
|
const SMTP_PORT_IMPLICIT_TLS = 465;
|
|
19
|
+
/** Per-message SMTP retry delay: if a send attempt fails, wait this long before
|
|
20
|
+
* the same file is retried. Gives the server time to settle so a retry after a
|
|
21
|
+
* lost-ack doesn't arrive while the first copy is still being processed. */
|
|
22
|
+
const OUTBOX_RETRY_DELAY_MS = 60000;
|
|
23
|
+
/** Parse X-Mailx-Retry* tracking headers from a raw RFC822 message. */
|
|
24
|
+
function parseRetryInfo(raw) {
|
|
25
|
+
const headerEnd = raw.search(/\r?\n\r?\n/);
|
|
26
|
+
const headers = headerEnd >= 0 ? raw.slice(0, headerEnd) : raw;
|
|
27
|
+
const attemptCount = (headers.match(/^X-Mailx-Retry:/gmi) || []).length;
|
|
28
|
+
const nextMatch = headers.match(/^X-Mailx-Retry-After:\s*(.+)$/mi);
|
|
29
|
+
const parsed = nextMatch ? Date.parse(nextMatch[1].trim()) : NaN;
|
|
30
|
+
return { attemptCount, nextAttemptAt: Number.isFinite(parsed) ? parsed : 0 };
|
|
31
|
+
}
|
|
32
|
+
/** Remove every occurrence of a header field from a raw RFC822 message. */
|
|
33
|
+
function stripHeaderField(raw, name) {
|
|
34
|
+
const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}:.*\\r?\\n`, "gmi");
|
|
35
|
+
return raw.replace(re, "");
|
|
36
|
+
}
|
|
37
|
+
/** Insert a header line just before the header/body blank line. Preserves CRLF vs LF. */
|
|
38
|
+
function insertHeaderBeforeBody(raw, line) {
|
|
39
|
+
const m = raw.match(/\r?\n\r?\n/);
|
|
40
|
+
if (!m)
|
|
41
|
+
return raw + "\r\n" + line + "\r\n";
|
|
42
|
+
const nl = m[0].startsWith("\r\n") ? "\r\n" : "\n";
|
|
43
|
+
return raw.slice(0, m.index) + nl + line + raw.slice(m.index);
|
|
44
|
+
}
|
|
19
45
|
/** Extract full error detail with provenance */
|
|
20
46
|
function imapError(err) {
|
|
21
47
|
const msg = err.message || err.reason || err.code || (typeof err === "string" ? err : "");
|
|
@@ -346,7 +372,7 @@ export class ImapManager extends EventEmitter {
|
|
|
346
372
|
const tokenDir = path.join(getConfigDir(), "tokens", account.imap.user.replace(/[@.]/g, "_"));
|
|
347
373
|
tokenProvider = async () => {
|
|
348
374
|
const result = await authenticateOAuth(credPath, {
|
|
349
|
-
scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly",
|
|
375
|
+
scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/calendar",
|
|
350
376
|
tokenDirectory: tokenDir,
|
|
351
377
|
credentialsKey: "installed",
|
|
352
378
|
loginHint: account.imap.user,
|
|
@@ -360,6 +386,9 @@ export class ImapManager extends EventEmitter {
|
|
|
360
386
|
username: account.imap.user,
|
|
361
387
|
password: account.imap.password,
|
|
362
388
|
tokenProvider,
|
|
389
|
+
// Slow Dovecot servers (e.g. iecc.com) can stall >60s during multi-body FETCH.
|
|
390
|
+
// Raise the inactivity timeout so the connection isn't dropped mid-stream.
|
|
391
|
+
inactivityTimeout: 180000,
|
|
363
392
|
});
|
|
364
393
|
this.configs.set(account.id, config);
|
|
365
394
|
// Register account in DB
|
|
@@ -1733,17 +1762,36 @@ export class ImapManager extends EventEmitter {
|
|
|
1733
1762
|
const sentDir = path.join(getConfigDir(), "sending", accountId, "sent");
|
|
1734
1763
|
fs.mkdirSync(sentDir, { recursive: true });
|
|
1735
1764
|
if (this.isGmailAccount(accountId)) {
|
|
1765
|
+
const nowMs = Date.now();
|
|
1736
1766
|
for (const { dir, file } of filesToSend) {
|
|
1737
1767
|
const filePath = path.join(dir, file);
|
|
1738
|
-
|
|
1768
|
+
let raw = fs.readFileSync(filePath, "utf-8");
|
|
1769
|
+
// Per-message rate limit: if a prior attempt set X-Mailx-Retry-After
|
|
1770
|
+
// in the future, skip this file for now. Minimizes the race where the
|
|
1771
|
+
// SMTP server actually accepted DATA but we lost the ack and would
|
|
1772
|
+
// otherwise retry immediately on the next 10s tick.
|
|
1773
|
+
const retryInfo = parseRetryInfo(raw);
|
|
1774
|
+
if (retryInfo.nextAttemptAt > nowMs)
|
|
1775
|
+
continue;
|
|
1776
|
+
// Record this attempt: strip internal X-Mailx-Retry-After, append a new
|
|
1777
|
+
// X-Mailx-Retry: N <ISO-timestamp> line to the headers. The updated file
|
|
1778
|
+
// is written back *before* the send so a crash mid-send doesn't lose state.
|
|
1779
|
+
const attempt = retryInfo.attemptCount + 1;
|
|
1780
|
+
raw = stripHeaderField(raw, "X-Mailx-Retry-After");
|
|
1781
|
+
raw = insertHeaderBeforeBody(raw, `X-Mailx-Retry: ${attempt} ${new Date().toISOString()}`);
|
|
1782
|
+
fs.writeFileSync(filePath, raw, "utf-8");
|
|
1739
1783
|
try {
|
|
1740
1784
|
await this.sendRawViaSMTP(accountId, raw);
|
|
1741
|
-
// Move to sent/
|
|
1742
1785
|
fs.renameSync(filePath, path.join(sentDir, file));
|
|
1743
|
-
console.log(` [outbox] Sent ${file} via SMTP → sent
|
|
1786
|
+
console.log(` [outbox] Sent ${file} via SMTP → sent/ (attempt ${attempt})`);
|
|
1744
1787
|
}
|
|
1745
1788
|
catch (e) {
|
|
1746
|
-
|
|
1789
|
+
// Persist a next-attempt timestamp so the same file won't be retried
|
|
1790
|
+
// until RETRY_DELAY_MS later — gives the server time to settle.
|
|
1791
|
+
const nextAt = new Date(nowMs + OUTBOX_RETRY_DELAY_MS).toISOString();
|
|
1792
|
+
const withDelay = insertHeaderBeforeBody(raw, `X-Mailx-Retry-After: ${nextAt}`);
|
|
1793
|
+
fs.writeFileSync(filePath, withDelay, "utf-8");
|
|
1794
|
+
console.error(` [outbox] Send failed for ${file} (attempt ${attempt}, retry after ${nextAt}): ${e.message}`);
|
|
1747
1795
|
}
|
|
1748
1796
|
}
|
|
1749
1797
|
return;
|
|
@@ -1939,12 +1987,21 @@ export class ImapManager extends EventEmitter {
|
|
|
1939
1987
|
this.outboxBackoffDelay.delete(accountId);
|
|
1940
1988
|
}
|
|
1941
1989
|
catch (e) {
|
|
1942
|
-
//
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1990
|
+
// Stale-socket errors (Dovecot silently drops idle connections):
|
|
1991
|
+
// don't back off — just reconnect on the next tick. The 300s
|
|
1992
|
+
// backoff is meant for real auth/network failures, not dead sockets.
|
|
1993
|
+
const msg = String(e?.message || e);
|
|
1994
|
+
if (/Not connected|ECONNRESET|socket hang up|EPIPE|write after end/i.test(msg)) {
|
|
1995
|
+
console.error(` [outbox] Stale connection for ${accountId}: ${msg} — will retry next tick`);
|
|
1996
|
+
}
|
|
1997
|
+
else {
|
|
1998
|
+
// Exponential backoff: 60s → 120s → 300s (max 5min)
|
|
1999
|
+
const prevDelay = this.outboxBackoffDelay.get(accountId) || 0;
|
|
2000
|
+
const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 60000;
|
|
2001
|
+
this.outboxBackoffDelay.set(accountId, delay);
|
|
2002
|
+
this.outboxBackoff.set(accountId, now + delay);
|
|
2003
|
+
console.error(` [outbox] Error for ${accountId}: ${imapError(e)} (retry in ${Math.round(delay / 1000)}s)`);
|
|
2004
|
+
}
|
|
1948
2005
|
}
|
|
1949
2006
|
}
|
|
1950
2007
|
};
|
|
@@ -288,7 +288,7 @@ const OAUTH_CLIENT = {
|
|
|
288
288
|
// Use full drive scope so we can read/write the desktop's accounts.jsonc + clients.jsonc.
|
|
289
289
|
// drive.file is per-consent-grant: files created by desktop's grant aren't visible to Android's grant
|
|
290
290
|
// even with the same client_id. drive (full) lets us see all files the user has access to.
|
|
291
|
-
const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive";
|
|
291
|
+
const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar";
|
|
292
292
|
// ── Token cache (IndexedDB) ──
|
|
293
293
|
async function getCachedToken(email) {
|
|
294
294
|
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|