@bobfrankston/mailx 1.0.451 → 1.0.452
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.map +1 -0
- package/bin/mailx.ts +1498 -0
- package/bin/postinstall.js.map +1 -0
- package/bin/postinstall.ts +41 -0
- package/bin/tsconfig.json +10 -0
- package/client/.gitattributes +10 -0
- package/client/app.js +51 -2
- package/client/app.js.map +1 -0
- package/client/app.ts +3112 -0
- package/client/components/address-book.js.map +1 -0
- package/client/components/address-book.ts +204 -0
- package/client/components/alarms.js.map +1 -0
- package/client/components/alarms.ts +276 -0
- package/client/components/calendar-sidebar.js.map +1 -0
- package/client/components/calendar-sidebar.ts +474 -0
- package/client/components/calendar.js.map +1 -0
- package/client/components/calendar.ts +211 -0
- package/client/components/context-menu.js.map +1 -0
- package/client/components/context-menu.ts +95 -0
- package/client/components/folder-picker.js.map +1 -0
- package/client/components/folder-picker.ts +127 -0
- package/client/components/folder-tree.js.map +1 -0
- package/client/components/folder-tree.ts +1069 -0
- package/client/components/message-list.js.map +1 -0
- package/client/components/message-list.ts +1129 -0
- package/client/components/message-viewer.js.map +1 -0
- package/client/components/message-viewer.ts +1257 -0
- package/client/components/outbox-view.js.map +1 -0
- package/client/components/outbox-view.ts +102 -0
- package/client/components/tasks.js.map +1 -0
- package/client/components/tasks.ts +234 -0
- package/client/compose/compose.js.map +1 -0
- package/client/compose/compose.ts +1231 -0
- package/client/compose/editor.js.map +1 -0
- package/client/compose/editor.ts +599 -0
- package/client/compose/ghost-text.js.map +1 -0
- package/client/compose/ghost-text.ts +140 -0
- package/client/index.html +1 -0
- package/client/lib/android-bootstrap.js.map +1 -0
- package/client/lib/android-bootstrap.ts +9 -0
- package/client/lib/api-client.js.map +1 -0
- package/client/lib/api-client.ts +439 -0
- package/client/lib/local-service.js.map +1 -0
- package/client/lib/local-service.ts +646 -0
- package/client/lib/local-store.js.map +1 -0
- package/client/lib/local-store.ts +283 -0
- package/client/lib/message-state.js.map +1 -0
- package/client/lib/message-state.ts +140 -0
- package/client/tsconfig.json +19 -0
- package/package.json +15 -15
- package/packages/mailx-api/.gitattributes +10 -0
- package/packages/mailx-api/index.d.ts.map +1 -0
- package/packages/mailx-api/index.js.map +1 -0
- package/packages/mailx-api/index.ts +283 -0
- package/packages/mailx-api/tsconfig.json +9 -0
- package/packages/mailx-compose/.gitattributes +10 -0
- package/packages/mailx-compose/index.d.ts.map +1 -0
- package/packages/mailx-compose/index.js.map +1 -0
- package/packages/mailx-compose/index.ts +85 -0
- package/packages/mailx-compose/tsconfig.json +9 -0
- package/packages/mailx-core/index.d.ts.map +1 -0
- package/packages/mailx-core/index.js.map +1 -0
- package/packages/mailx-core/index.ts +424 -0
- package/packages/mailx-core/ipc.d.ts.map +1 -0
- package/packages/mailx-core/ipc.js.map +1 -0
- package/packages/mailx-core/ipc.ts +62 -0
- package/packages/mailx-core/tsconfig.json +9 -0
- package/packages/mailx-host/.gitattributes +10 -0
- package/packages/mailx-host/index.d.ts.map +1 -0
- package/packages/mailx-host/index.js.map +1 -0
- package/packages/mailx-host/index.ts +38 -0
- package/packages/mailx-host/package.json +10 -2
- package/packages/mailx-host/tsconfig.json +9 -0
- package/packages/mailx-send/.gitattributes +10 -0
- package/packages/mailx-send/cli-queue.d.ts.map +1 -0
- package/packages/mailx-send/cli-queue.js.map +1 -0
- package/packages/mailx-send/cli-queue.ts +62 -0
- package/packages/mailx-send/cli-send.d.ts.map +1 -0
- package/packages/mailx-send/cli-send.js.map +1 -0
- package/packages/mailx-send/cli-send.ts +83 -0
- package/packages/mailx-send/cli.d.ts.map +1 -0
- package/packages/mailx-send/cli.js.map +1 -0
- package/packages/mailx-send/cli.ts +126 -0
- package/packages/mailx-send/index.d.ts.map +1 -0
- package/packages/mailx-send/index.js.map +1 -0
- package/packages/mailx-send/index.ts +333 -0
- package/packages/mailx-send/mailsend/cli.d.ts.map +1 -0
- package/packages/mailx-send/mailsend/cli.js.map +1 -0
- package/packages/mailx-send/mailsend/cli.ts +81 -0
- package/packages/mailx-send/mailsend/index.d.ts.map +1 -0
- package/packages/mailx-send/mailsend/index.js.map +1 -0
- package/packages/mailx-send/mailsend/index.ts +333 -0
- package/packages/mailx-send/mailsend/package-lock.json +65 -0
- package/packages/mailx-send/mailsend/tsconfig.json +21 -0
- package/packages/mailx-send/package-lock.json +65 -0
- package/packages/mailx-send/package.json +1 -1
- package/packages/mailx-send/tsconfig.json +21 -0
- package/packages/mailx-server/.gitattributes +10 -0
- package/packages/mailx-server/index.d.ts.map +1 -0
- package/packages/mailx-server/index.js.map +1 -0
- package/packages/mailx-server/index.ts +429 -0
- package/packages/mailx-server/tsconfig.json +9 -0
- package/packages/mailx-service/google-sync.d.ts.map +1 -0
- package/packages/mailx-service/google-sync.js.map +1 -0
- package/packages/mailx-service/google-sync.ts +238 -0
- package/packages/mailx-service/index.d.ts.map +1 -0
- package/packages/mailx-service/index.js.map +1 -0
- package/packages/mailx-service/index.ts +2461 -0
- package/packages/mailx-service/jsonrpc.d.ts.map +1 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -0
- package/packages/mailx-service/jsonrpc.ts +268 -0
- package/packages/mailx-service/tsconfig.json +9 -0
- package/packages/mailx-settings/.gitattributes +10 -0
- package/packages/mailx-settings/cloud.d.ts.map +1 -0
- package/packages/mailx-settings/cloud.js.map +1 -0
- package/packages/mailx-settings/cloud.ts +388 -0
- package/packages/mailx-settings/index.d.ts.map +1 -0
- package/packages/mailx-settings/index.js.map +1 -0
- package/packages/mailx-settings/index.ts +892 -0
- package/packages/mailx-settings/tsconfig.json +9 -0
- package/packages/mailx-store/.gitattributes +10 -0
- package/packages/mailx-store/db.d.ts.map +1 -0
- package/packages/mailx-store/db.js.map +1 -0
- package/packages/mailx-store/db.ts +2007 -0
- package/packages/mailx-store/file-store.d.ts.map +1 -0
- package/packages/mailx-store/file-store.js.map +1 -0
- package/packages/mailx-store/file-store.ts +82 -0
- package/packages/mailx-store/index.d.ts.map +1 -0
- package/packages/mailx-store/index.js.map +1 -0
- package/packages/mailx-store/index.ts +7 -0
- package/packages/mailx-store/tsconfig.json +9 -0
- package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -0
- package/packages/mailx-store-web/android-bootstrap.js.map +1 -0
- package/packages/mailx-store-web/android-bootstrap.ts +1262 -0
- package/packages/mailx-store-web/db.d.ts.map +1 -0
- package/packages/mailx-store-web/db.js.map +1 -0
- package/packages/mailx-store-web/db.ts +756 -0
- package/packages/mailx-store-web/gmail-api-web.d.ts.map +1 -0
- package/packages/mailx-store-web/gmail-api-web.js.map +1 -0
- package/packages/mailx-store-web/gmail-api-web.ts +11 -0
- package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -0
- package/packages/mailx-store-web/imap-web-provider.js.map +1 -0
- package/packages/mailx-store-web/imap-web-provider.ts +156 -0
- package/packages/mailx-store-web/index.d.ts.map +1 -0
- package/packages/mailx-store-web/index.js.map +1 -0
- package/packages/mailx-store-web/index.ts +10 -0
- package/packages/mailx-store-web/main-thread-host.d.ts.map +1 -0
- package/packages/mailx-store-web/main-thread-host.js.map +1 -0
- package/packages/mailx-store-web/main-thread-host.ts +322 -0
- package/packages/mailx-store-web/package.json +4 -4
- package/packages/mailx-store-web/provider-types.d.ts.map +1 -0
- package/packages/mailx-store-web/provider-types.js.map +1 -0
- package/packages/mailx-store-web/provider-types.ts +7 -0
- package/packages/mailx-store-web/sync-manager.d.ts.map +1 -0
- package/packages/mailx-store-web/sync-manager.js.map +1 -0
- package/packages/mailx-store-web/sync-manager.ts +508 -0
- package/packages/mailx-store-web/tsconfig.json +10 -0
- package/packages/mailx-store-web/web-jsonrpc.d.ts.map +1 -0
- package/packages/mailx-store-web/web-jsonrpc.js.map +1 -0
- package/packages/mailx-store-web/web-jsonrpc.ts +116 -0
- package/packages/mailx-store-web/web-message-store.d.ts.map +1 -0
- package/packages/mailx-store-web/web-message-store.js.map +1 -0
- package/packages/mailx-store-web/web-message-store.ts +97 -0
- package/packages/mailx-store-web/web-service.d.ts.map +1 -0
- package/packages/mailx-store-web/web-service.js.map +1 -0
- package/packages/mailx-store-web/web-service.ts +616 -0
- package/packages/mailx-store-web/web-settings.d.ts.map +1 -0
- package/packages/mailx-store-web/web-settings.js.map +1 -0
- package/packages/mailx-store-web/web-settings.ts +522 -0
- package/packages/mailx-store-web/worker-entry.d.ts.map +1 -0
- package/packages/mailx-store-web/worker-entry.js.map +1 -0
- package/packages/mailx-store-web/worker-entry.ts +215 -0
- package/packages/mailx-store-web/worker-tcp-transport.d.ts.map +1 -0
- package/packages/mailx-store-web/worker-tcp-transport.js.map +1 -0
- package/packages/mailx-store-web/worker-tcp-transport.ts +101 -0
- package/packages/mailx-types/.gitattributes +10 -0
- package/packages/mailx-types/index.d.ts.map +1 -0
- package/packages/mailx-types/index.js.map +1 -0
- package/packages/mailx-types/index.ts +498 -0
- package/packages/mailx-types/tsconfig.json +9 -0
- package/tsconfig.base.json +2 -1
- package/tsconfig.json +9 -0
- package/build-apk.cmd +0 -3
- package/npmg.bat +0 -6
- package/packages/mailx-imap/index.d.ts +0 -442
- package/packages/mailx-imap/index.js +0 -3684
- package/packages/mailx-imap/package.json +0 -25
- package/packages/mailx-imap/providers/gmail-api.d.ts +0 -8
- package/packages/mailx-imap/providers/gmail-api.js +0 -8
- package/packages/mailx-imap/providers/types.d.ts +0 -9
- package/packages/mailx-imap/providers/types.js +0 -9
- package/packages/mailx-imap/tsconfig.tsbuildinfo +0 -1
- package/rebuild.cmd +0 -23
- package/tdview.cmd +0 -2
- package/temp.ps1 +0 -10
- package/test-smtp-direct.mjs +0 -4
- package/unbash.cmd +0 -55
- package/unwedge.cmd +0 -1
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Android bootstrap — wires WebMailxDB + WebMessageStore + GmailApiWebProvider + WebMailxService
|
|
3
|
+
* into the mailxapi bridge. This replaces Node.js backend for Android WebView.
|
|
4
|
+
*
|
|
5
|
+
* On Android, everything runs in the same JavaScript context:
|
|
6
|
+
* - wa-sqlite for metadata (via WebMailxDB)
|
|
7
|
+
* - IndexedDB for message bodies (via WebMessageStore)
|
|
8
|
+
* - Gmail/Outlook sync via REST APIs (plain fetch — no native bridge needed)
|
|
9
|
+
* - IMAP accounts use BridgeTransport (via MAUI TCP bridge) — not yet implemented
|
|
10
|
+
*
|
|
11
|
+
* The existing client UI (app.ts, components/) is completely unchanged —
|
|
12
|
+
* it calls window.mailxapi.* which this module provides.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { WebMailxDB } from "./db.js";
|
|
16
|
+
import { WebMessageStore } from "./web-message-store.js";
|
|
17
|
+
import { WebMailxService, type WebSyncManager } from "./web-service.js";
|
|
18
|
+
import {
|
|
19
|
+
loadAccounts, loadAccountsFromCloud, saveAccounts, loadSettings, clearSettings, getDeviceId,
|
|
20
|
+
setGDriveTokenProvider, setGDriveFolderId
|
|
21
|
+
} from "./web-settings.js";
|
|
22
|
+
import { GmailApiWebProvider } from "./gmail-api-web.js";
|
|
23
|
+
import { ImapWebProvider } from "./imap-web-provider.js";
|
|
24
|
+
import { SmtpClient, type SmtpAuth } from "@bobfrankston/smtp-direct";
|
|
25
|
+
import { BridgeTcpTransport } from "@bobfrankston/tcp-transport";
|
|
26
|
+
import type { MailProvider, ProviderMessage } from "./provider-types.js";
|
|
27
|
+
import type { Folder, EmailAddress, AccountConfig } from "@bobfrankston/mailx-types";
|
|
28
|
+
|
|
29
|
+
// ── State ──
|
|
30
|
+
|
|
31
|
+
let db: WebMailxDB;
|
|
32
|
+
let bodyStore: WebMessageStore;
|
|
33
|
+
let service: WebMailxService;
|
|
34
|
+
let syncManager: AndroidSyncManager;
|
|
35
|
+
const eventHandlers: ((event: any) => void)[] = [];
|
|
36
|
+
|
|
37
|
+
// ── Event emitter ──
|
|
38
|
+
|
|
39
|
+
function emitEvent(event: any): void {
|
|
40
|
+
for (const h of eventHandlers) {
|
|
41
|
+
try { h(event); } catch { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
if (typeof (window as any)._msgapiServiceEvent === "function") {
|
|
44
|
+
(window as any)._msgapiServiceEvent(event);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Helpers ──
|
|
49
|
+
|
|
50
|
+
function toEmailAddress(addr: { name?: string; address?: string } | undefined): EmailAddress {
|
|
51
|
+
return { name: addr?.name || "", address: addr?.address || "" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Verbose log — goes to logit but doesn't clutter the screen (silent=true) */
|
|
55
|
+
function vlog(msg: string): void {
|
|
56
|
+
try {
|
|
57
|
+
fetch(`https://rmf39.aaz.lt/logit/${encodeURIComponent("V/" + msg.substring(0, 800))}?log=mailx-android&silent=true`).catch(() => {});
|
|
58
|
+
} catch { /* ignore */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Sync Manager ──
|
|
62
|
+
|
|
63
|
+
class AndroidSyncManager implements WebSyncManager {
|
|
64
|
+
private providers = new Map<string, MailProvider>();
|
|
65
|
+
private tokenProviders = new Map<string, () => Promise<string>>();
|
|
66
|
+
// One prefetch session per account — prevents every syncAll tick from
|
|
67
|
+
// spawning parallel fetch loops that race on IndexedDB and blow through
|
|
68
|
+
// Gmail's per-user quota.
|
|
69
|
+
private prefetchingAccounts = new Set<string>();
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
private db: WebMailxDB,
|
|
73
|
+
private bodyStore: WebMessageStore,
|
|
74
|
+
) {}
|
|
75
|
+
|
|
76
|
+
on(_event: string, _handler: (...args: any[]) => void): void { /* stub */ }
|
|
77
|
+
emit(event: string, ...args: any[]): void { emitEvent({ type: event, ...args[0] }); }
|
|
78
|
+
|
|
79
|
+
async addAccount(account: AccountConfig): Promise<void> {
|
|
80
|
+
vlog(`addAccount id=${account.id} email=${account.email} host=${account.imap?.host} auth=${account.imap?.auth}`);
|
|
81
|
+
this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
82
|
+
if (this.isGmailAccount(account)) {
|
|
83
|
+
const tokenProvider = this.tokenProviders.get(account.id);
|
|
84
|
+
if (tokenProvider) {
|
|
85
|
+
this.providers.set(account.id, new GmailApiWebProvider(tokenProvider));
|
|
86
|
+
console.log(`[sync] ${account.id}: Gmail API provider registered`);
|
|
87
|
+
} else {
|
|
88
|
+
console.warn(`[sync] ${account.id}: no token provider`);
|
|
89
|
+
}
|
|
90
|
+
} else if (account.imap?.host && account.imap?.user) {
|
|
91
|
+
// Generic IMAP account — use BridgeTransport through MAUI's TCP bridge
|
|
92
|
+
try {
|
|
93
|
+
const provider = new ImapWebProvider({
|
|
94
|
+
server: account.imap.host,
|
|
95
|
+
port: account.imap.port || 993,
|
|
96
|
+
username: account.imap.user,
|
|
97
|
+
password: account.imap.password,
|
|
98
|
+
inactivityTimeout: 300000, // 300s for slow Dovecot
|
|
99
|
+
fetchChunkSize: 10,
|
|
100
|
+
fetchChunkSizeMax: 100,
|
|
101
|
+
}, () => new BridgeTcpTransport());
|
|
102
|
+
this.providers.set(account.id, provider);
|
|
103
|
+
vlog(`addAccount ${account.id}: IMAP provider registered (${account.imap.host}:${account.imap.port})`);
|
|
104
|
+
console.log(`[sync] ${account.id}: IMAP provider registered (${account.imap.host})`);
|
|
105
|
+
} catch (e: any) {
|
|
106
|
+
vlog(`addAccount ${account.id}: IMAP provider FAILED: ${e.message}`);
|
|
107
|
+
console.error(`[sync] ${account.id}: IMAP provider failed: ${e.message}`);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
vlog(`addAccount ${account.id}: no imap config, skipping`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setTokenProvider(accountId: string, provider: () => Promise<string>): void {
|
|
115
|
+
this.tokenProviders.set(accountId, provider);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private isGmailAccount(account: AccountConfig): boolean {
|
|
119
|
+
return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private getProvider(accountId: string): MailProvider | null {
|
|
123
|
+
return this.providers.get(accountId) || null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async syncAll(): Promise<void> {
|
|
127
|
+
const accounts = this.db.getAccounts();
|
|
128
|
+
vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map(a => a.id).join(",")}`);
|
|
129
|
+
|
|
130
|
+
// Phase 1: Sync INBOX for every account first — user sees new mail fast.
|
|
131
|
+
for (const account of accounts) {
|
|
132
|
+
if (!this.providers.has(account.id)) continue;
|
|
133
|
+
try {
|
|
134
|
+
const folders = await this.syncFolders(account.id);
|
|
135
|
+
const inbox = folders.find(f => f.specialUse === "inbox");
|
|
136
|
+
if (inbox) {
|
|
137
|
+
await this.syncFolder(account.id, inbox.id);
|
|
138
|
+
emitEvent({ type: "syncComplete", accountId: account.id });
|
|
139
|
+
}
|
|
140
|
+
} catch (e: any) {
|
|
141
|
+
console.error(`[sync] ${account.id} inbox: ${e.message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Phase 2: Remaining folders (sent, drafts, trash, then everything else).
|
|
146
|
+
for (const account of accounts) {
|
|
147
|
+
if (!this.providers.has(account.id)) continue;
|
|
148
|
+
try {
|
|
149
|
+
const folders = this.db.getFolders(account.id);
|
|
150
|
+
const remaining = folders.filter(f => f.specialUse !== "inbox");
|
|
151
|
+
for (const folder of remaining) {
|
|
152
|
+
try { await this.syncFolder(account.id, folder.id); }
|
|
153
|
+
catch (e: any) { console.error(`[sync] Skip ${folder.path}: ${e.message}`); }
|
|
154
|
+
}
|
|
155
|
+
this.db.updateLastSync(account.id, Date.now());
|
|
156
|
+
emitEvent({ type: "syncComplete", accountId: account.id });
|
|
157
|
+
} catch (e: any) {
|
|
158
|
+
console.error(`[sync] ${account.id}: ${e.message}`);
|
|
159
|
+
vlog(`syncAll: ${account.id} ERROR: ${e.message}`);
|
|
160
|
+
emitEvent({ type: "syncError", accountId: account.id, error: e.message });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Phase 3: background body prefetch. Fire-and-forget — sync itself is
|
|
165
|
+
// already done and the UI doesn't wait on this. Per-account guard means
|
|
166
|
+
// a slow account can't block a fast one.
|
|
167
|
+
for (const account of accounts) {
|
|
168
|
+
if (!this.providers.has(account.id)) continue;
|
|
169
|
+
this.prefetchBodies(account.id).catch(e =>
|
|
170
|
+
console.error(`[prefetch] ${account.id}: ${e.message}`));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Background body prefetch — download bodies for messages that don't have
|
|
175
|
+
* them yet, so tapping a message in the list opens instantly from cache. */
|
|
176
|
+
async prefetchBodies(accountId: string): Promise<void> {
|
|
177
|
+
if (this.prefetchingAccounts.has(accountId)) return;
|
|
178
|
+
this.prefetchingAccounts.add(accountId);
|
|
179
|
+
try {
|
|
180
|
+
const BATCH_SIZE = 20;
|
|
181
|
+
const THROTTLE_MS = 150;
|
|
182
|
+
const RATE_LIMIT_PAUSE_MS = 30000;
|
|
183
|
+
const ERROR_BUDGET = 10;
|
|
184
|
+
const CONCURRENCY = 2; // S62: 2 in-flight per account
|
|
185
|
+
let totalFetched = 0;
|
|
186
|
+
let errors = 0;
|
|
187
|
+
let announced = false;
|
|
188
|
+
// S62: INBOX always first. Within each folder the DB returns rows
|
|
189
|
+
// most-recent-first (PRIMARY KEY order), so newest unfetched INBOX
|
|
190
|
+
// mail wins the queue. A slow label (`[Gmail]/Jerrry`, etc.) can't
|
|
191
|
+
// starve INBOX any more.
|
|
192
|
+
const folderPriority = (folderId: number): number => {
|
|
193
|
+
const f = this.db.getFolders(accountId).find((x: any) => x.id === folderId);
|
|
194
|
+
return f?.specialUse === "inbox" ? 0 : 1;
|
|
195
|
+
};
|
|
196
|
+
let rateLimitCooldownUntil = 0;
|
|
197
|
+
|
|
198
|
+
while (true) {
|
|
199
|
+
const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
|
|
200
|
+
if (missing.length === 0) break;
|
|
201
|
+
if (!announced) {
|
|
202
|
+
console.log(`[prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
|
|
203
|
+
vlog(`prefetch ${accountId} start: ${missing.length}+ pending`);
|
|
204
|
+
announced = true;
|
|
205
|
+
}
|
|
206
|
+
// Sort this batch INBOX-first. getMessagesWithoutBody doesn't
|
|
207
|
+
// know the priority, and re-querying per folder would multiply
|
|
208
|
+
// the SELECTs. One in-memory sort is cheap.
|
|
209
|
+
missing.sort((a: any, b: any) => folderPriority(a.folderId) - folderPriority(b.folderId));
|
|
210
|
+
let progressedThisBatch = false;
|
|
211
|
+
let batchAborted = false;
|
|
212
|
+
|
|
213
|
+
// Bounded-concurrency worker pool. Each worker pulls the next
|
|
214
|
+
// unclaimed item from `missing`. Shared flags (errors,
|
|
215
|
+
// rateLimitCooldownUntil, progressedThisBatch) are updated
|
|
216
|
+
// inside the loop — sql.js is single-threaded so there's no
|
|
217
|
+
// actual race on reads/writes.
|
|
218
|
+
let cursor = 0;
|
|
219
|
+
const worker = async (): Promise<void> => {
|
|
220
|
+
while (cursor < missing.length) {
|
|
221
|
+
if (batchAborted) return;
|
|
222
|
+
if (errors >= ERROR_BUDGET) return;
|
|
223
|
+
const idx = cursor++;
|
|
224
|
+
const m = missing[idx];
|
|
225
|
+
// Honor rate-limit cooldown across workers.
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
if (rateLimitCooldownUntil > now) {
|
|
228
|
+
await new Promise(r => setTimeout(r, rateLimitCooldownUntil - now));
|
|
229
|
+
}
|
|
230
|
+
if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) {
|
|
231
|
+
this.db.updateBodyPath(accountId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`);
|
|
232
|
+
progressedThisBatch = true;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const result = await this.fetchMessageBody(accountId, m.folderId, m.uid);
|
|
237
|
+
if (result) {
|
|
238
|
+
totalFetched++;
|
|
239
|
+
progressedThisBatch = true;
|
|
240
|
+
emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId });
|
|
241
|
+
} else {
|
|
242
|
+
errors++;
|
|
243
|
+
}
|
|
244
|
+
} catch (e: any) {
|
|
245
|
+
errors++;
|
|
246
|
+
const msg = String(e?.message || "");
|
|
247
|
+
if (/429|rate|too many/i.test(msg)) {
|
|
248
|
+
console.log(`[prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
|
|
249
|
+
rateLimitCooldownUntil = Date.now() + RATE_LIMIT_PAUSE_MS;
|
|
250
|
+
} else {
|
|
251
|
+
console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Throttle kept per-request to spread load on flaky
|
|
255
|
+
// phone networks; concurrency-2 means effective request
|
|
256
|
+
// rate is ~1 per THROTTLE_MS/2.
|
|
257
|
+
await new Promise(r => setTimeout(r, THROTTLE_MS));
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, missing.length) }, () => worker()));
|
|
261
|
+
if (errors >= ERROR_BUDGET) {
|
|
262
|
+
console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
|
|
263
|
+
vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!progressedThisBatch) {
|
|
267
|
+
console.warn(`[prefetch] ${accountId}: batch made no progress, stopping`);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (totalFetched > 0) {
|
|
272
|
+
console.log(`[prefetch] ${accountId}: done — cached ${totalFetched} bodies`);
|
|
273
|
+
vlog(`prefetch ${accountId} done: ${totalFetched} cached`);
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
this.prefetchingAccounts.delete(accountId);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async syncFolders(accountId: string): Promise<Folder[]> {
|
|
281
|
+
const provider = this.getProvider(accountId);
|
|
282
|
+
if (!provider) {
|
|
283
|
+
const existing = this.db.getFolders(accountId);
|
|
284
|
+
vlog(`syncFolders: ${accountId} no provider, returning ${existing.length} cached folders`);
|
|
285
|
+
return existing;
|
|
286
|
+
}
|
|
287
|
+
emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 0 });
|
|
288
|
+
const providerFolders = await provider.listFolders();
|
|
289
|
+
for (const folder of providerFolders) {
|
|
290
|
+
const flags = folder.flags || [];
|
|
291
|
+
if (flags.some(f => f.toLowerCase() === "\\noselect")) continue;
|
|
292
|
+
this.db.upsertFolder(accountId, folder.path, folder.name, folder.specialUse, folder.delimiter);
|
|
293
|
+
}
|
|
294
|
+
emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 100 });
|
|
295
|
+
const dbFolders = this.db.getFolders(accountId);
|
|
296
|
+
emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
297
|
+
return dbFolders;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async syncFolder(accountId: string, folderId: number): Promise<void> {
|
|
301
|
+
const provider = this.getProvider(accountId);
|
|
302
|
+
if (!provider) return;
|
|
303
|
+
const folders = this.db.getFolders(accountId);
|
|
304
|
+
const folder = folders.find(f => f.id === folderId);
|
|
305
|
+
if (!folder) return;
|
|
306
|
+
|
|
307
|
+
emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 0 });
|
|
308
|
+
const highestUid = this.db.getHighestUid(accountId, folderId);
|
|
309
|
+
const startDate = new Date(Date.now() - 30 * 86400000);
|
|
310
|
+
|
|
311
|
+
let messages: ProviderMessage[];
|
|
312
|
+
if (highestUid > 0) {
|
|
313
|
+
messages = await provider.fetchSince(folder.path, highestUid, { source: false });
|
|
314
|
+
messages = messages.filter(m => m.uid > highestUid);
|
|
315
|
+
} else {
|
|
316
|
+
const tomorrow = new Date(Date.now() + 86400000);
|
|
317
|
+
messages = await provider.fetchByDate(folder.path, startDate, tomorrow, { source: false });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (messages.length > 0) {
|
|
321
|
+
console.log(`[sync] ${folder.path}: ${messages.length} messages`);
|
|
322
|
+
this.storeProviderMessages(accountId, folderId, messages);
|
|
323
|
+
this.db.recalcFolderCounts(folderId);
|
|
324
|
+
emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Reconcile deletions — messages present locally but no longer on the
|
|
328
|
+
// server (moved away, deleted on another client). Without this, the
|
|
329
|
+
// Android client never drops removed rows: e.g., moves to _spam from
|
|
330
|
+
// another client showed up in _spam (next time it synced) but never
|
|
331
|
+
// disappeared from INBOX.
|
|
332
|
+
//
|
|
333
|
+
// Same safety guards as the desktop reconcile path:
|
|
334
|
+
// - Skip if the server list is empty but local has messages (likely
|
|
335
|
+
// a transient API failure that returned []).
|
|
336
|
+
// - Refuse to delete more than 50% of local in one pass — better to
|
|
337
|
+
// keep phantoms than to wipe a folder on a sync bug. Rebuild local
|
|
338
|
+
// cache fixes a stuck state.
|
|
339
|
+
try {
|
|
340
|
+
const serverUidsArr = await provider.getUids(folder.path);
|
|
341
|
+
const serverUids = new Set(serverUidsArr);
|
|
342
|
+
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
343
|
+
if (serverUidsArr.length === 0 && localUids.length > 0) {
|
|
344
|
+
console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`);
|
|
345
|
+
} else {
|
|
346
|
+
const toDelete = localUids.filter(uid => !serverUids.has(uid));
|
|
347
|
+
const RECONCILE_DELETE_THRESHOLD = 0.5;
|
|
348
|
+
if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
|
|
349
|
+
console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`);
|
|
350
|
+
} else {
|
|
351
|
+
for (const uid of toDelete) {
|
|
352
|
+
this.db.deleteMessage(accountId, uid);
|
|
353
|
+
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => {});
|
|
354
|
+
}
|
|
355
|
+
if (toDelete.length > 0) {
|
|
356
|
+
console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`);
|
|
357
|
+
this.db.recalcFolderCounts(folderId);
|
|
358
|
+
emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch (e: any) {
|
|
363
|
+
console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] });
|
|
367
|
+
emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private storeProviderMessages(accountId: string, folderId: number, messages: ProviderMessage[]): void {
|
|
371
|
+
this.db.beginTransaction();
|
|
372
|
+
try {
|
|
373
|
+
for (const msg of messages) {
|
|
374
|
+
const flags: string[] = [];
|
|
375
|
+
if (msg.seen) flags.push("\\Seen");
|
|
376
|
+
if (msg.flagged) flags.push("\\Flagged");
|
|
377
|
+
if (msg.answered) flags.push("\\Answered");
|
|
378
|
+
if (msg.draft) flags.push("\\Draft");
|
|
379
|
+
// Store the Gmail providerId in bodyPath as "gmail:<id>" so we can
|
|
380
|
+
// fetch the body directly without re-listing 1000 messages from the folder
|
|
381
|
+
const bodyPath = msg.providerId ? `gmail:${msg.providerId}` : "";
|
|
382
|
+
this.db.upsertMessage({
|
|
383
|
+
accountId, folderId, uid: msg.uid,
|
|
384
|
+
messageId: msg.messageId || "", inReplyTo: "", references: [],
|
|
385
|
+
date: msg.date ? msg.date.getTime() : Date.now(),
|
|
386
|
+
subject: msg.subject || "",
|
|
387
|
+
from: toEmailAddress(msg.from?.[0]),
|
|
388
|
+
to: msg.to.map(a => toEmailAddress(a)),
|
|
389
|
+
cc: msg.cc.map(a => toEmailAddress(a)),
|
|
390
|
+
flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
this.db.commitTransaction();
|
|
394
|
+
} catch (e: any) {
|
|
395
|
+
this.db.rollbackTransaction();
|
|
396
|
+
console.error(`[sync] storeMessages error: ${e.message}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Uint8Array | null> {
|
|
401
|
+
const t0 = Date.now();
|
|
402
|
+
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
403
|
+
const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
|
|
404
|
+
console.log(`[fetchBody] cache hit ${accountId}/${folderId}/${uid} (${Date.now() - t0}ms)`);
|
|
405
|
+
return cached;
|
|
406
|
+
}
|
|
407
|
+
console.log(`[fetchBody] cache miss ${accountId}/${folderId}/${uid} — fetching`);
|
|
408
|
+
const provider = this.getProvider(accountId);
|
|
409
|
+
if (!provider) {
|
|
410
|
+
console.warn(`[fetchBody] No provider for ${accountId}`);
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
// Look up the Gmail providerId stored in body_path during sync
|
|
414
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
415
|
+
const bp = (envelope as any)?.bodyPath || "";
|
|
416
|
+
// 60 s wall-clock cap — infinite hang was the user-reported symptom
|
|
417
|
+
// ("fetch message body on android is infinite"). A dead BridgeTransport
|
|
418
|
+
// socket won't recover by waiting. Legit fetches finish in seconds.
|
|
419
|
+
const FETCH_TIMEOUT_MS = 60_000;
|
|
420
|
+
const fetchPromise = (async (): Promise<any> => {
|
|
421
|
+
if (bp.startsWith("gmail:") && (provider as any).fetchById) {
|
|
422
|
+
const providerId = bp.substring(6);
|
|
423
|
+
return (provider as any).fetchById(providerId, { source: true });
|
|
424
|
+
}
|
|
425
|
+
const folders = this.db.getFolders(accountId);
|
|
426
|
+
const folder = folders.find(f => f.id === folderId);
|
|
427
|
+
if (!folder) return null;
|
|
428
|
+
return provider.fetchOne(folder.path, uid, { source: true });
|
|
429
|
+
})();
|
|
430
|
+
let msg: any = null;
|
|
431
|
+
try {
|
|
432
|
+
msg = await Promise.race([
|
|
433
|
+
fetchPromise,
|
|
434
|
+
new Promise((_, reject) => setTimeout(
|
|
435
|
+
() => reject(new Error(`body-fetch timeout ${FETCH_TIMEOUT_MS / 1000}s (${accountId}/${folderId}/${uid})`)),
|
|
436
|
+
FETCH_TIMEOUT_MS
|
|
437
|
+
)),
|
|
438
|
+
]);
|
|
439
|
+
} catch (e: any) {
|
|
440
|
+
console.error(`[fetchBody] failed ${accountId}/${folderId}/${uid} after ${Date.now() - t0}ms: ${e?.message || e}`);
|
|
441
|
+
throw e;
|
|
442
|
+
}
|
|
443
|
+
if (!msg?.source) {
|
|
444
|
+
console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, ${Date.now() - t0}ms)`);
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
// Encode the UTF-8 string back to bytes for storage
|
|
448
|
+
const raw = new TextEncoder().encode(msg.source);
|
|
449
|
+
await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
450
|
+
this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
|
|
451
|
+
console.log(`[fetchBody] fetched + cached ${accountId}/${folderId}/${uid} (${raw.byteLength} bytes, ${Date.now() - t0}ms)`);
|
|
452
|
+
return raw;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void> {
|
|
456
|
+
this.db.updateMessageFlags(accountId, uid, flags);
|
|
457
|
+
this.db.recalcFolderCounts(folderId);
|
|
458
|
+
this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
|
|
459
|
+
emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async trashMessage(accountId: string, folderId: number, uid: number): Promise<void> {
|
|
463
|
+
this.db.deleteMessage(accountId, uid);
|
|
464
|
+
this.db.queueSyncAction(accountId, "trash", uid, folderId);
|
|
465
|
+
emitEvent({ type: "messageDeleted", accountId, folderId, uid });
|
|
466
|
+
emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async trashMessages(accountId: string, messages: { uid: number; folderId: number }[]): Promise<void> {
|
|
470
|
+
for (const m of messages) await this.trashMessage(accountId, m.folderId, m.uid);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async moveMessage(accountId: string, uid: number, folderId: number, targetFolderId: number): Promise<void> {
|
|
474
|
+
this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId });
|
|
475
|
+
emitEvent({ type: "messageMoved", accountId, fromFolderId: folderId, toFolderId: targetFolderId, uid });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async moveMessages(accountId: string, messages: { uid: number; folderId: number }[], targetFolderId: number): Promise<void> {
|
|
479
|
+
for (const m of messages) await this.moveMessage(accountId, m.uid, m.folderId, targetFolderId);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async moveMessageCrossAccount(): Promise<void> {
|
|
483
|
+
throw new Error("Cross-account move not supported on mobile");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void> {
|
|
487
|
+
this.db.queueSyncAction(accountId, "undelete", uid, folderId);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Q112: drain queued move/flag/trash actions to the provider. Android is
|
|
491
|
+
* standalone — it pushes state changes directly to Gmail (or other
|
|
492
|
+
* provider) the same way desktop does. Called from the periodic 2-min
|
|
493
|
+
* tick above. `send` actions drain separately via `processSendQueue`. */
|
|
494
|
+
async processSyncActions(accountId: string): Promise<void> {
|
|
495
|
+
const provider: any = this.providers.get(accountId);
|
|
496
|
+
if (!provider) return;
|
|
497
|
+
const pending = this.db.getPendingSyncActions(accountId)
|
|
498
|
+
.filter((a: any) => a.action !== "send");
|
|
499
|
+
if (pending.length === 0) return;
|
|
500
|
+
const folders = this.db.getFolders(accountId);
|
|
501
|
+
const folderPath = (id: number): string | null => {
|
|
502
|
+
const f = folders.find((x: any) => x.id === id);
|
|
503
|
+
return f?.path || null;
|
|
504
|
+
};
|
|
505
|
+
for (const p of pending) {
|
|
506
|
+
const path = folderPath(p.folderId);
|
|
507
|
+
if (!path) { this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`); continue; }
|
|
508
|
+
try {
|
|
509
|
+
if (p.action === "flags" && typeof provider.setFlags === "function") {
|
|
510
|
+
await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
|
|
511
|
+
} else if (p.action === "trash" && typeof provider.trashMessage === "function") {
|
|
512
|
+
await provider.trashMessage(path, p.uid);
|
|
513
|
+
} else if (p.action === "move" && typeof provider.moveMessage === "function") {
|
|
514
|
+
const toId = p.targetFolderId as number;
|
|
515
|
+
const toPath = folderPath(toId);
|
|
516
|
+
if (!toPath) { this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`); continue; }
|
|
517
|
+
await provider.moveMessage(path, p.uid, toPath);
|
|
518
|
+
} else {
|
|
519
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
this.db.completeSyncActionByUid(accountId, p.action, p.uid);
|
|
523
|
+
} catch (e: any) {
|
|
524
|
+
const msg = e?.message || String(e);
|
|
525
|
+
console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
|
|
526
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** In-flight send tracker keyed by queueUid. Prevents
|
|
532
|
+
* processSendQueue from re-firing the same row when it overlaps
|
|
533
|
+
* with an in-progress attempt (e.g., the periodic tick fires while
|
|
534
|
+
* the original attemptSend's promise is still pending). Without
|
|
535
|
+
* this, a slow Gmail/SMTP send race-conditions into a double-send. */
|
|
536
|
+
private sendInFlight = new Set<number>();
|
|
537
|
+
|
|
538
|
+
async queueOutgoingLocal(accountId: string, rawMessage: string): Promise<void> {
|
|
539
|
+
// Local-first: PERSIST to sync_actions before attempting the network
|
|
540
|
+
// send, so a crash / offline / process kill between now and SMTP ACK
|
|
541
|
+
// doesn't drop the message. Desktop parity — PC writes `.ltr` to disk
|
|
542
|
+
// synchronously; Android writes a sync_actions row and now FLUSHES
|
|
543
|
+
// sql.js → IndexedDB before returning. The previous version relied on
|
|
544
|
+
// the 1-second scheduleSave debounce, so a tab-close inside the debounce
|
|
545
|
+
// window erased the row before it was persisted — the "letter just
|
|
546
|
+
// disappeared" symptom user-reported 2026-04-30.
|
|
547
|
+
//
|
|
548
|
+
// Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr` durable write.
|
|
549
|
+
const queueUid = -Date.now();
|
|
550
|
+
this.db.queueSyncAction(accountId, "send", queueUid, -1, { rawMessage });
|
|
551
|
+
await this.db.flush();
|
|
552
|
+
this.attemptSend(accountId, queueUid, rawMessage);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Kick off a send for a message that's already in the queue. Called by
|
|
556
|
+
* queueOutgoingLocal on a fresh submit AND by processSendQueue on
|
|
557
|
+
* startup / periodic tick for anything stranded from a prior run.
|
|
558
|
+
* Guards against double-send via sendInFlight. */
|
|
559
|
+
private attemptSend(accountId: string, queueUid: number, rawMessage: string): void {
|
|
560
|
+
if (this.sendInFlight.has(queueUid)) return;
|
|
561
|
+
this.sendInFlight.add(queueUid);
|
|
562
|
+
// Helper to mark complete + flush + clear in-flight — used on every
|
|
563
|
+
// success/failure exit. Flush ensures the row deletion or attempt
|
|
564
|
+
// counter actually reaches IndexedDB before the next process-kill,
|
|
565
|
+
// matching the "persist before network" rule for the post-network
|
|
566
|
+
// outcome too. Without flushing on completion, a successful send
|
|
567
|
+
// followed by a fast app-close left the row in the queue, which
|
|
568
|
+
// looked like a "stuck" message on next launch.
|
|
569
|
+
const finishSend = (success: boolean, error?: string) => {
|
|
570
|
+
if (success) {
|
|
571
|
+
this.db.completeSyncActionByUid(accountId, "send", queueUid);
|
|
572
|
+
} else {
|
|
573
|
+
this.db.failSyncActionByUid(accountId, "send", queueUid, error || "send failed");
|
|
574
|
+
}
|
|
575
|
+
this.db.flush().catch(() => { /* save will retry on next mutation */ });
|
|
576
|
+
this.sendInFlight.delete(queueUid);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const provider = this.getProvider(accountId);
|
|
580
|
+
if (provider && typeof (provider as any).sendRaw === "function") {
|
|
581
|
+
(provider as any).sendRaw(rawMessage)
|
|
582
|
+
.then((result: { id: string; threadId: string }) => {
|
|
583
|
+
console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
|
|
584
|
+
finishSend(true);
|
|
585
|
+
emitEvent({ type: "sendComplete", accountId, messageId: result.id });
|
|
586
|
+
})
|
|
587
|
+
.catch((e: any) => {
|
|
588
|
+
console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
|
|
589
|
+
finishSend(false, e.message || String(e));
|
|
590
|
+
emitEvent({ type: "sendError", accountId, error: e.message });
|
|
591
|
+
});
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Non-Gmail: use smtp-direct + BridgeTransport. Pull SMTP config from the
|
|
596
|
+
// stored account JSON.
|
|
597
|
+
const accounts = db.getAccountConfigs();
|
|
598
|
+
const row = accounts.find(a => a.id === accountId);
|
|
599
|
+
if (!row) {
|
|
600
|
+
const e = "Unknown account";
|
|
601
|
+
console.error(`[send] ${accountId}: ${e}`);
|
|
602
|
+
finishSend(false, e);
|
|
603
|
+
emitEvent({ type: "sendError", accountId, error: e });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
let account: AccountConfig;
|
|
607
|
+
try { account = JSON.parse(row.configJson); }
|
|
608
|
+
catch {
|
|
609
|
+
const e = "Account config malformed";
|
|
610
|
+
finishSend(false, e);
|
|
611
|
+
emitEvent({ type: "sendError", accountId, error: e });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (!account.smtp) {
|
|
615
|
+
const e = "No SMTP config for this account";
|
|
616
|
+
console.error(`[send] ${accountId}: ${e}`);
|
|
617
|
+
finishSend(false, e);
|
|
618
|
+
emitEvent({ type: "sendError", accountId, error: e });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
this.sendViaSmtpDirect(accountId, account, rawMessage)
|
|
623
|
+
.then((result) => {
|
|
624
|
+
console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
|
|
625
|
+
finishSend(true);
|
|
626
|
+
emitEvent({ type: "sendComplete", accountId });
|
|
627
|
+
})
|
|
628
|
+
.catch((e: any) => {
|
|
629
|
+
console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
|
|
630
|
+
finishSend(false, e.message || String(e));
|
|
631
|
+
emitEvent({ type: "sendError", accountId, error: e.message });
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Drain any stranded 'send' queue entries — called at startup and on
|
|
636
|
+
* each periodic sync tick so messages queued while offline or stranded
|
|
637
|
+
* by a crash get a retry. Each row keeps its queueUid as tracking key. */
|
|
638
|
+
async processSendQueue(accountId: string): Promise<void> {
|
|
639
|
+
const pending = this.db.getPendingSyncActions(accountId).filter(a => a.action === "send" && a.rawMessage);
|
|
640
|
+
if (pending.length === 0) return;
|
|
641
|
+
console.log(`[send] ${accountId}: draining ${pending.length} queued message(s)`);
|
|
642
|
+
for (const p of pending) {
|
|
643
|
+
this.attemptSend(accountId, p.uid, p.rawMessage);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Build SMTP config from account, send via smtp-direct over BridgeTransport. */
|
|
648
|
+
private async sendViaSmtpDirect(
|
|
649
|
+
accountId: string, account: AccountConfig, raw: string,
|
|
650
|
+
): Promise<{ accepted: string[]; rejected: { address: string; code: number; message: string }[] }> {
|
|
651
|
+
const SMTP_PORT_STARTTLS = 587;
|
|
652
|
+
const SMTP_PORT_IMPLICIT_TLS = 465;
|
|
653
|
+
const smtp = account.smtp!;
|
|
654
|
+
const smtpPort = smtp.port || SMTP_PORT_STARTTLS;
|
|
655
|
+
const smtpHost = smtp.host || account.imap?.host;
|
|
656
|
+
if (!smtpHost) throw new Error("No SMTP host");
|
|
657
|
+
|
|
658
|
+
// Auth: password → PLAIN; oauth2 → XOAUTH2 (token from this account's provider)
|
|
659
|
+
const smtpUser = smtp.user || account.imap?.user || account.email;
|
|
660
|
+
const authType = smtp.auth || (account.imap?.password ? "password" : undefined);
|
|
661
|
+
let auth: SmtpAuth | undefined;
|
|
662
|
+
if (authType === "password") {
|
|
663
|
+
const pass = smtp.password || account.imap?.password;
|
|
664
|
+
if (!pass) throw new Error("SMTP password not configured");
|
|
665
|
+
auth = { method: "PLAIN", user: smtpUser, pass };
|
|
666
|
+
} else if (authType === "oauth2") {
|
|
667
|
+
const tp = this.tokenProviders.get(accountId);
|
|
668
|
+
if (!tp) throw new Error("OAuth token provider not registered");
|
|
669
|
+
const token = await tp();
|
|
670
|
+
auth = { method: "XOAUTH2", user: smtpUser, token };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Recipients from headers
|
|
674
|
+
const parseAddrs = (s: string) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
|
|
675
|
+
const toMatch = raw.match(/^To:\s*(.+)$/mi);
|
|
676
|
+
const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
|
|
677
|
+
const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
|
|
678
|
+
const fromMatch = raw.match(/^From:\s*(.+)$/mi);
|
|
679
|
+
const recipients = [
|
|
680
|
+
...(toMatch ? parseAddrs(toMatch[1]) : []),
|
|
681
|
+
...(ccMatch ? parseAddrs(ccMatch[1]) : []),
|
|
682
|
+
...(bccMatch ? parseAddrs(bccMatch[1]) : []),
|
|
683
|
+
];
|
|
684
|
+
const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
|
|
685
|
+
if (recipients.length === 0) throw new Error("No recipients");
|
|
686
|
+
|
|
687
|
+
const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
|
|
688
|
+
|
|
689
|
+
const client = new SmtpClient({
|
|
690
|
+
host: smtpHost,
|
|
691
|
+
port: smtpPort,
|
|
692
|
+
secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
|
|
693
|
+
auth,
|
|
694
|
+
localname: "mailx-android",
|
|
695
|
+
}, () => new BridgeTcpTransport());
|
|
696
|
+
try {
|
|
697
|
+
await client.connect();
|
|
698
|
+
return await client.sendMail({ from: sender, to: recipients }, rawToSend);
|
|
699
|
+
} finally {
|
|
700
|
+
try { await client.quit(); } catch { /* ignore */ }
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async saveDraft(_accountId: string, _raw: string, _prevUid?: number, _draftId?: string): Promise<number | null> {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async deleteDraft(_accountId: string, _draftUid: number): Promise<void> { }
|
|
709
|
+
|
|
710
|
+
async reauthenticate(_accountId: string): Promise<boolean> { return false; }
|
|
711
|
+
|
|
712
|
+
async searchOnServer(): Promise<number[]> { return []; }
|
|
713
|
+
|
|
714
|
+
async syncAllContacts(): Promise<void> { }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ── OAuth credentials (same "installed" client as desktop) ──
|
|
718
|
+
|
|
719
|
+
// Same credentials as desktop mailx (iflow-credentials.json from @bobfrankston/iflow-direct)
|
|
720
|
+
const OAUTH_CLIENT = {
|
|
721
|
+
clientId: "884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u.apps.googleusercontent.com",
|
|
722
|
+
clientSecret: "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",
|
|
723
|
+
authUri: "https://accounts.google.com/o/oauth2/auth",
|
|
724
|
+
tokenUri: "https://oauth2.googleapis.com/token",
|
|
725
|
+
// Reverse client ID scheme — auto-allowed for Google "installed" apps
|
|
726
|
+
redirectUri: "com.googleusercontent.apps.884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u:/oauth2callback",
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// Use full drive scope so we can read/write the desktop's accounts.jsonc + clients.jsonc.
|
|
730
|
+
// drive.file is per-consent-grant: files created by desktop's grant aren't visible to Android's grant
|
|
731
|
+
// even with the same client_id. drive (full) lets us see all files the user has access to.
|
|
732
|
+
const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar";
|
|
733
|
+
|
|
734
|
+
// ── Token cache (IndexedDB) ──
|
|
735
|
+
|
|
736
|
+
async function getCachedToken(email: string): Promise<{ access_token: string; refresh_token?: string; expires_at?: number } | null> {
|
|
737
|
+
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
738
|
+
const raw = localStorage.getItem(key);
|
|
739
|
+
if (!raw) return null;
|
|
740
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function setCachedToken(email: string, token: { access_token: string; refresh_token?: string; expires_at?: number }): Promise<void> {
|
|
744
|
+
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
745
|
+
localStorage.setItem(key, JSON.stringify(token));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function clearCachedToken(email: string): Promise<void> {
|
|
749
|
+
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
750
|
+
localStorage.removeItem(key);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── Token exchange ──
|
|
754
|
+
|
|
755
|
+
async function exchangeCodeForTokens(code: string): Promise<{ access_token: string; refresh_token?: string; expires_in: number }> {
|
|
756
|
+
const body = new URLSearchParams({
|
|
757
|
+
code,
|
|
758
|
+
client_id: OAUTH_CLIENT.clientId,
|
|
759
|
+
client_secret: OAUTH_CLIENT.clientSecret,
|
|
760
|
+
redirect_uri: OAUTH_CLIENT.redirectUri,
|
|
761
|
+
grant_type: "authorization_code",
|
|
762
|
+
});
|
|
763
|
+
const res = await fetch(OAUTH_CLIENT.tokenUri, {
|
|
764
|
+
method: "POST",
|
|
765
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
766
|
+
body: body.toString(),
|
|
767
|
+
});
|
|
768
|
+
if (!res.ok) {
|
|
769
|
+
const text = await res.text();
|
|
770
|
+
throw new Error(`Token exchange failed: ${res.status} ${text}`);
|
|
771
|
+
}
|
|
772
|
+
return res.json();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function refreshAccessToken(refreshToken: string): Promise<{ access_token: string; expires_in: number }> {
|
|
776
|
+
const body = new URLSearchParams({
|
|
777
|
+
refresh_token: refreshToken,
|
|
778
|
+
client_id: OAUTH_CLIENT.clientId,
|
|
779
|
+
client_secret: OAUTH_CLIENT.clientSecret,
|
|
780
|
+
grant_type: "refresh_token",
|
|
781
|
+
});
|
|
782
|
+
const res = await fetch(OAUTH_CLIENT.tokenUri, {
|
|
783
|
+
method: "POST",
|
|
784
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
785
|
+
body: body.toString(),
|
|
786
|
+
});
|
|
787
|
+
if (!res.ok) {
|
|
788
|
+
const text = await res.text();
|
|
789
|
+
throw new Error(`Token refresh failed: ${res.status} ${text}`);
|
|
790
|
+
}
|
|
791
|
+
return res.json();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ── Token provider (browser OAuth, same as desktop) ──
|
|
795
|
+
|
|
796
|
+
function createNativeTokenProvider(email: string): () => Promise<string> {
|
|
797
|
+
return async () => {
|
|
798
|
+
// Check cached token first
|
|
799
|
+
const cached = await getCachedToken(email);
|
|
800
|
+
if (cached?.access_token) {
|
|
801
|
+
const expiresAt = cached.expires_at || 0;
|
|
802
|
+
const bufferMs = 5 * 60 * 1000; // 5 min buffer
|
|
803
|
+
if (Date.now() < expiresAt - bufferMs) {
|
|
804
|
+
return cached.access_token;
|
|
805
|
+
}
|
|
806
|
+
// Try refresh
|
|
807
|
+
if (cached.refresh_token) {
|
|
808
|
+
try {
|
|
809
|
+
console.log(`[oauth] Refreshing token for ${email}`);
|
|
810
|
+
const refreshed = await refreshAccessToken(cached.refresh_token);
|
|
811
|
+
const token = {
|
|
812
|
+
access_token: refreshed.access_token,
|
|
813
|
+
refresh_token: cached.refresh_token,
|
|
814
|
+
expires_at: Date.now() + refreshed.expires_in * 1000,
|
|
815
|
+
};
|
|
816
|
+
await setCachedToken(email, token);
|
|
817
|
+
return token.access_token;
|
|
818
|
+
} catch (e: any) {
|
|
819
|
+
console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// No valid token — start browser OAuth flow
|
|
825
|
+
const bridge = (window as any)._nativeBridge;
|
|
826
|
+
if (!bridge?.app?.startOAuth) {
|
|
827
|
+
throw new Error("No native OAuth bridge");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
|
|
831
|
+
client_id: OAUTH_CLIENT.clientId,
|
|
832
|
+
redirect_uri: OAUTH_CLIENT.redirectUri,
|
|
833
|
+
response_type: "code",
|
|
834
|
+
scope: OAUTH_SCOPES,
|
|
835
|
+
access_type: "offline",
|
|
836
|
+
prompt: "consent",
|
|
837
|
+
login_hint: email,
|
|
838
|
+
}).toString();
|
|
839
|
+
|
|
840
|
+
console.log(`[oauth] Starting browser consent for ${email}`);
|
|
841
|
+
const code = await bridge.app.startOAuth(authUrl);
|
|
842
|
+
const tokens = await exchangeCodeForTokens(code);
|
|
843
|
+
const token = {
|
|
844
|
+
access_token: tokens.access_token,
|
|
845
|
+
refresh_token: tokens.refresh_token,
|
|
846
|
+
expires_at: Date.now() + tokens.expires_in * 1000,
|
|
847
|
+
};
|
|
848
|
+
await setCachedToken(email, token);
|
|
849
|
+
console.log(`[oauth] Token obtained for ${email}`);
|
|
850
|
+
return token.access_token;
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ── GDrive folder lookup ──
|
|
855
|
+
|
|
856
|
+
async function registerDeviceInGDrive(
|
|
857
|
+
tokenProvider: () => Promise<string>,
|
|
858
|
+
folderId: string,
|
|
859
|
+
accountIds: string[]
|
|
860
|
+
): Promise<void> {
|
|
861
|
+
try {
|
|
862
|
+
const token = await tokenProvider();
|
|
863
|
+
// Use persistent Android device ID (survives factory reset & app data clear)
|
|
864
|
+
const bridge = (window as any)._nativeBridge;
|
|
865
|
+
let deviceId = "android-unknown";
|
|
866
|
+
if (bridge?.app?.getAndroidId) {
|
|
867
|
+
try {
|
|
868
|
+
const androidId = await bridge.app.getAndroidId();
|
|
869
|
+
deviceId = `android-${androidId.substring(0, 12)}`;
|
|
870
|
+
} catch {
|
|
871
|
+
deviceId = `android-${getDeviceId().substring(0, 8)}`;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// Read existing clients.jsonc
|
|
875
|
+
const q = encodeURIComponent(`name='clients.jsonc' and '${folderId}' in parents and trashed=false`);
|
|
876
|
+
const listRes = await fetch(
|
|
877
|
+
`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id)`,
|
|
878
|
+
{ headers: { "Authorization": `Bearer ${token}` } }
|
|
879
|
+
);
|
|
880
|
+
if (!listRes.ok) {
|
|
881
|
+
console.warn(`[gdrive] clients.jsonc list failed: ${listRes.status}`);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const listData = await listRes.json() as any;
|
|
885
|
+
const fileId = listData.files?.[0]?.id;
|
|
886
|
+
let clients: any = {};
|
|
887
|
+
if (fileId) {
|
|
888
|
+
const readRes = await fetch(
|
|
889
|
+
`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`,
|
|
890
|
+
{ headers: { "Authorization": `Bearer ${token}` } }
|
|
891
|
+
);
|
|
892
|
+
if (readRes.ok) {
|
|
893
|
+
try { clients = JSON.parse(await readRes.text()); } catch { /* */ }
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Remove stale android-* entries (from old random-UUID approach) — keep only this device
|
|
897
|
+
for (const key of Object.keys(clients)) {
|
|
898
|
+
if (key.startsWith("android-") && key !== deviceId) {
|
|
899
|
+
delete clients[key];
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
clients[deviceId] = {
|
|
903
|
+
hostname: deviceId,
|
|
904
|
+
platform: "android",
|
|
905
|
+
accounts: accountIds,
|
|
906
|
+
lastSeen: new Date().toISOString(),
|
|
907
|
+
version: (window as any)._nativeBridge?.info?.version || "?",
|
|
908
|
+
};
|
|
909
|
+
const content = JSON.stringify(clients, null, 2);
|
|
910
|
+
if (fileId) {
|
|
911
|
+
const upRes = await fetch(
|
|
912
|
+
`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media`,
|
|
913
|
+
{
|
|
914
|
+
method: "PATCH",
|
|
915
|
+
headers: {
|
|
916
|
+
"Authorization": `Bearer ${token}`,
|
|
917
|
+
"Content-Type": "application/json",
|
|
918
|
+
},
|
|
919
|
+
body: content,
|
|
920
|
+
}
|
|
921
|
+
);
|
|
922
|
+
if (upRes.ok) console.log(`[android] Registered device in clients.jsonc as ${deviceId}`);
|
|
923
|
+
else console.warn(`[gdrive] clients.jsonc update failed: ${upRes.status}`);
|
|
924
|
+
}
|
|
925
|
+
} catch (e: any) {
|
|
926
|
+
console.warn(`[android] Device registration failed: ${e.message}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function findGDriveMailxFolder(tokenProvider: () => Promise<string>): Promise<string | null> {
|
|
931
|
+
const token = await tokenProvider();
|
|
932
|
+
const q = encodeURIComponent("name='mailx' and mimeType='application/vnd.google-apps.folder' and trashed=false");
|
|
933
|
+
const res = await fetch(
|
|
934
|
+
`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,name)&spaces=drive`,
|
|
935
|
+
{ headers: { "Authorization": `Bearer ${token}` } }
|
|
936
|
+
);
|
|
937
|
+
if (!res.ok) {
|
|
938
|
+
console.warn(`[gdrive] Folder search failed: ${res.status}`);
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
const data = await res.json() as any;
|
|
942
|
+
const folder = data.files?.[0];
|
|
943
|
+
return folder?.id || null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ── Initialization ──
|
|
947
|
+
|
|
948
|
+
async function waitForNativeBridge(timeoutMs: number = 5000): Promise<void> {
|
|
949
|
+
if ((window as any)._nativeBridge) return;
|
|
950
|
+
return new Promise((resolve) => {
|
|
951
|
+
const start = Date.now();
|
|
952
|
+
const check = () => {
|
|
953
|
+
if ((window as any)._nativeBridge || Date.now() - start > timeoutMs) {
|
|
954
|
+
resolve();
|
|
955
|
+
} else {
|
|
956
|
+
setTimeout(check, 50);
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
// Also listen for the event C# dispatches after bridge injection
|
|
960
|
+
window.addEventListener("nativebridgeready", () => resolve(), { once: true });
|
|
961
|
+
check();
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export async function initAndroid(): Promise<void> {
|
|
966
|
+
console.log("[android] Initializing mailx (main-thread mode)...");
|
|
967
|
+
|
|
968
|
+
// Main-thread path: async I/O (fetch, TCP bridge) doesn't block the UI,
|
|
969
|
+
// and only sql.js is CPU-bound enough to maybe warrant a Worker later.
|
|
970
|
+
// Worker path was reverted 2026-04-14 (stuck at "Initializing..." on Android).
|
|
971
|
+
await waitForNativeBridge();
|
|
972
|
+
if ((window as any)._nativeBridge && !(window as any).msgapi) {
|
|
973
|
+
(window as any).msgapi = (window as any)._nativeBridge;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
db = new WebMailxDB("mailx");
|
|
977
|
+
await db.waitReady();
|
|
978
|
+
bodyStore = new WebMessageStore();
|
|
979
|
+
syncManager = new AndroidSyncManager(db, bodyStore);
|
|
980
|
+
service = new WebMailxService(db, bodyStore, syncManager);
|
|
981
|
+
|
|
982
|
+
let accounts = await loadAccounts();
|
|
983
|
+
console.log(`[android] ${accounts.length} account(s) found`);
|
|
984
|
+
|
|
985
|
+
// Find a Gmail account to use as the GDrive token provider
|
|
986
|
+
let gmailTokenProvider: (() => Promise<string>) | null = null;
|
|
987
|
+
for (const account of accounts) {
|
|
988
|
+
if (!account.enabled) continue;
|
|
989
|
+
const domain = account.email?.split("@")[1]?.toLowerCase() || "";
|
|
990
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
991
|
+
const tp = createNativeTokenProvider(account.email);
|
|
992
|
+
syncManager.setTokenProvider(account.id, tp);
|
|
993
|
+
if (!gmailTokenProvider) gmailTokenProvider = tp;
|
|
994
|
+
}
|
|
995
|
+
await syncManager.addAccount(account);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Install the mailxapi bridge + drain pending queues IMMEDIATELY using
|
|
999
|
+
// the local-cache account list. UI shouldn't wait on GDrive (which can
|
|
1000
|
+
// be slow on cold network) before becoming actionable. GDrive
|
|
1001
|
+
// reconciliation (below) runs in the background and re-registers fresh
|
|
1002
|
+
// accounts when it returns.
|
|
1003
|
+
installBridge();
|
|
1004
|
+
for (const account of accounts) {
|
|
1005
|
+
if (!account.enabled) continue;
|
|
1006
|
+
syncManager.processSendQueue(account.id)
|
|
1007
|
+
.catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`));
|
|
1008
|
+
syncManager.processSyncActions(account.id)
|
|
1009
|
+
.catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
|
|
1010
|
+
}
|
|
1011
|
+
// First sync from local accounts on a tiny delay so the UI gets to paint.
|
|
1012
|
+
setTimeout(() => {
|
|
1013
|
+
syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
|
|
1014
|
+
}, 1000);
|
|
1015
|
+
|
|
1016
|
+
// GDrive reconciliation runs in the background — accounts.jsonc on the
|
|
1017
|
+
// shared cloud may have been edited from another device, so we re-pull
|
|
1018
|
+
// and re-register if it differs from the cached copy. The user can
|
|
1019
|
+
// already see and use mail by the time this resolves.
|
|
1020
|
+
if (gmailTokenProvider) {
|
|
1021
|
+
const tp = gmailTokenProvider;
|
|
1022
|
+
(async () => {
|
|
1023
|
+
setGDriveTokenProvider(tp);
|
|
1024
|
+
try {
|
|
1025
|
+
console.log("[android] Looking up GDrive mailx folder…");
|
|
1026
|
+
const folderId = await findGDriveMailxFolder(tp);
|
|
1027
|
+
if (!folderId) {
|
|
1028
|
+
console.warn("[android] GDrive mailx folder not found");
|
|
1029
|
+
} else {
|
|
1030
|
+
setGDriveFolderId(folderId);
|
|
1031
|
+
console.log(`[android] GDrive mailx folder: ${folderId}`);
|
|
1032
|
+
// DEBUG: list all files in the folder
|
|
1033
|
+
try {
|
|
1034
|
+
const tk = await tp();
|
|
1035
|
+
const lr = await fetch(
|
|
1036
|
+
`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`,
|
|
1037
|
+
{ headers: { "Authorization": `Bearer ${tk}` } }
|
|
1038
|
+
);
|
|
1039
|
+
if (lr.ok) {
|
|
1040
|
+
const ld = await lr.json() as any;
|
|
1041
|
+
const names = (ld.files || []).map((f: any) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
|
|
1042
|
+
console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
|
|
1043
|
+
} else {
|
|
1044
|
+
console.warn(`[android] List folder failed: ${lr.status}`);
|
|
1045
|
+
}
|
|
1046
|
+
} catch (e: any) {
|
|
1047
|
+
console.warn(`[android] List debug: ${e.message}`);
|
|
1048
|
+
}
|
|
1049
|
+
// Read accounts directly from GDrive (bypass IndexedDB cache)
|
|
1050
|
+
console.log("[android] Reading accounts.jsonc from GDrive...");
|
|
1051
|
+
const gdriveAccounts = await loadAccountsFromCloud();
|
|
1052
|
+
console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
|
|
1053
|
+
if (gdriveAccounts.length > 0) {
|
|
1054
|
+
// Use canonical GDrive accounts (upsert handles overwrites)
|
|
1055
|
+
accounts = gdriveAccounts;
|
|
1056
|
+
for (const account of accounts) {
|
|
1057
|
+
vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
|
|
1058
|
+
if (!account.enabled) {
|
|
1059
|
+
vlog(`init: ${account.id} disabled, skipping`);
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
const domain = account.email?.split("@")[1]?.toLowerCase() || "";
|
|
1063
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
1064
|
+
syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
|
|
1065
|
+
}
|
|
1066
|
+
await syncManager.addAccount(account);
|
|
1067
|
+
}
|
|
1068
|
+
console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
|
|
1069
|
+
}
|
|
1070
|
+
// Register this Android device in clients.jsonc
|
|
1071
|
+
await registerDeviceInGDrive(tp, folderId, accounts.map(a => a.id));
|
|
1072
|
+
}
|
|
1073
|
+
} catch (e: any) {
|
|
1074
|
+
console.warn(`[android] GDrive access failed: ${e.message}`);
|
|
1075
|
+
}
|
|
1076
|
+
})();
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Periodic re-sync every 2 minutes (no IDLE on Android, so poll)
|
|
1080
|
+
const SYNC_INTERVAL_MS = 2 * 60 * 1000;
|
|
1081
|
+
setInterval(() => {
|
|
1082
|
+
console.log("[sync] periodic poll");
|
|
1083
|
+
vlog("periodic sync poll");
|
|
1084
|
+
// Retry any failed/stranded sends every poll tick
|
|
1085
|
+
for (const account of db.getAccounts()) {
|
|
1086
|
+
syncManager.processSendQueue(account.id)
|
|
1087
|
+
.catch(e => console.error(`[android] retry ${account.id}: ${e.message}`));
|
|
1088
|
+
syncManager.processSyncActions(account.id)
|
|
1089
|
+
.catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
|
|
1090
|
+
}
|
|
1091
|
+
syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
|
|
1092
|
+
}, SYNC_INTERVAL_MS);
|
|
1093
|
+
|
|
1094
|
+
// Immediate sync + send-queue drain when app comes back to foreground
|
|
1095
|
+
// (e.g. user switches from another app). Without the send-queue drain,
|
|
1096
|
+
// a message queued while offline waits up to 2 minutes after resume
|
|
1097
|
+
// before retrying — long enough for the user to think it's stuck.
|
|
1098
|
+
document.addEventListener("visibilitychange", () => {
|
|
1099
|
+
if (document.visibilityState === "visible") {
|
|
1100
|
+
console.log("[sync] resume poll");
|
|
1101
|
+
for (const account of db.getAccounts()) {
|
|
1102
|
+
syncManager.processSendQueue(account.id)
|
|
1103
|
+
.catch(e => console.error(`[android] resume send-drain ${account.id}: ${e.message}`));
|
|
1104
|
+
}
|
|
1105
|
+
syncManager.syncAll().catch(e => console.error(`[android] Resume sync error: ${e.message}`));
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
console.log("[android] Initialization complete");
|
|
1110
|
+
emitEvent({ type: "connected" });
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
export async function resetStore(): Promise<void> {
|
|
1114
|
+
await service.resetStore();
|
|
1115
|
+
await clearSettings();
|
|
1116
|
+
console.log("[android] Store reset");
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ── mailxapi Bridge ──
|
|
1120
|
+
|
|
1121
|
+
function installBridge(): void {
|
|
1122
|
+
const api = {
|
|
1123
|
+
isApp: true,
|
|
1124
|
+
platform: "android",
|
|
1125
|
+
getAccounts: () => service.getAccounts(),
|
|
1126
|
+
getFolders: (accountId: string) => service.getFolders(accountId),
|
|
1127
|
+
getMessages: (accountId: string, folderId: number, page: number, pageSize: number) =>
|
|
1128
|
+
service.getMessages(accountId, folderId, page, pageSize),
|
|
1129
|
+
getUnifiedInbox: (page: number, pageSize: number) => service.getUnifiedInbox(page, pageSize),
|
|
1130
|
+
getMessage: (accountId: string, uid: number, allowRemote: boolean, folderId?: number) =>
|
|
1131
|
+
service.getMessage(accountId, uid, allowRemote, folderId),
|
|
1132
|
+
updateFlags: async (accountId: string, uid: number, flags: string[]) => {
|
|
1133
|
+
await service.updateFlags(accountId, uid, flags); return { ok: true };
|
|
1134
|
+
},
|
|
1135
|
+
deleteMessage: async (accountId: string, uid: number) => {
|
|
1136
|
+
await service.deleteMessage(accountId, uid); return { ok: true };
|
|
1137
|
+
},
|
|
1138
|
+
deleteMessages: async (accountId: string, uids: number[]) => {
|
|
1139
|
+
await service.deleteMessages(accountId, uids); return { ok: true, count: uids.length };
|
|
1140
|
+
},
|
|
1141
|
+
undeleteMessage: async (accountId: string, uid: number, folderId: number) => {
|
|
1142
|
+
await service.undeleteMessage(accountId, uid, folderId); return { ok: true };
|
|
1143
|
+
},
|
|
1144
|
+
moveMessage: async (accountId: string, uid: number, targetFolderId: number, targetAccountId?: string) => {
|
|
1145
|
+
await service.moveMessage(accountId, uid, targetFolderId, targetAccountId); return { ok: true };
|
|
1146
|
+
},
|
|
1147
|
+
moveMessages: async (accountId: string, uids: number[], targetFolderId: number) => {
|
|
1148
|
+
await service.moveMessages(accountId, uids, targetFolderId); return { ok: true, count: uids.length };
|
|
1149
|
+
},
|
|
1150
|
+
sendMessage: async (msg: any) => { await service.send(msg); return { ok: true }; },
|
|
1151
|
+
saveDraft: (p: any) => service.saveDraft(p.accountId, p.subject, p.bodyHtml, p.bodyText, p.to, p.cc, p.previousDraftUid, p.draftId),
|
|
1152
|
+
deleteDraft: async (accountId: string, draftUid: number) => {
|
|
1153
|
+
await service.deleteDraft(accountId, draftUid); return { ok: true };
|
|
1154
|
+
},
|
|
1155
|
+
searchMessages: (query: string, page: number, pageSize: number) => service.search(query, page, pageSize),
|
|
1156
|
+
searchContacts: (query: string) => service.searchContacts(query),
|
|
1157
|
+
listContacts: (query: string, page = 1, pageSize = 100) => service.listContacts(query || "", page, pageSize),
|
|
1158
|
+
upsertContact: (name: string, email: string) => service.upsertContact(name || "", email),
|
|
1159
|
+
deleteContact: (email: string) => service.deleteContact(email),
|
|
1160
|
+
addContact: (name: string, email: string) => service.addContact(name || "", email),
|
|
1161
|
+
hasCcHistoryTo: (email: string) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
|
|
1162
|
+
syncAll: async () => { await service.syncAll(); return { ok: true }; },
|
|
1163
|
+
syncAccount: async (accountId: string) => { await service.syncAccount(accountId); return { ok: true }; },
|
|
1164
|
+
getSyncPending: () => service.getSyncPending(),
|
|
1165
|
+
getPrimaryAccount: (feature?: string) => {
|
|
1166
|
+
// Resolve primary account for a feature (calendar/tasks/contacts):
|
|
1167
|
+
// per-feature flag → catch-all `primary` → first account.
|
|
1168
|
+
const all = db.getAccountConfigs().map(r => {
|
|
1169
|
+
try { return { id: r.id, name: r.name, email: r.email, ...JSON.parse(r.configJson) }; }
|
|
1170
|
+
catch { return { id: r.id, name: r.name, email: r.email }; }
|
|
1171
|
+
});
|
|
1172
|
+
if (feature) {
|
|
1173
|
+
const key = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
|
|
1174
|
+
const perFeature = all.find((a: any) => a[key]);
|
|
1175
|
+
if (perFeature) return perFeature;
|
|
1176
|
+
}
|
|
1177
|
+
return all.find((a: any) => a.primary) || all[0] || null;
|
|
1178
|
+
},
|
|
1179
|
+
reauthenticate: async (accountId: string) => ({ ok: await service.reauthenticate(accountId) }),
|
|
1180
|
+
markFolderRead: (_accountId: string, folderId: number) => { service.markFolderRead(folderId); return { ok: true }; },
|
|
1181
|
+
createFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
|
|
1182
|
+
renameFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
|
|
1183
|
+
deleteFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
|
|
1184
|
+
emptyFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
|
|
1185
|
+
allowRemoteContent: async (type: string, value: string) => {
|
|
1186
|
+
await service.allowRemoteContent(type as any, value); return { ok: true };
|
|
1187
|
+
},
|
|
1188
|
+
getSettings: () => service.getSettings(),
|
|
1189
|
+
saveSettingsData: async (data: any) => { await service.saveSettingsData(data); return { ok: true }; },
|
|
1190
|
+
getVersion: async () => {
|
|
1191
|
+
const settings = await service.getSettings();
|
|
1192
|
+
const nativeVersion = (window as any)._nativeBridge?.info?.version || "?";
|
|
1193
|
+
return { version: nativeVersion, theme: settings.ui?.theme || "system", storage: service.getStorageInfo(), platform: "android" };
|
|
1194
|
+
},
|
|
1195
|
+
getAutocompleteSettings: () => service.getAutocompleteSettings(),
|
|
1196
|
+
saveAutocompleteSettings: async (settings: any) => { await service.saveAutocompleteSettings(settings); return { ok: true }; },
|
|
1197
|
+
getDeviceAccounts: async () => {
|
|
1198
|
+
const bridge = (window as any)._nativeBridge;
|
|
1199
|
+
if (bridge?.app?.getDeviceAccounts) {
|
|
1200
|
+
return bridge.app.getDeviceAccounts();
|
|
1201
|
+
}
|
|
1202
|
+
return [];
|
|
1203
|
+
},
|
|
1204
|
+
setupAccount: async (name: string, email: string, _password: string) => {
|
|
1205
|
+
try {
|
|
1206
|
+
if (!email || !email.includes("@")) {
|
|
1207
|
+
return { ok: false, error: "Email address required" };
|
|
1208
|
+
}
|
|
1209
|
+
const domain = email.split("@")[1].toLowerCase();
|
|
1210
|
+
const id = domain.split(".")[0] || "account";
|
|
1211
|
+
const account: AccountConfig = {
|
|
1212
|
+
id,
|
|
1213
|
+
name: name || email.split("@")[0],
|
|
1214
|
+
email,
|
|
1215
|
+
enabled: true,
|
|
1216
|
+
imap: { host: `imap.${domain}`, port: 993, tls: true, auth: "oauth2" as const, user: email },
|
|
1217
|
+
smtp: { host: `smtp.${domain}`, port: 587, tls: true, auth: "oauth2" as const, user: email },
|
|
1218
|
+
};
|
|
1219
|
+
// Apply known provider defaults
|
|
1220
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
1221
|
+
account.label = "Gmail";
|
|
1222
|
+
account.imap = { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2", user: email };
|
|
1223
|
+
account.smtp = { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2", user: email };
|
|
1224
|
+
}
|
|
1225
|
+
const existing = await loadAccounts();
|
|
1226
|
+
if (existing.some(a => a.email === email)) {
|
|
1227
|
+
return { ok: true, message: "Account already exists" };
|
|
1228
|
+
}
|
|
1229
|
+
existing.push(account);
|
|
1230
|
+
await saveAccounts(existing);
|
|
1231
|
+
// Set up token provider before adding account
|
|
1232
|
+
const setupDomain = email.split("@")[1].toLowerCase();
|
|
1233
|
+
if (setupDomain === "gmail.com" || setupDomain === "googlemail.com") {
|
|
1234
|
+
syncManager.setTokenProvider(account.id, createNativeTokenProvider(email));
|
|
1235
|
+
}
|
|
1236
|
+
await syncManager.addAccount(account);
|
|
1237
|
+
db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
1238
|
+
console.log(`[android] Account added: ${email}`);
|
|
1239
|
+
return { ok: true, message: `Added ${email}. Syncing...` };
|
|
1240
|
+
} catch (e: any) {
|
|
1241
|
+
return { ok: false, error: e.message };
|
|
1242
|
+
}
|
|
1243
|
+
},
|
|
1244
|
+
repairAccounts: async () => ({ ok: false, error: "Use desktop for repair" }),
|
|
1245
|
+
resetStore: () => resetStore(),
|
|
1246
|
+
resetAll: async () => {
|
|
1247
|
+
const bridge = (window as any)._nativeBridge;
|
|
1248
|
+
if (bridge?.app?.resetAll) {
|
|
1249
|
+
await bridge.app.resetAll();
|
|
1250
|
+
} else {
|
|
1251
|
+
await resetStore();
|
|
1252
|
+
location.reload();
|
|
1253
|
+
}
|
|
1254
|
+
},
|
|
1255
|
+
restart: () => { location.reload(); },
|
|
1256
|
+
onEvent: (handler: (event: any) => void) => { eventHandlers.push(handler); },
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
(window as any).mailxapi = api;
|
|
1260
|
+
window.dispatchEvent(new CustomEvent("mailxapiready"));
|
|
1261
|
+
console.log("[android] mailxapi bridge installed");
|
|
1262
|
+
}
|