@bobfrankston/mailx 1.0.211 → 1.0.213
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":304,"y":36}
|
|
@@ -37,6 +37,96 @@ export function initViewer() {
|
|
|
37
37
|
// "messages" change (sync reload) — don't touch the viewer
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
+
// Zoom is persisted across messages via localStorage
|
|
41
|
+
const ZOOM_KEY = "mailx-preview-zoom";
|
|
42
|
+
const ZOOM_MIN = 0.5;
|
|
43
|
+
const ZOOM_MAX = 3.0;
|
|
44
|
+
const ZOOM_STEP = 0.1;
|
|
45
|
+
let previewZoom = clampZoom(parseFloat(localStorage.getItem(ZOOM_KEY) || "1"));
|
|
46
|
+
function clampZoom(z) {
|
|
47
|
+
if (!Number.isFinite(z) || z <= 0)
|
|
48
|
+
return 1;
|
|
49
|
+
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(z * 100) / 100));
|
|
50
|
+
}
|
|
51
|
+
function applyZoom(doc) {
|
|
52
|
+
if (doc.body)
|
|
53
|
+
doc.body.style.zoom = String(previewZoom);
|
|
54
|
+
}
|
|
55
|
+
function setZoom(z, doc) {
|
|
56
|
+
previewZoom = clampZoom(z);
|
|
57
|
+
localStorage.setItem(ZOOM_KEY, String(previewZoom));
|
|
58
|
+
applyZoom(doc);
|
|
59
|
+
}
|
|
60
|
+
/** Install preview iframe controls: key forwarding to parent, Ctrl+wheel zoom,
|
|
61
|
+
* keyboard zoom shortcuts (Ctrl+= / Ctrl+- / Ctrl+0), and the right-click menu. */
|
|
62
|
+
function installPreviewControls(iframe) {
|
|
63
|
+
const attach = () => {
|
|
64
|
+
const doc = iframe.contentDocument;
|
|
65
|
+
if (!doc)
|
|
66
|
+
return;
|
|
67
|
+
applyZoom(doc);
|
|
68
|
+
doc.addEventListener("keydown", (e) => {
|
|
69
|
+
const target = e.target;
|
|
70
|
+
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)))
|
|
71
|
+
return;
|
|
72
|
+
if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
setZoom(previewZoom + ZOOM_STEP, doc);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (e.ctrlKey && e.key === "-") {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
setZoom(previewZoom - ZOOM_STEP, doc);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (e.ctrlKey && e.key === "0") {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
setZoom(1, doc);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
document.dispatchEvent(new KeyboardEvent("keydown", {
|
|
88
|
+
key: e.key, code: e.code,
|
|
89
|
+
ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
|
|
90
|
+
bubbles: true, cancelable: true,
|
|
91
|
+
}));
|
|
92
|
+
});
|
|
93
|
+
doc.addEventListener("wheel", (e) => {
|
|
94
|
+
if (!e.ctrlKey)
|
|
95
|
+
return;
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
setZoom(previewZoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), doc);
|
|
98
|
+
}, { passive: false });
|
|
99
|
+
doc.addEventListener("contextmenu", (e) => {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
const me = e;
|
|
102
|
+
const rect = iframe.getBoundingClientRect();
|
|
103
|
+
const x = rect.left + me.clientX;
|
|
104
|
+
const y = rect.top + me.clientY;
|
|
105
|
+
const pct = Math.round(previewZoom * 100);
|
|
106
|
+
const items = [
|
|
107
|
+
{ label: "Copy", action: () => doc.execCommand("copy") },
|
|
108
|
+
{ label: "Select all", action: () => {
|
|
109
|
+
const sel = doc.defaultView?.getSelection();
|
|
110
|
+
if (!sel)
|
|
111
|
+
return;
|
|
112
|
+
const range = doc.createRange();
|
|
113
|
+
range.selectNodeContents(doc.body);
|
|
114
|
+
sel.removeAllRanges();
|
|
115
|
+
sel.addRange(range);
|
|
116
|
+
} },
|
|
117
|
+
{ label: "", action: () => { }, separator: true },
|
|
118
|
+
{ label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) },
|
|
119
|
+
{ label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) },
|
|
120
|
+
{ label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) },
|
|
121
|
+
];
|
|
122
|
+
showContextMenu(x, y, items);
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
if (iframe.contentDocument?.readyState === "complete")
|
|
126
|
+
attach();
|
|
127
|
+
else
|
|
128
|
+
iframe.addEventListener("load", attach, { once: true });
|
|
129
|
+
}
|
|
40
130
|
function clearViewer() {
|
|
41
131
|
currentMessage = null;
|
|
42
132
|
currentAccountId = "";
|
|
@@ -251,6 +341,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
251
341
|
const iframe = document.createElement("iframe");
|
|
252
342
|
iframe.srcdoc = wrapHtmlBody(full.bodyHtml, true);
|
|
253
343
|
bodyEl.appendChild(iframe);
|
|
344
|
+
installPreviewControls(iframe);
|
|
254
345
|
}
|
|
255
346
|
};
|
|
256
347
|
banner.querySelector("#btn-load-remote").addEventListener("click", loadRemote);
|
|
@@ -279,6 +370,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
279
370
|
iframe.sandbox.add("allow-top-navigation-by-user-activation");
|
|
280
371
|
iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
|
|
281
372
|
bodyEl.appendChild(iframe);
|
|
373
|
+
installPreviewControls(iframe);
|
|
282
374
|
}
|
|
283
375
|
else if (msg.bodyText) {
|
|
284
376
|
const pre = document.createElement("pre");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.213",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.275",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -71,5 +71,24 @@
|
|
|
71
71
|
"quill": "^2.0.3",
|
|
72
72
|
"ws": "^8.18.0",
|
|
73
73
|
"sql.js": "^1.14.1"
|
|
74
|
+
},
|
|
75
|
+
".transformedSnapshot": {
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"@bobfrankston/iflow-direct": "^0.1.6",
|
|
78
|
+
"@bobfrankston/iflow-node": "^0.1.2",
|
|
79
|
+
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
|
+
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
+
"@bobfrankston/msger": "^0.1.275",
|
|
82
|
+
"@capacitor/android": "^8.3.0",
|
|
83
|
+
"@capacitor/cli": "^8.3.0",
|
|
84
|
+
"@capacitor/core": "^8.3.0",
|
|
85
|
+
"express": "^4.21.0",
|
|
86
|
+
"jsonc-parser": "^3.3.1",
|
|
87
|
+
"mailparser": "^3.7.2",
|
|
88
|
+
"nodemailer": "^7.0.0",
|
|
89
|
+
"quill": "^2.0.3",
|
|
90
|
+
"ws": "^8.18.0",
|
|
91
|
+
"sql.js": "^1.14.1"
|
|
92
|
+
}
|
|
74
93
|
}
|
|
75
94
|
}
|
|
@@ -38,6 +38,13 @@ function emitEvent(event) {
|
|
|
38
38
|
function toEmailAddress(addr) {
|
|
39
39
|
return { name: addr?.name || "", address: addr?.address || "" };
|
|
40
40
|
}
|
|
41
|
+
/** Verbose log — goes to logit but doesn't clutter the screen (silent=true) */
|
|
42
|
+
function vlog(msg) {
|
|
43
|
+
try {
|
|
44
|
+
fetch(`https://rmf39.aaz.lt/logit/${encodeURIComponent("V/" + msg.substring(0, 800))}?log=mailx-android&silent=true`).catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
catch { /* ignore */ }
|
|
47
|
+
}
|
|
41
48
|
// ── Sync Manager ──
|
|
42
49
|
class AndroidSyncManager {
|
|
43
50
|
db;
|
|
@@ -51,6 +58,7 @@ class AndroidSyncManager {
|
|
|
51
58
|
on(_event, _handler) { }
|
|
52
59
|
emit(event, ...args) { emitEvent({ type: event, ...args[0] }); }
|
|
53
60
|
async addAccount(account) {
|
|
61
|
+
vlog(`addAccount id=${account.id} email=${account.email} host=${account.imap?.host} auth=${account.imap?.auth}`);
|
|
54
62
|
this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
55
63
|
if (this.isGmailAccount(account)) {
|
|
56
64
|
const tokenProvider = this.tokenProviders.get(account.id);
|
|
@@ -62,6 +70,9 @@ class AndroidSyncManager {
|
|
|
62
70
|
console.warn(`[sync] ${account.id}: no token provider`);
|
|
63
71
|
}
|
|
64
72
|
}
|
|
73
|
+
else {
|
|
74
|
+
vlog(`addAccount ${account.id}: NOT a Gmail account — needs IMAP via TCP bridge (not yet implemented)`);
|
|
75
|
+
}
|
|
65
76
|
}
|
|
66
77
|
setTokenProvider(accountId, provider) {
|
|
67
78
|
this.tokenProviders.set(accountId, provider);
|
|
@@ -74,9 +85,13 @@ class AndroidSyncManager {
|
|
|
74
85
|
}
|
|
75
86
|
async syncAll() {
|
|
76
87
|
const accounts = this.db.getAccounts();
|
|
88
|
+
vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map(a => a.id).join(",")}`);
|
|
77
89
|
for (const account of accounts) {
|
|
90
|
+
const hasProvider = this.providers.has(account.id);
|
|
91
|
+
vlog(`syncAll: ${account.id} hasProvider=${hasProvider}`);
|
|
78
92
|
try {
|
|
79
93
|
const folders = await this.syncFolders(account.id);
|
|
94
|
+
vlog(`syncAll: ${account.id} got ${folders.length} folders`);
|
|
80
95
|
const sorted = [...folders].sort((a, b) => {
|
|
81
96
|
if (a.specialUse === "inbox")
|
|
82
97
|
return -1;
|
|
@@ -97,14 +112,18 @@ class AndroidSyncManager {
|
|
|
97
112
|
}
|
|
98
113
|
catch (e) {
|
|
99
114
|
console.error(`[sync] ${account.id}: ${e.message}`);
|
|
115
|
+
vlog(`syncAll: ${account.id} ERROR: ${e.message}`);
|
|
100
116
|
emitEvent({ type: "syncError", accountId: account.id, error: e.message });
|
|
101
117
|
}
|
|
102
118
|
}
|
|
103
119
|
}
|
|
104
120
|
async syncFolders(accountId) {
|
|
105
121
|
const provider = this.getProvider(accountId);
|
|
106
|
-
if (!provider)
|
|
107
|
-
|
|
122
|
+
if (!provider) {
|
|
123
|
+
const existing = this.db.getFolders(accountId);
|
|
124
|
+
vlog(`syncFolders: ${accountId} no provider, returning ${existing.length} cached folders`);
|
|
125
|
+
return existing;
|
|
126
|
+
}
|
|
108
127
|
emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 0 });
|
|
109
128
|
const providerFolders = await provider.listFolders();
|
|
110
129
|
for (const folder of providerFolders) {
|
|
@@ -546,8 +565,11 @@ export async function initAndroid() {
|
|
|
546
565
|
// Use canonical GDrive accounts (upsert handles overwrites)
|
|
547
566
|
accounts = gdriveAccounts;
|
|
548
567
|
for (const account of accounts) {
|
|
549
|
-
|
|
568
|
+
vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
|
|
569
|
+
if (!account.enabled) {
|
|
570
|
+
vlog(`init: ${account.id} disabled, skipping`);
|
|
550
571
|
continue;
|
|
572
|
+
}
|
|
551
573
|
const domain = account.email?.split("@")[1]?.toLowerCase() || "";
|
|
552
574
|
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
553
575
|
syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
|
|
@@ -134,29 +134,62 @@ function parseMimeParts(body, boundary, topHeaders) {
|
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
else if (partType.includes("text/html")) {
|
|
137
|
-
|
|
137
|
+
const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i);
|
|
138
|
+
html = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8");
|
|
138
139
|
}
|
|
139
140
|
else if (partType.includes("text/plain")) {
|
|
140
|
-
|
|
141
|
+
const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i);
|
|
142
|
+
text = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8");
|
|
141
143
|
}
|
|
142
144
|
}
|
|
143
145
|
return { html, text, headers: topHeaders, attachments };
|
|
144
146
|
}
|
|
145
|
-
function decodeBody(body, encoding) {
|
|
147
|
+
function decodeBody(body, encoding, charset = "utf-8") {
|
|
148
|
+
// Step 1: decode the transfer encoding to a byte array
|
|
149
|
+
let bytes;
|
|
146
150
|
if (encoding === "base64") {
|
|
147
151
|
try {
|
|
148
|
-
|
|
152
|
+
const binary = atob(body.replace(/\s/g, ""));
|
|
153
|
+
bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
149
154
|
}
|
|
150
155
|
catch {
|
|
151
156
|
return body;
|
|
152
157
|
}
|
|
153
158
|
}
|
|
154
|
-
if (encoding === "quoted-printable") {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
159
|
+
else if (encoding === "quoted-printable") {
|
|
160
|
+
// Decode QP into bytes (NOT into a string — multi-byte UTF-8 must stay as bytes)
|
|
161
|
+
const cleaned = body.replace(/=\r?\n/g, "");
|
|
162
|
+
const out = [];
|
|
163
|
+
for (let i = 0; i < cleaned.length; i++) {
|
|
164
|
+
const c = cleaned[i];
|
|
165
|
+
if (c === "=" && i + 2 < cleaned.length && /[0-9A-Fa-f]{2}/.test(cleaned.substr(i + 1, 2))) {
|
|
166
|
+
out.push(parseInt(cleaned.substr(i + 1, 2), 16));
|
|
167
|
+
i += 2;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Existing character — encode as its byte (assumes ASCII for QP source)
|
|
171
|
+
out.push(c.charCodeAt(0) & 0xff);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
bytes = new Uint8Array(out);
|
|
175
|
+
}
|
|
176
|
+
else if (encoding === "7bit" || encoding === "8bit" || encoding === "" || encoding === "binary") {
|
|
177
|
+
// No transfer encoding — body is already a string of single-byte chars
|
|
178
|
+
bytes = Uint8Array.from(body, c => c.charCodeAt(0) & 0xff);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// Unknown encoding — return as-is
|
|
182
|
+
return body;
|
|
183
|
+
}
|
|
184
|
+
// Step 2: decode bytes using the declared charset (default UTF-8)
|
|
185
|
+
try {
|
|
186
|
+
const normalized = charset.toLowerCase().replace("windows-", "windows-").replace("iso-", "iso-");
|
|
187
|
+
return new TextDecoder(normalized).decode(bytes);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Unknown charset — fall back to UTF-8 with replacement chars
|
|
191
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
158
192
|
}
|
|
159
|
-
return body;
|
|
160
193
|
}
|
|
161
194
|
// ── Quoted-printable encoding (for compose/send) ──
|
|
162
195
|
function encodeQuotedPrintable(text) {
|