@bobfrankston/mailx 1.0.203 → 1.0.205
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/client/.msger-window.json +1 -1
- package/client/app.js +23 -0
- package/client/components/message-viewer.js +3 -2
- package/package.json +2 -2
- package/packages/mailx-imap/providers/gmail-api.js +1 -1
- package/packages/mailx-settings/index.js +1 -0
- package/packages/mailx-store-web/android-bootstrap.js +51 -4
- package/packages/mailx-store-web/web-settings.d.ts +2 -0
- package/packages/mailx-store-web/web-settings.js +14 -0
- package/packages/mailx-types/index.d.ts +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":1344,"width":2151,"x":
|
|
1
|
+
{"height":1344,"width":2151,"x":476,"y":58}
|
package/client/app.js
CHANGED
|
@@ -341,6 +341,7 @@ async function openCompose(mode) {
|
|
|
341
341
|
else if (msg && mode === "forward") {
|
|
342
342
|
init.subject = `Fwd: ${cleanSubject}`;
|
|
343
343
|
init.bodyHtml = forwardBody(msg);
|
|
344
|
+
init.fromAddress = detectReplyFrom();
|
|
344
345
|
}
|
|
345
346
|
// Store init data for compose window to pick up
|
|
346
347
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
@@ -897,6 +898,28 @@ document.addEventListener("keydown", (e) => {
|
|
|
897
898
|
row.classList.toggle("unread", !newFlags.includes("\\Seen"));
|
|
898
899
|
}).catch(() => { });
|
|
899
900
|
}
|
|
901
|
+
// Arrow keys — navigate message list
|
|
902
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
903
|
+
const active = document.activeElement;
|
|
904
|
+
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.tagName === "SELECT"))
|
|
905
|
+
return;
|
|
906
|
+
const body = document.getElementById("ml-body");
|
|
907
|
+
if (!body)
|
|
908
|
+
return;
|
|
909
|
+
const selected = body.querySelector(".ml-row.selected");
|
|
910
|
+
if (!selected) {
|
|
911
|
+
const first = body.querySelector(".ml-row");
|
|
912
|
+
if (first)
|
|
913
|
+
first.click();
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const next = (e.key === "ArrowDown" ? selected.nextElementSibling : selected.previousElementSibling);
|
|
917
|
+
if (next?.classList.contains("ml-row")) {
|
|
918
|
+
e.preventDefault();
|
|
919
|
+
next.click();
|
|
920
|
+
next.scrollIntoView({ block: "nearest" });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
900
923
|
});
|
|
901
924
|
// ── View menu ──
|
|
902
925
|
const viewBtn = document.getElementById("btn-view");
|
|
@@ -290,10 +290,11 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
290
290
|
else {
|
|
291
291
|
bodyEl.innerHTML = `<div class="mv-empty">No content</div>`;
|
|
292
292
|
}
|
|
293
|
-
// Attachments
|
|
293
|
+
// Attachments — always clear first to avoid stale chips from previous message
|
|
294
|
+
attEl.innerHTML = "";
|
|
295
|
+
attEl.hidden = true;
|
|
294
296
|
if (msg.attachments?.length) {
|
|
295
297
|
attEl.hidden = false;
|
|
296
|
-
attEl.innerHTML = "";
|
|
297
298
|
for (let i = 0; i < msg.attachments.length; i++) {
|
|
298
299
|
const att = msg.attachments[i];
|
|
299
300
|
const chip = document.createElement("a");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.205",
|
|
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.266",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -144,7 +144,7 @@ export class GmailApiProvider {
|
|
|
144
144
|
if (options.source && msg.raw) {
|
|
145
145
|
// Gmail returns URL-safe base64 — convert to standard base64 then decode
|
|
146
146
|
const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
147
|
-
source =
|
|
147
|
+
source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
|
|
148
148
|
}
|
|
149
149
|
const fromRaw = getHeader(headers, "From");
|
|
150
150
|
const toRaw = getHeader(headers, "To");
|
|
@@ -321,6 +321,7 @@ function normalizeAccount(acct, globalName) {
|
|
|
321
321
|
syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
|
|
322
322
|
relayDomains: acct.relayDomains,
|
|
323
323
|
deliveredToPrefix: acct.deliveredToPrefix,
|
|
324
|
+
identityDomains: acct.identityDomains,
|
|
324
325
|
};
|
|
325
326
|
}
|
|
326
327
|
// ── Defaults ──
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { WebMailxDB } from "./db.js";
|
|
15
15
|
import { WebMessageStore } from "./web-message-store.js";
|
|
16
16
|
import { WebMailxService } from "./web-service.js";
|
|
17
|
-
import { loadAccounts, saveAccounts, clearSettings } from "./web-settings.js";
|
|
17
|
+
import { loadAccounts, loadAccountsFromCloud, saveAccounts, clearSettings, setGDriveTokenProvider, setGDriveFolderId } from "./web-settings.js";
|
|
18
18
|
import { GmailApiWebProvider } from "./gmail-api-web.js";
|
|
19
19
|
// ── State ──
|
|
20
20
|
let db;
|
|
@@ -363,6 +363,19 @@ function createNativeTokenProvider(email) {
|
|
|
363
363
|
return token.access_token;
|
|
364
364
|
};
|
|
365
365
|
}
|
|
366
|
+
// ── GDrive folder lookup ──
|
|
367
|
+
async function findGDriveMailxFolder(tokenProvider) {
|
|
368
|
+
const token = await tokenProvider();
|
|
369
|
+
const q = encodeURIComponent("name='mailx' and mimeType='application/vnd.google-apps.folder' and trashed=false");
|
|
370
|
+
const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,name)&spaces=drive`, { headers: { "Authorization": `Bearer ${token}` } });
|
|
371
|
+
if (!res.ok) {
|
|
372
|
+
console.warn(`[gdrive] Folder search failed: ${res.status}`);
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const data = await res.json();
|
|
376
|
+
const folder = data.files?.[0];
|
|
377
|
+
return folder?.id || null;
|
|
378
|
+
}
|
|
366
379
|
// ── Initialization ──
|
|
367
380
|
async function waitForNativeBridge(timeoutMs = 5000) {
|
|
368
381
|
if (window._nativeBridge)
|
|
@@ -392,18 +405,52 @@ export async function initAndroid() {
|
|
|
392
405
|
bodyStore = new WebMessageStore();
|
|
393
406
|
syncManager = new AndroidSyncManager(db, bodyStore);
|
|
394
407
|
service = new WebMailxService(db, bodyStore, syncManager);
|
|
395
|
-
|
|
408
|
+
let accounts = await loadAccounts();
|
|
396
409
|
console.log(`[android] ${accounts.length} account(s) found`);
|
|
410
|
+
// Find a Gmail account to use as the GDrive token provider
|
|
411
|
+
let gmailTokenProvider = null;
|
|
397
412
|
for (const account of accounts) {
|
|
398
413
|
if (!account.enabled)
|
|
399
414
|
continue;
|
|
400
|
-
// Set up OAuth token provider for Gmail accounts via Android AccountManager
|
|
401
415
|
const domain = account.email?.split("@")[1]?.toLowerCase() || "";
|
|
402
416
|
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
403
|
-
|
|
417
|
+
const tp = createNativeTokenProvider(account.email);
|
|
418
|
+
syncManager.setTokenProvider(account.id, tp);
|
|
419
|
+
if (!gmailTokenProvider)
|
|
420
|
+
gmailTokenProvider = tp;
|
|
404
421
|
}
|
|
405
422
|
await syncManager.addAccount(account);
|
|
406
423
|
}
|
|
424
|
+
// Connect to GDrive using the Gmail token — read shared accounts.jsonc
|
|
425
|
+
if (gmailTokenProvider) {
|
|
426
|
+
setGDriveTokenProvider(gmailTokenProvider);
|
|
427
|
+
try {
|
|
428
|
+
const folderId = await findGDriveMailxFolder(gmailTokenProvider);
|
|
429
|
+
if (folderId) {
|
|
430
|
+
setGDriveFolderId(folderId);
|
|
431
|
+
console.log(`[android] GDrive mailx folder: ${folderId}`);
|
|
432
|
+
// Re-read accounts directly from GDrive (bypass cache to pick up bob.ma etc.)
|
|
433
|
+
const gdriveAccounts = await loadAccountsFromCloud();
|
|
434
|
+
if (gdriveAccounts.length > accounts.length) {
|
|
435
|
+
console.log(`[android] GDrive has ${gdriveAccounts.length} accounts (was ${accounts.length})`);
|
|
436
|
+
accounts = gdriveAccounts;
|
|
437
|
+
// Register any new accounts
|
|
438
|
+
for (const account of accounts) {
|
|
439
|
+
if (!account.enabled)
|
|
440
|
+
continue;
|
|
441
|
+
const domain = account.email?.split("@")[1]?.toLowerCase() || "";
|
|
442
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
443
|
+
syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
|
|
444
|
+
}
|
|
445
|
+
await syncManager.addAccount(account);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
catch (e) {
|
|
451
|
+
console.warn(`[android] GDrive access failed: ${e.message}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
407
454
|
installBridge();
|
|
408
455
|
setTimeout(() => {
|
|
409
456
|
syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
|
|
@@ -42,6 +42,8 @@ declare const DEFAULT_ALLOWLIST: {
|
|
|
42
42
|
};
|
|
43
43
|
/** Load accounts — first from IndexedDB cache, then GDrive */
|
|
44
44
|
export declare function loadAccounts(): Promise<AccountConfig[]>;
|
|
45
|
+
/** Load accounts directly from GDrive, bypassing local cache */
|
|
46
|
+
export declare function loadAccountsFromCloud(): Promise<AccountConfig[]>;
|
|
45
47
|
/** Save accounts to IndexedDB and GDrive */
|
|
46
48
|
export declare function saveAccounts(accounts: AccountConfig[]): Promise<void>;
|
|
47
49
|
/** Load preferences */
|
|
@@ -260,6 +260,20 @@ export async function loadAccounts() {
|
|
|
260
260
|
}
|
|
261
261
|
return [];
|
|
262
262
|
}
|
|
263
|
+
/** Load accounts directly from GDrive, bypassing local cache */
|
|
264
|
+
export async function loadAccountsFromCloud() {
|
|
265
|
+
const content = await gDriveRead("accounts.jsonc");
|
|
266
|
+
if (content) {
|
|
267
|
+
await idbWrite("accounts.jsonc", content);
|
|
268
|
+
try {
|
|
269
|
+
const data = JSON.parse(content);
|
|
270
|
+
const raw = data.accounts || (Array.isArray(data) ? data : []);
|
|
271
|
+
return raw.map((a) => normalizeAccount(a, data.name));
|
|
272
|
+
}
|
|
273
|
+
catch { /* parse error */ }
|
|
274
|
+
}
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
263
277
|
/** Save accounts to IndexedDB and GDrive */
|
|
264
278
|
export async function saveAccounts(accounts) {
|
|
265
279
|
const content = JSON.stringify({ accounts }, null, 2);
|
|
@@ -33,6 +33,7 @@ export interface AccountConfig {
|
|
|
33
33
|
syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
|
|
34
34
|
relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
|
|
35
35
|
deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
|
|
36
|
+
identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
|
|
36
37
|
}
|
|
37
38
|
/** Standard IMAP special-use folder types */
|
|
38
39
|
export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";
|