@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.
- package/bin/mailx.js +20 -4
- package/client/.msger-window.json +1 -1
- package/client/android.html +5 -1
- package/client/app.js +84 -20
- package/client/components/folder-tree.js +48 -0
- package/client/components/message-viewer.js +54 -24
- package/client/index.html +6 -4
- package/client/styles/components.css +37 -0
- package/client/styles/layout.css +19 -0
- package/package.json +21 -12
- package/packages/mailx-imap/index.d.ts +4 -2
- package/packages/mailx-imap/index.js +149 -38
- package/packages/mailx-imap/package.json +4 -3
- 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 +166 -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/package.json +4 -1
- package/packages/mailx-types/index.d.ts +7 -0
- package/test-smtp-direct.mjs +4 -0
|
@@ -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 */
|
|
@@ -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
|
-
|
|
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(
|
|
2071
|
+
fs.writeFileSync(claimedPath, raw, "utf-8");
|
|
2012
2072
|
try {
|
|
2013
2073
|
await this.sendRawViaSMTP(accountId, raw);
|
|
2014
|
-
fs.renameSync(
|
|
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
|
|
2019
|
-
//
|
|
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(
|
|
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
|
-
|
|
2060
|
-
|
|
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
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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
|
|
2070
|
-
if (!
|
|
2140
|
+
const token = await this.getOAuthToken(accountId);
|
|
2141
|
+
if (!token)
|
|
2071
2142
|
throw new Error("OAuth token not available");
|
|
2072
|
-
|
|
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;
|
|
2165
|
+
return;
|
|
2106
2166
|
}
|
|
2107
2167
|
const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
|
|
2108
2168
|
this.saveSendingCopy(accountId, rawToSend, "sent");
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2256
|
+
if (flagsNow.includes("$PermanentFailure"))
|
|
2149
2257
|
continue;
|
|
2150
|
-
if (
|
|
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.
|
|
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",
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
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,
|
|
290
|
-
|
|
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.
|
|
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.
|