@bobfrankston/mailx 1.0.244 → 1.0.251
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mailx.js +20 -4
- package/client/.msger-window.json +1 -1
- package/client/android.html +4 -0
- package/client/app.js +90 -22
- package/client/components/folder-tree.js +48 -0
- package/client/components/message-viewer.js +54 -16
- package/client/index.html +5 -3
- package/client/styles/components.css +37 -0
- package/client/styles/layout.css +19 -0
- package/package.json +19 -10
- package/packages/mailx-imap/index.d.ts +14 -7
- package/packages/mailx-imap/index.js +207 -87
- package/packages/mailx-imap/package.json +4 -3
- package/packages/mailx-imap/providers/gmail-api.js +24 -5
- package/packages/mailx-server/index.js +3 -0
- package/packages/mailx-store/db.js +4 -2
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/android-bootstrap.js +173 -3
- package/packages/mailx-store-web/gmail-api-web.d.ts +7 -0
- package/packages/mailx-store-web/gmail-api-web.js +12 -0
- package/packages/mailx-store-web/imap-web-provider.d.ts +9 -0
- package/packages/mailx-store-web/imap-web-provider.js +45 -8
- package/packages/mailx-store-web/package.json +4 -1
- package/packages/mailx-types/index.d.ts +7 -0
- package/test-smtp-direct.mjs +4 -0
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,14 +256,27 @@ 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)
|
|
274
|
+
// Also reset narrow navigation: show message list, hide viewer
|
|
224
275
|
document.getElementById("folder-tree")?.addEventListener("click", (e) => {
|
|
225
276
|
if (window.innerWidth <= 768 && e.target.closest(".ft-folder")) {
|
|
226
277
|
document.querySelector(".folder-panel")?.classList.remove("open");
|
|
278
|
+
document.getElementById("message-viewer")?.classList.remove("narrow-active");
|
|
279
|
+
document.getElementById("message-list")?.classList.remove("narrow-hidden");
|
|
227
280
|
}
|
|
228
281
|
});
|
|
229
282
|
// Close folder overlay when user clicks outside it (narrow mode OR
|
|
@@ -289,6 +342,14 @@ document.getElementById("btn-restart-quick")?.addEventListener("click", async ()
|
|
|
289
342
|
if (restartDropdown)
|
|
290
343
|
restartDropdown.hidden = true;
|
|
291
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
|
+
}
|
|
292
353
|
// IPC mode: reload the UI (no server to restart)
|
|
293
354
|
location.reload();
|
|
294
355
|
}
|
|
@@ -755,10 +816,11 @@ window.addEventListener("message", (e) => {
|
|
|
755
816
|
if (e.data?.type === "linkClick" && e.data.url) {
|
|
756
817
|
const url = e.data.url;
|
|
757
818
|
if (window.mailxapi?.platform === "android") {
|
|
758
|
-
// Android: use
|
|
819
|
+
// Android: use mailxapi:// bridge scheme — OnNavigating intercepts it
|
|
820
|
+
// and opens in system browser. Raw http:// in sub-frames doesn't trigger OnNavigating.
|
|
759
821
|
const f = document.createElement("iframe");
|
|
760
822
|
f.style.display = "none";
|
|
761
|
-
f.src = url
|
|
823
|
+
f.src = `mailxapi://openurl?url=${encodeURIComponent(url)}`;
|
|
762
824
|
document.body.appendChild(f);
|
|
763
825
|
setTimeout(() => f.remove(), 500);
|
|
764
826
|
}
|
|
@@ -855,6 +917,16 @@ onWsEvent((event) => {
|
|
|
855
917
|
// where folders don't exist until sync fetches them from Gmail API)
|
|
856
918
|
refreshFolderTree();
|
|
857
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;
|
|
858
930
|
case "folderCountsChanged": {
|
|
859
931
|
// Update folder badges + silently refresh message list (preserves selection and viewer)
|
|
860
932
|
updateFolderCounts();
|
|
@@ -1339,18 +1411,19 @@ const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
|
1339
1411
|
// Wait for server ready signal, then fetch version
|
|
1340
1412
|
const versionPromise = getVersion();
|
|
1341
1413
|
versionPromise.then((d) => {
|
|
1342
|
-
const
|
|
1414
|
+
const els = document.querySelectorAll(".app-version");
|
|
1343
1415
|
const storage = d.storage || {};
|
|
1344
1416
|
const storageLabel = storage.provider && storage.provider !== "local"
|
|
1345
1417
|
? ` [${storage.provider}]`
|
|
1346
1418
|
: "";
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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;
|
|
1354
1427
|
}
|
|
1355
1428
|
if (d.settingsError) {
|
|
1356
1429
|
showAlert(d.settingsError, "settings-error");
|
|
@@ -1386,15 +1459,10 @@ versionPromise.then((d) => {
|
|
|
1386
1459
|
}
|
|
1387
1460
|
}).catch((e) => {
|
|
1388
1461
|
// Version fetch failed
|
|
1389
|
-
const
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
}
|
|
1394
|
-
else {
|
|
1395
|
-
if (el)
|
|
1396
|
-
el.textContent = "mailx [server offline]";
|
|
1397
|
-
}
|
|
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;
|
|
1398
1466
|
});
|
|
1399
1467
|
// ── Sync pending indicator + server health check (HTTP mode only) ──
|
|
1400
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,19 +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
|
-
}
|
|
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
|
+
})();
|
|
551
589
|
</script>
|
|
552
590
|
</head><body>${html}</body></html>`;
|
|
553
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
|
}
|