@bobfrankston/mailx 1.0.214 → 1.0.216

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.
@@ -1 +1 @@
1
- {"height":1344,"width":2151,"x":304,"y":36}
1
+ {"height":1344,"width":2151,"x":243,"y":28}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.214",
3
+ "version": "1.0.216",
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.6",
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.276",
27
+ "@bobfrankston/msger": "^0.1.278",
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.6",
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.276",
81
+ "@bobfrankston/msger": "^0.1.278",
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 : "");
@@ -1734,36 +1760,38 @@ export class ImapManager extends EventEmitter {
1734
1760
  return;
1735
1761
  // Gmail/API accounts: send directly via SMTP from local queue (no IMAP outbox)
1736
1762
  const sentDir = path.join(getConfigDir(), "sending", accountId, "sent");
1737
- const failedDir = path.join(getConfigDir(), "sending", accountId, "failed");
1738
1763
  fs.mkdirSync(sentDir, { recursive: true });
1739
- fs.mkdirSync(failedDir, { recursive: true });
1740
1764
  if (this.isGmailAccount(accountId)) {
1765
+ const nowMs = Date.now();
1741
1766
  for (const { dir, file } of filesToSend) {
1742
1767
  const filePath = path.join(dir, file);
1743
- const raw = fs.readFileSync(filePath, "utf-8");
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");
1744
1783
  try {
1745
1784
  await this.sendRawViaSMTP(accountId, raw);
1746
1785
  fs.renameSync(filePath, path.join(sentDir, file));
1747
- console.log(` [outbox] Sent ${file} via SMTP → sent/`);
1786
+ console.log(` [outbox] Sent ${file} via SMTP → sent/ (attempt ${attempt})`);
1748
1787
  }
1749
1788
  catch (e) {
1750
- // Critical: NEVER leave a .ltr in the queue after a send attempt unless the
1751
- // failure was clearly pre-connect. An error after the SMTP session opened may
1752
- // mean the server actually accepted DATA but we lost the ack — retrying would
1753
- // double-send. Classify conservatively: only keep-in-queue for clearly
1754
- // network-level / pre-auth errors.
1755
- const code = String(e?.code || "");
1756
- const cmd = String(e?.command || "").toUpperCase();
1757
- const preConnect = /^(ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNECTION|ETIMEDOUT)$/.test(code)
1758
- && (!cmd || cmd === "CONN");
1759
- if (preConnect) {
1760
- console.error(` [outbox] Pre-connect failure for ${file} (${code}), will retry: ${e.message}`);
1761
- }
1762
- else {
1763
- // Ambiguous or terminal — move to failed/ so we don't resend if SMTP actually delivered
1764
- fs.renameSync(filePath, path.join(failedDir, file));
1765
- console.error(` [outbox] Send failed for ${file} → failed/ (no auto-retry, code=${code || "?"}): ${e.message}`);
1766
- }
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}`);
1767
1795
  }
1768
1796
  }
1769
1797
  return;