@bobfrankston/mailx 1.0.246 → 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 +84 -20
- package/client/components/folder-tree.js +48 -0
- package/client/components/message-viewer.js +54 -24
- 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 +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
package/bin/mailx.js
CHANGED
|
@@ -513,14 +513,14 @@ async function runTest() {
|
|
|
513
513
|
// Test IMAP
|
|
514
514
|
try {
|
|
515
515
|
const { createAutoImapConfig, CompatImapClient } = await import("@bobfrankston/iflow-direct");
|
|
516
|
-
const {
|
|
516
|
+
const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
|
|
517
517
|
const config = createAutoImapConfig({
|
|
518
518
|
server: account.imap.host,
|
|
519
519
|
port: account.imap.port,
|
|
520
520
|
username: account.imap.user,
|
|
521
521
|
password: account.imap.password
|
|
522
522
|
});
|
|
523
|
-
const client = new CompatImapClient(config, () => new
|
|
523
|
+
const client = new CompatImapClient(config, () => new NodeTcpTransport());
|
|
524
524
|
const folders = await client.getFolderList();
|
|
525
525
|
await client.logout();
|
|
526
526
|
console.log(` IMAP: OK (${folders.length} folders)`);
|
|
@@ -698,8 +698,8 @@ async function main() {
|
|
|
698
698
|
}
|
|
699
699
|
}
|
|
700
700
|
const db = new MailxDB(getConfigDir());
|
|
701
|
-
const {
|
|
702
|
-
const imapManager = new ImapManager(db, () => new
|
|
701
|
+
const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
|
|
702
|
+
const imapManager = new ImapManager(db, () => new NodeTcpTransport());
|
|
703
703
|
// Native client is the only option (iflow-direct)
|
|
704
704
|
const svc = new MailxService(db, imapManager);
|
|
705
705
|
// Open msger in service mode — custom protocol serves files from client dir
|
|
@@ -769,6 +769,22 @@ async function main() {
|
|
|
769
769
|
}, 1000); // batch count updates every 1s
|
|
770
770
|
}
|
|
771
771
|
});
|
|
772
|
+
// Batch folderSynced events (same cadence as counts) so a sync-all burst
|
|
773
|
+
// doesn't flood the WebView with one stdin write per folder.
|
|
774
|
+
let pendingSynced = {};
|
|
775
|
+
let syncedTimer = null;
|
|
776
|
+
imapManager.on("folderSynced", (accountId, folderId, syncedAt) => {
|
|
777
|
+
(pendingSynced[accountId] ||= []).push({ folderId, syncedAt });
|
|
778
|
+
if (!syncedTimer) {
|
|
779
|
+
syncedTimer = setTimeout(() => {
|
|
780
|
+
syncedTimer = null;
|
|
781
|
+
for (const [id, entries] of Object.entries(pendingSynced)) {
|
|
782
|
+
handle.send({ _event: "folderSynced", type: "folderSynced", accountId: id, entries });
|
|
783
|
+
}
|
|
784
|
+
pendingSynced = {};
|
|
785
|
+
}, 1000);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
772
788
|
imapManager.on("syncError", (accountId, error) => {
|
|
773
789
|
handle.send({ _event: "error", type: "error", message: `${accountId}: ${error}` });
|
|
774
790
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":380,"y":73}
|
package/client/android.html
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"@bobfrankston/mailx-types": "../packages/mailx-types/index.js",
|
|
18
18
|
"@bobfrankston/iflow-direct": "../node_modules/@bobfrankston/iflow-direct/index.js",
|
|
19
19
|
"@bobfrankston/iflow-direct/": "../node_modules/@bobfrankston/iflow-direct/",
|
|
20
|
+
"@bobfrankston/smtp-direct": "../node_modules/@bobfrankston/smtp-direct/index.js",
|
|
21
|
+
"@bobfrankston/smtp-direct/": "../node_modules/@bobfrankston/smtp-direct/",
|
|
22
|
+
"@bobfrankston/tcp-transport": "../node_modules/@bobfrankston/tcp-transport/index.js",
|
|
23
|
+
"@bobfrankston/tcp-transport/": "../node_modules/@bobfrankston/tcp-transport/",
|
|
20
24
|
"sql.js": "../packages/mailx-store-web/sql-wasm-esm.js"
|
|
21
25
|
}
|
|
22
26
|
}
|
package/client/app.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* mailx client entry point.
|
|
3
3
|
* Wires together all UI components and WebSocket connection.
|
|
4
4
|
*/
|
|
5
|
-
import { initFolderTree, refreshFolderTree, updateFolderCounts } from "./components/folder-tree.js";
|
|
5
|
+
import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced } from "./components/folder-tree.js";
|
|
6
6
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
|
|
7
7
|
import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
|
|
8
8
|
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags } from "./lib/api-client.js";
|
|
@@ -154,6 +154,44 @@ let currentFolderSpecialUse = "";
|
|
|
154
154
|
function clearViewer() {
|
|
155
155
|
messageState.select(null); // Deselect — viewer clears via subscription
|
|
156
156
|
}
|
|
157
|
+
const folderTitleEl = document.getElementById("ml-folder-title");
|
|
158
|
+
let currentFolderName = "";
|
|
159
|
+
let currentFolderSyncedAt;
|
|
160
|
+
function formatAge(ms) {
|
|
161
|
+
const s = Math.round(ms / 1000);
|
|
162
|
+
if (s < 60)
|
|
163
|
+
return `${s}s ago`;
|
|
164
|
+
const m = Math.round(s / 60);
|
|
165
|
+
if (m < 60)
|
|
166
|
+
return `${m}m ago`;
|
|
167
|
+
const h = Math.round(m / 60);
|
|
168
|
+
if (h < 24)
|
|
169
|
+
return `${h}h ago`;
|
|
170
|
+
return `${Math.round(h / 24)}d ago`;
|
|
171
|
+
}
|
|
172
|
+
function renderNarrowFolderTitle() {
|
|
173
|
+
if (!folderTitleEl)
|
|
174
|
+
return;
|
|
175
|
+
if (currentFolderSyncedAt) {
|
|
176
|
+
const age = formatAge(Date.now() - currentFolderSyncedAt);
|
|
177
|
+
folderTitleEl.innerHTML = `${currentFolderName}<span class="ml-folder-age"> · ${age}</span>`;
|
|
178
|
+
folderTitleEl.title = `Last synced ${new Date(currentFolderSyncedAt).toLocaleTimeString()}`;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
folderTitleEl.textContent = currentFolderName;
|
|
182
|
+
folderTitleEl.title = "";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function setNarrowFolderTitle(name) {
|
|
186
|
+
currentFolderName = name;
|
|
187
|
+
currentFolderSyncedAt = getFolderSynced(currentAccountId, currentFolderId);
|
|
188
|
+
renderNarrowFolderTitle();
|
|
189
|
+
}
|
|
190
|
+
// Tick the "3m ago" text every 30s so it stays truthful without flooding repaints.
|
|
191
|
+
setInterval(() => {
|
|
192
|
+
if (currentFolderSyncedAt)
|
|
193
|
+
renderNarrowFolderTitle();
|
|
194
|
+
}, 30_000);
|
|
157
195
|
initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
158
196
|
currentFolderSpecialUse = specialUse;
|
|
159
197
|
currentAccountId = accountId;
|
|
@@ -164,12 +202,14 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
164
202
|
clearViewer();
|
|
165
203
|
loadMessages(accountId, folderId, 1, specialUse);
|
|
166
204
|
setTitle(`mailx - ${folderName}`);
|
|
205
|
+
setNarrowFolderTitle(folderName);
|
|
167
206
|
}, () => {
|
|
168
207
|
// Unified inbox handler
|
|
169
208
|
currentFolderSpecialUse = "inbox";
|
|
170
209
|
clearViewer();
|
|
171
210
|
loadUnifiedInbox();
|
|
172
211
|
setTitle("mailx - All Inboxes");
|
|
212
|
+
setNarrowFolderTitle("All Inboxes");
|
|
173
213
|
});
|
|
174
214
|
initMessageList((accountId, uid, folderId) => {
|
|
175
215
|
showMessage(accountId, uid, folderId, currentFolderSpecialUse);
|
|
@@ -216,10 +256,20 @@ if (messageList) {
|
|
|
216
256
|
document.getElementById("btn-menu")?.addEventListener("click", () => {
|
|
217
257
|
document.querySelector(".folder-panel")?.classList.toggle("open");
|
|
218
258
|
});
|
|
219
|
-
|
|
259
|
+
const backToList = (e) => {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
e.stopPropagation();
|
|
220
262
|
document.getElementById("message-viewer")?.classList.remove("narrow-active");
|
|
221
263
|
document.getElementById("message-list")?.classList.remove("narrow-hidden");
|
|
222
|
-
|
|
264
|
+
// Deselect the message so the viewer component clears. Without this, a
|
|
265
|
+
// subsequent "selected" state change (e.g. sync reload) could re-show the
|
|
266
|
+
// same message and re-trigger narrow-active.
|
|
267
|
+
messageState.select(null);
|
|
268
|
+
};
|
|
269
|
+
document.getElementById("btn-back")?.addEventListener("click", backToList);
|
|
270
|
+
// Android WebView sometimes drops synthetic clicks after a touchend inside a
|
|
271
|
+
// header bar layered above the iframe — handle touchend explicitly too.
|
|
272
|
+
document.getElementById("btn-back")?.addEventListener("touchend", backToList);
|
|
223
273
|
// Close folder panel when a folder is selected (narrow mode)
|
|
224
274
|
// Also reset narrow navigation: show message list, hide viewer
|
|
225
275
|
document.getElementById("folder-tree")?.addEventListener("click", (e) => {
|
|
@@ -292,6 +342,14 @@ document.getElementById("btn-restart-quick")?.addEventListener("click", async ()
|
|
|
292
342
|
if (restartDropdown)
|
|
293
343
|
restartDropdown.hidden = true;
|
|
294
344
|
if (isApp) {
|
|
345
|
+
// Android: check for updates before reloading
|
|
346
|
+
if (window.mailxapi?.platform === "android") {
|
|
347
|
+
const f = document.createElement("iframe");
|
|
348
|
+
f.style.display = "none";
|
|
349
|
+
f.src = "mailxapi://checkUpdate";
|
|
350
|
+
document.body.appendChild(f);
|
|
351
|
+
setTimeout(() => f.remove(), 100);
|
|
352
|
+
}
|
|
295
353
|
// IPC mode: reload the UI (no server to restart)
|
|
296
354
|
location.reload();
|
|
297
355
|
}
|
|
@@ -859,6 +917,16 @@ onWsEvent((event) => {
|
|
|
859
917
|
// where folders don't exist until sync fetches them from Gmail API)
|
|
860
918
|
refreshFolderTree();
|
|
861
919
|
break;
|
|
920
|
+
case "folderSynced":
|
|
921
|
+
// Per-folder timestamps — drives the tooltip + freshness dot.
|
|
922
|
+
for (const entry of event.entries || []) {
|
|
923
|
+
setFolderSynced(event.accountId, entry.folderId, entry.syncedAt);
|
|
924
|
+
if (currentFolderId === entry.folderId && currentAccountId === event.accountId) {
|
|
925
|
+
currentFolderSyncedAt = entry.syncedAt;
|
|
926
|
+
renderNarrowFolderTitle();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
break;
|
|
862
930
|
case "folderCountsChanged": {
|
|
863
931
|
// Update folder badges + silently refresh message list (preserves selection and viewer)
|
|
864
932
|
updateFolderCounts();
|
|
@@ -1343,18 +1411,19 @@ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
|
1343
1411
|
// Wait for server ready signal, then fetch version
|
|
1344
1412
|
const versionPromise = getVersion();
|
|
1345
1413
|
versionPromise.then((d) => {
|
|
1346
|
-
const
|
|
1414
|
+
const els = document.querySelectorAll(".app-version");
|
|
1347
1415
|
const storage = d.storage || {};
|
|
1348
1416
|
const storageLabel = storage.provider && storage.provider !== "local"
|
|
1349
1417
|
? ` [${storage.provider}]`
|
|
1350
1418
|
: "";
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1419
|
+
const text = `mailx v${d.version}${storageLabel}${isApp ? "" : " [browser]"}`;
|
|
1420
|
+
const tip = storage.provider && storage.provider !== "local"
|
|
1421
|
+
? (storage.cloudPath ? `My Drive/${storage.cloudPath}/` : storage.provider)
|
|
1422
|
+
: "";
|
|
1423
|
+
for (const el of els) {
|
|
1424
|
+
el.textContent = text;
|
|
1425
|
+
if (tip)
|
|
1426
|
+
el.title = tip;
|
|
1358
1427
|
}
|
|
1359
1428
|
if (d.settingsError) {
|
|
1360
1429
|
showAlert(d.settingsError, "settings-error");
|
|
@@ -1390,15 +1459,10 @@ versionPromise.then((d) => {
|
|
|
1390
1459
|
}
|
|
1391
1460
|
}).catch((e) => {
|
|
1392
1461
|
// Version fetch failed
|
|
1393
|
-
const
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
}
|
|
1398
|
-
else {
|
|
1399
|
-
if (el)
|
|
1400
|
-
el.textContent = "mailx [server offline]";
|
|
1401
|
-
}
|
|
1462
|
+
const els = document.querySelectorAll(".app-version");
|
|
1463
|
+
const text = isApp ? `mailx [version error: ${e.message}]` : "mailx [server offline]";
|
|
1464
|
+
for (const el of els)
|
|
1465
|
+
el.textContent = text;
|
|
1402
1466
|
});
|
|
1403
1467
|
// ── Sync pending indicator + server health check (HTTP mode only) ──
|
|
1404
1468
|
let serverDown = false;
|
|
@@ -18,6 +18,47 @@ const expandState = JSON.parse(localStorage.getItem("mailx-folders-expanded") ||
|
|
|
18
18
|
function saveExpandState() {
|
|
19
19
|
localStorage.setItem("mailx-folders-expanded", JSON.stringify(expandState));
|
|
20
20
|
}
|
|
21
|
+
// Last-sync tracking: populated by folderSynced events. Memory-only; fresh
|
|
22
|
+
// restarts start with an empty map and fill in as syncs happen.
|
|
23
|
+
const folderLastSync = new Map(); // key = `${accountId}:${folderId}`
|
|
24
|
+
function syncKey(accountId, folderId) {
|
|
25
|
+
return `${accountId}:${folderId}`;
|
|
26
|
+
}
|
|
27
|
+
export function setFolderSynced(accountId, folderId, syncedAt) {
|
|
28
|
+
folderLastSync.set(syncKey(accountId, folderId), syncedAt);
|
|
29
|
+
// Update the row in place if rendered — avoids a full re-render.
|
|
30
|
+
const el = document.querySelector(`.ft-folder[data-account-id="${CSS.escape(accountId)}"][data-folder-id="${folderId}"]`);
|
|
31
|
+
if (el)
|
|
32
|
+
applyFreshness(el, syncedAt);
|
|
33
|
+
}
|
|
34
|
+
export function getFolderSynced(accountId, folderId) {
|
|
35
|
+
return folderLastSync.get(syncKey(accountId, folderId));
|
|
36
|
+
}
|
|
37
|
+
function formatAge(ms) {
|
|
38
|
+
const secs = Math.round(ms / 1000);
|
|
39
|
+
if (secs < 60)
|
|
40
|
+
return `${secs}s ago`;
|
|
41
|
+
const mins = Math.round(secs / 60);
|
|
42
|
+
if (mins < 60)
|
|
43
|
+
return `${mins}m ago`;
|
|
44
|
+
const hours = Math.round(mins / 60);
|
|
45
|
+
if (hours < 24)
|
|
46
|
+
return `${hours}h ago`;
|
|
47
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
48
|
+
}
|
|
49
|
+
function freshnessClass(ageMs) {
|
|
50
|
+
if (ageMs < 5 * 60_000)
|
|
51
|
+
return "fresh"; // green
|
|
52
|
+
if (ageMs < 30 * 60_000)
|
|
53
|
+
return "stale-soft"; // yellow
|
|
54
|
+
return "stale"; // red
|
|
55
|
+
}
|
|
56
|
+
function applyFreshness(el, syncedAt) {
|
|
57
|
+
const age = Date.now() - syncedAt;
|
|
58
|
+
el.classList.remove("fresh", "stale-soft", "stale");
|
|
59
|
+
el.classList.add(freshnessClass(age));
|
|
60
|
+
el.title = `Last synced: ${formatAge(age)} (${new Date(syncedAt).toLocaleTimeString()})`;
|
|
61
|
+
}
|
|
21
62
|
/** Build a tree from flat folder list using delimiter */
|
|
22
63
|
function buildTree(folders, delimiter, accountId) {
|
|
23
64
|
const root = [];
|
|
@@ -140,10 +181,17 @@ function renderNode(node, container, depth) {
|
|
|
140
181
|
toggle.textContent = " ";
|
|
141
182
|
}
|
|
142
183
|
folderEl.appendChild(toggle);
|
|
184
|
+
const freshnessDot = document.createElement("span");
|
|
185
|
+
freshnessDot.className = "ft-freshness";
|
|
186
|
+
freshnessDot.setAttribute("aria-hidden", "true");
|
|
187
|
+
folderEl.appendChild(freshnessDot);
|
|
143
188
|
const nameSpan = document.createElement("span");
|
|
144
189
|
nameSpan.className = "ft-folder-name";
|
|
145
190
|
nameSpan.textContent = node.name;
|
|
146
191
|
folderEl.appendChild(nameSpan);
|
|
192
|
+
const syncedAt = getFolderSynced(node.accountId, node.id);
|
|
193
|
+
if (syncedAt)
|
|
194
|
+
applyFreshness(folderEl, syncedAt);
|
|
147
195
|
const isOutbox = node.specialUse === "outbox" || node.path.toLowerCase() === "outbox";
|
|
148
196
|
if (isOutbox && node.totalCount > 0) {
|
|
149
197
|
// Outbox: show total (pending) count with warning style
|
|
@@ -49,8 +49,13 @@ function clampZoom(z) {
|
|
|
49
49
|
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(z * 100) / 100));
|
|
50
50
|
}
|
|
51
51
|
function applyZoom(doc) {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Zoom lives on <html>, not <body>, because WebView2/Chromium's scroll
|
|
53
|
+
// container is the root element — body.style.zoom leaves the scroll
|
|
54
|
+
// container out of sync with the zoomed content, breaking scrollbar
|
|
55
|
+
// display and wheel scrolling. documentElement.style.zoom keeps them
|
|
56
|
+
// aligned so the iframe scrolls normally at any zoom level.
|
|
57
|
+
if (doc.documentElement)
|
|
58
|
+
doc.documentElement.style.zoom = String(previewZoom);
|
|
54
59
|
}
|
|
55
60
|
function setZoom(z, doc) {
|
|
56
61
|
previewZoom = clampZoom(z);
|
|
@@ -96,6 +101,9 @@ function installPreviewControls(iframe) {
|
|
|
96
101
|
e.preventDefault();
|
|
97
102
|
setZoom(previewZoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), doc);
|
|
98
103
|
}, { passive: false });
|
|
104
|
+
// Link interception lives in the iframe's own inline <script> (see
|
|
105
|
+
// wrapHtmlBody). That script runs under a CSP nonce so email-body
|
|
106
|
+
// scripts stay blocked while ours forwards taps to the parent frame.
|
|
99
107
|
doc.addEventListener("contextmenu", (e) => {
|
|
100
108
|
e.preventDefault();
|
|
101
109
|
const me = e;
|
|
@@ -411,6 +419,12 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
411
419
|
iframe.sandbox.add("allow-popups");
|
|
412
420
|
iframe.sandbox.add("allow-popups-to-escape-sandbox");
|
|
413
421
|
iframe.sandbox.add("allow-top-navigation-by-user-activation");
|
|
422
|
+
// allow-scripts lets OUR injected <script> run (for Android link
|
|
423
|
+
// interception — parent-side contentDocument listeners don't fire
|
|
424
|
+
// reliably on Android WebView). CSP with a nonce restricts script
|
|
425
|
+
// execution to our tag only; inline scripts in the email body are
|
|
426
|
+
// still blocked.
|
|
427
|
+
iframe.sandbox.add("allow-scripts");
|
|
414
428
|
iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
|
|
415
429
|
bodyEl.appendChild(iframe);
|
|
416
430
|
installPreviewControls(iframe);
|
|
@@ -503,7 +517,14 @@ function formatSize(bytes) {
|
|
|
503
517
|
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
504
518
|
}
|
|
505
519
|
function wrapHtmlBody(html, allowRemote = false) {
|
|
506
|
-
// CSP blocks remote
|
|
520
|
+
// CSP blocks remote resources (tracking pixels, external CSS). Inline
|
|
521
|
+
// scripts are allowed via 'unsafe-inline' so our injected link-tap handler
|
|
522
|
+
// runs; email-body <script> tags and on* handlers are stripped server-side
|
|
523
|
+
// by sanitizeHtml() in mailx-core, so this doesn't actually widen the
|
|
524
|
+
// attack surface. (A per-render nonce would be tidier, but meta-CSP with
|
|
525
|
+
// nonces isn't reliably honored across older WebViews — and when a nonce
|
|
526
|
+
// is present, 'unsafe-inline' is ignored, so our script fell back to
|
|
527
|
+
// blocked on those WebViews.)
|
|
507
528
|
const csp = allowRemote
|
|
508
529
|
? ""
|
|
509
530
|
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: cid:; form-action 'none';">`;
|
|
@@ -512,6 +533,7 @@ function wrapHtmlBody(html, allowRemote = false) {
|
|
|
512
533
|
<meta charset="UTF-8">
|
|
513
534
|
${csp}
|
|
514
535
|
<style>
|
|
536
|
+
html { height: 100%; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; }
|
|
515
537
|
body {
|
|
516
538
|
font-family: system-ui, sans-serif;
|
|
517
539
|
font-size: 17.5px;
|
|
@@ -520,6 +542,7 @@ ${csp}
|
|
|
520
542
|
background: #fff;
|
|
521
543
|
padding: 1rem;
|
|
522
544
|
margin: 0;
|
|
545
|
+
min-height: 100%;
|
|
523
546
|
word-break: break-word;
|
|
524
547
|
color-scheme: dark light;
|
|
525
548
|
}
|
|
@@ -535,27 +558,34 @@ ${csp}
|
|
|
535
558
|
</style>
|
|
536
559
|
<base target="_blank">
|
|
537
560
|
<script>
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
561
|
+
// Link interception — Android WebView doesn't fire the default <a target="_blank">
|
|
562
|
+
// new-window handler, so we postMessage to the parent which routes through the
|
|
563
|
+
// native bridge (mailxapi://openurl) to Launcher.OpenAsync.
|
|
564
|
+
(function () {
|
|
565
|
+
function handleLinkTap(e) {
|
|
566
|
+
var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|
|
567
|
+
if (!a) return;
|
|
568
|
+
var url = a.href;
|
|
569
|
+
if (!url || url.indexOf("javascript:") === 0 || url.charAt(0) === "#") return;
|
|
570
|
+
e.preventDefault();
|
|
571
|
+
e.stopPropagation();
|
|
572
|
+
window.parent.postMessage({ type: "linkClick", url: url }, "*");
|
|
573
|
+
}
|
|
574
|
+
document.addEventListener("click", handleLinkTap, true);
|
|
575
|
+
var lastTouchTarget = null;
|
|
576
|
+
document.addEventListener("touchstart", function (e) {
|
|
577
|
+
lastTouchTarget = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
|
|
578
|
+
}, true);
|
|
579
|
+
document.addEventListener("touchend", function (e) {
|
|
580
|
+
var t = (e.target && e.target.closest) ? e.target.closest("a[href]") || e.target : e.target;
|
|
581
|
+
if (lastTouchTarget && lastTouchTarget === t) handleLinkTap(e);
|
|
582
|
+
lastTouchTarget = null;
|
|
583
|
+
}, true);
|
|
584
|
+
document.addEventListener("mouseover", function (e) {
|
|
585
|
+
var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|
|
586
|
+
window.parent.postMessage({ type: "linkHover", url: a ? a.href : "" }, "*");
|
|
587
|
+
});
|
|
588
|
+
})();
|
|
559
589
|
</script>
|
|
560
590
|
</head><body>${html}</body></html>`;
|
|
561
591
|
}
|
package/client/index.html
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<div class="toolbar-left">
|
|
16
16
|
<button class="tb-btn" id="btn-menu" title="Folders" hidden>☰</button>
|
|
17
17
|
<button class="tb-btn" id="btn-compose" title="Compose (Ctrl+N)">
|
|
18
|
-
<span class="tb-icon">✏</span> Compose
|
|
18
|
+
<span class="tb-icon">✏</span><span class="tb-label"> Compose</span>
|
|
19
19
|
</button>
|
|
20
20
|
</div>
|
|
21
21
|
<div class="toolbar-center">
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
</div>
|
|
47
47
|
<div class="toolbar-right">
|
|
48
48
|
<button class="tb-btn" id="btn-sync" title="Sync all folders (F5)">
|
|
49
|
-
<span class="tb-icon">↻</span> Sync
|
|
49
|
+
<span class="tb-icon">↻</span><span class="tb-label"> Sync</span>
|
|
50
50
|
</button>
|
|
51
51
|
<div class="tb-menu" id="restart-menu">
|
|
52
52
|
<button class="tb-btn" id="btn-restart" title="Reload">
|
|
53
|
-
<span class="tb-icon">⚡</span> Restart
|
|
53
|
+
<span class="tb-icon">⚡</span><span class="tb-label"> Restart ▾</span>
|
|
54
54
|
</button>
|
|
55
55
|
<div class="tb-menu-dropdown" id="restart-dropdown" hidden>
|
|
56
56
|
<button class="tb-menu-item" id="btn-restart-quick" title="Reload the page">Reload</button>
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
</select>
|
|
87
87
|
<input type="search" id="search-input" placeholder="Search... (/regex/)" autocomplete="off" title="Search messages. /pattern/ for regex. Qualifiers: from: to: subject:">
|
|
88
88
|
</search>
|
|
89
|
+
<div class="ml-folder-title" id="ml-folder-title"></div>
|
|
89
90
|
<div class="ml-header">
|
|
90
91
|
<span class="ml-col ml-col-flag"></span>
|
|
91
92
|
<span class="ml-col ml-col-from" data-sort="from">From</span>
|
|
@@ -134,6 +135,7 @@
|
|
|
134
135
|
<span id="status-sync">Syncing...</span>
|
|
135
136
|
<span id="status-pending"></span>
|
|
136
137
|
<span id="status-queue"></span>
|
|
138
|
+
<span class="app-version" id="status-version">mailx</span>
|
|
137
139
|
</footer>
|
|
138
140
|
|
|
139
141
|
<div id="startup-overlay" class="startup-overlay">
|
|
@@ -334,6 +334,43 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
334
334
|
.ml-col { cursor: pointer; &:hover { color: var(--color-text); } }
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
/* Narrow-mode folder title above the list — hidden on wide where the window
|
|
338
|
+
title and toolbar already convey location. */
|
|
339
|
+
.ml-folder-title {
|
|
340
|
+
grid-column: 1 / -1;
|
|
341
|
+
display: none;
|
|
342
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
343
|
+
border-bottom: 1px solid var(--color-border);
|
|
344
|
+
background: var(--color-bg-surface);
|
|
345
|
+
font-size: var(--font-size-lg);
|
|
346
|
+
font-weight: 600;
|
|
347
|
+
color: var(--color-text);
|
|
348
|
+
user-select: none;
|
|
349
|
+
}
|
|
350
|
+
.ml-folder-age {
|
|
351
|
+
font-weight: 400;
|
|
352
|
+
font-size: var(--font-size-sm);
|
|
353
|
+
color: var(--color-text-muted);
|
|
354
|
+
margin-left: var(--gap-xs);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* Per-folder freshness dot — empty by default, colored once the folder has
|
|
358
|
+
a known last-sync time. Fresh (<5m) green, stale-soft (<30m) yellow,
|
|
359
|
+
stale (30m+) red. */
|
|
360
|
+
.ft-folder .ft-freshness {
|
|
361
|
+
display: inline-block;
|
|
362
|
+
width: 8px;
|
|
363
|
+
height: 8px;
|
|
364
|
+
margin-right: 6px;
|
|
365
|
+
border-radius: 50%;
|
|
366
|
+
background: transparent;
|
|
367
|
+
border: 1px solid color-mix(in oklch, var(--color-text-muted) 40%, transparent);
|
|
368
|
+
flex-shrink: 0;
|
|
369
|
+
}
|
|
370
|
+
.ft-folder.fresh .ft-freshness { background: #4a9; border-color: transparent; }
|
|
371
|
+
.ft-folder.stale-soft .ft-freshness { background: #d7a324; border-color: transparent; }
|
|
372
|
+
.ft-folder.stale .ft-freshness { background: #c74; border-color: transparent; }
|
|
373
|
+
|
|
337
374
|
.ml-body {
|
|
338
375
|
grid-column: 1 / -1;
|
|
339
376
|
overflow-y: auto;
|
package/client/styles/layout.css
CHANGED
|
@@ -91,6 +91,25 @@ body {
|
|
|
91
91
|
@media (max-width: 768px), (max-height: 600px) {
|
|
92
92
|
/* Hide preview snippet under message subject — save space */
|
|
93
93
|
.ml-preview { display: none; }
|
|
94
|
+
/* Column headers (From/Date/Subject) take space without being useful on narrow */
|
|
95
|
+
.ml-header { display: none; }
|
|
96
|
+
/* Current folder name shown above the list, Dovecot-style */
|
|
97
|
+
.ml-folder-title { display: block; }
|
|
98
|
+
/* Version string overflows the toolbar on narrow — move to the status bar */
|
|
99
|
+
#app-version { display: none; }
|
|
100
|
+
#status-version {
|
|
101
|
+
color: var(--color-text-muted);
|
|
102
|
+
font-size: var(--font-size-sm);
|
|
103
|
+
margin-left: auto;
|
|
104
|
+
}
|
|
105
|
+
/* Drop button captions on narrow — icons are self-explanatory and captions overflow */
|
|
106
|
+
.toolbar .tb-label { display: none; }
|
|
107
|
+
/* View and Settings menus don't need to shout on narrow */
|
|
108
|
+
#view-menu, #settings-menu { display: none; }
|
|
109
|
+
}
|
|
110
|
+
@media (min-width: 769px) {
|
|
111
|
+
/* Status-bar version is a narrow-only mirror — keep it hidden on wide */
|
|
112
|
+
#status-version { display: none; }
|
|
94
113
|
}
|
|
95
114
|
@media (max-width: 768px), (max-height: 600px) {
|
|
96
115
|
body {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.251",
|
|
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.
|
|
24
|
-
"@bobfrankston/iflow-node": "^0.1.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.15",
|
|
24
|
+
"@bobfrankston/iflow-node": "^0.1.5",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.313",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -34,7 +34,10 @@
|
|
|
34
34
|
"nodemailer": "^7.0.0",
|
|
35
35
|
"quill": "^2.0.3",
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
|
-
"sql.js": "^1.14.1"
|
|
37
|
+
"sql.js": "^1.14.1",
|
|
38
|
+
"@bobfrankston/tcp-transport": "^0.1.3",
|
|
39
|
+
"@bobfrankston/node-tcp-transport": "^0.1.1",
|
|
40
|
+
"@bobfrankston/smtp-direct": "^0.1.2"
|
|
38
41
|
},
|
|
39
42
|
"devDependencies": {
|
|
40
43
|
"@types/mailparser": "^3.4.6"
|
|
@@ -70,15 +73,18 @@
|
|
|
70
73
|
"nodemailer": "^7.0.0",
|
|
71
74
|
"quill": "^2.0.3",
|
|
72
75
|
"ws": "^8.18.0",
|
|
73
|
-
"sql.js": "^1.14.1"
|
|
76
|
+
"sql.js": "^1.14.1",
|
|
77
|
+
"@bobfrankston/tcp-transport": "file:../MailApps/tcp-transport",
|
|
78
|
+
"@bobfrankston/node-tcp-transport": "file:../MailApps/node-tcp-transport",
|
|
79
|
+
"@bobfrankston/smtp-direct": "file:../MailApps/smtp-direct"
|
|
74
80
|
},
|
|
75
81
|
".transformedSnapshot": {
|
|
76
82
|
"dependencies": {
|
|
77
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
78
|
-
"@bobfrankston/iflow-node": "^0.1.
|
|
83
|
+
"@bobfrankston/iflow-direct": "^0.1.15",
|
|
84
|
+
"@bobfrankston/iflow-node": "^0.1.5",
|
|
79
85
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
86
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
-
"@bobfrankston/msger": "^0.1.
|
|
87
|
+
"@bobfrankston/msger": "^0.1.313",
|
|
82
88
|
"@capacitor/android": "^8.3.0",
|
|
83
89
|
"@capacitor/cli": "^8.3.0",
|
|
84
90
|
"@capacitor/core": "^8.3.0",
|
|
@@ -88,7 +94,10 @@
|
|
|
88
94
|
"nodemailer": "^7.0.0",
|
|
89
95
|
"quill": "^2.0.3",
|
|
90
96
|
"ws": "^8.18.0",
|
|
91
|
-
"sql.js": "^1.14.1"
|
|
97
|
+
"sql.js": "^1.14.1",
|
|
98
|
+
"@bobfrankston/tcp-transport": "^0.1.3",
|
|
99
|
+
"@bobfrankston/node-tcp-transport": "^0.1.1",
|
|
100
|
+
"@bobfrankston/smtp-direct": "^0.1.2"
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
103
|
}
|
|
@@ -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";
|
|
@@ -208,7 +208,9 @@ export declare class ImapManager extends EventEmitter {
|
|
|
208
208
|
* directory is safe again. Any legitimate files that land there (crash
|
|
209
209
|
* recovery, manual drop) will get sent. */
|
|
210
210
|
private processLocalQueue;
|
|
211
|
-
/** 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. */
|
|
212
214
|
private sendRawViaSMTP;
|
|
213
215
|
/** Process Outbox — send pending messages with flag-based interlock */
|
|
214
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 */
|
|
@@ -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.
|