@bobfrankston/mailx-store-web 0.1.5

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.
Files changed (75) hide show
  1. package/android-bootstrap.d.ts +16 -0
  2. package/android-bootstrap.d.ts.map +1 -0
  3. package/android-bootstrap.js +1438 -0
  4. package/android-bootstrap.js.map +1 -0
  5. package/android-bootstrap.ts +1450 -0
  6. package/db.d.ts +146 -0
  7. package/db.d.ts.map +1 -0
  8. package/db.js +725 -0
  9. package/db.js.map +1 -0
  10. package/db.ts +831 -0
  11. package/gmail-api-web.d.ts +11 -0
  12. package/gmail-api-web.d.ts.map +1 -0
  13. package/gmail-api-web.js +11 -0
  14. package/gmail-api-web.js.map +1 -0
  15. package/gmail-api-web.ts +11 -0
  16. package/imap-web-provider.d.ts +33 -0
  17. package/imap-web-provider.d.ts.map +1 -0
  18. package/imap-web-provider.js +140 -0
  19. package/imap-web-provider.js.map +1 -0
  20. package/imap-web-provider.ts +156 -0
  21. package/index.d.ts +10 -0
  22. package/index.d.ts.map +1 -0
  23. package/index.js +10 -0
  24. package/index.js.map +1 -0
  25. package/index.ts +10 -0
  26. package/main-thread-host.d.ts +15 -0
  27. package/main-thread-host.d.ts.map +1 -0
  28. package/main-thread-host.js +292 -0
  29. package/main-thread-host.js.map +1 -0
  30. package/main-thread-host.ts +322 -0
  31. package/package.json +41 -0
  32. package/provider-types.d.ts +7 -0
  33. package/provider-types.d.ts.map +1 -0
  34. package/provider-types.js +7 -0
  35. package/provider-types.js.map +1 -0
  36. package/provider-types.ts +7 -0
  37. package/sql-wasm-esm.js +10 -0
  38. package/sql.js.d.ts +29 -0
  39. package/sync-manager.d.ts +68 -0
  40. package/sync-manager.d.ts.map +1 -0
  41. package/sync-manager.js +506 -0
  42. package/sync-manager.js.map +1 -0
  43. package/sync-manager.ts +508 -0
  44. package/tsconfig.json +10 -0
  45. package/web-jsonrpc.d.ts +20 -0
  46. package/web-jsonrpc.d.ts.map +1 -0
  47. package/web-jsonrpc.js +112 -0
  48. package/web-jsonrpc.js.map +1 -0
  49. package/web-jsonrpc.ts +126 -0
  50. package/web-message-store.d.ts +16 -0
  51. package/web-message-store.d.ts.map +1 -0
  52. package/web-message-store.js +89 -0
  53. package/web-message-store.js.map +1 -0
  54. package/web-message-store.ts +97 -0
  55. package/web-service.d.ts +136 -0
  56. package/web-service.d.ts.map +1 -0
  57. package/web-service.js +687 -0
  58. package/web-service.js.map +1 -0
  59. package/web-service.ts +754 -0
  60. package/web-settings.d.ts +91 -0
  61. package/web-settings.d.ts.map +1 -0
  62. package/web-settings.js +518 -0
  63. package/web-settings.js.map +1 -0
  64. package/web-settings.ts +547 -0
  65. package/worker-bundle.js +6838 -0
  66. package/worker-entry.d.ts +8 -0
  67. package/worker-entry.d.ts.map +1 -0
  68. package/worker-entry.js +218 -0
  69. package/worker-entry.js.map +1 -0
  70. package/worker-entry.ts +245 -0
  71. package/worker-tcp-transport.d.ts +28 -0
  72. package/worker-tcp-transport.d.ts.map +1 -0
  73. package/worker-tcp-transport.js +98 -0
  74. package/worker-tcp-transport.js.map +1 -0
  75. package/worker-tcp-transport.ts +101 -0
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Back-compat re-export. Canonical types live in @bobfrankston/mailx-sync.
3
+ * Earlier this file was a hand-maintained copy of mailx-imap's types — that
4
+ * arrangement drifted. Now both sides import from one place.
5
+ */
6
+ export type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "@bobfrankston/mailx-sync";
7
+ //# sourceMappingURL=provider-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider-types.d.ts","sourceRoot":"","sources":["provider-types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Back-compat re-export. Canonical types live in @bobfrankston/mailx-sync.
3
+ * Earlier this file was a hand-maintained copy of mailx-imap's types — that
4
+ * arrangement drifted. Now both sides import from one place.
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=provider-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider-types.js","sourceRoot":"","sources":["provider-types.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Back-compat re-export. Canonical types live in @bobfrankston/mailx-sync.
3
+ * Earlier this file was a hand-maintained copy of mailx-imap's types — that
4
+ * arrangement drifted. Now both sides import from one place.
5
+ */
6
+
7
+ export type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "@bobfrankston/mailx-sync";
@@ -0,0 +1,10 @@
1
+ // ESM wrapper for sql.js (UMD) — loads as classic script, re-exports the global
2
+ await new Promise((resolve, reject) => {
3
+ const s = document.createElement("script");
4
+ s.src = new URL("../../node_modules/sql.js/dist/sql-wasm.js", import.meta.url).href;
5
+ s.onload = resolve;
6
+ s.onerror = reject;
7
+ document.head.appendChild(s);
8
+ });
9
+ const initSqlJs = globalThis.initSqlJs;
10
+ export default initSqlJs;
package/sql.js.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Minimal type declarations for sql.js (WebAssembly SQLite).
3
+ * Only the API surface used by WebMailxDB is typed.
4
+ */
5
+
6
+ declare module "sql.js" {
7
+ interface SqlJsStatic {
8
+ Database: new (data?: ArrayLike<number>) => Database;
9
+ }
10
+
11
+ interface Database {
12
+ run(sql: string, params?: any[]): Database;
13
+ exec(sql: string, params?: any[]): QueryExecResult[];
14
+ export(): Uint8Array;
15
+ close(): void;
16
+ }
17
+
18
+ interface QueryExecResult {
19
+ columns: string[];
20
+ values: any[][];
21
+ }
22
+
23
+ function initSqlJs(config?: {
24
+ locateFile?: (file: string) => string;
25
+ }): Promise<SqlJsStatic>;
26
+
27
+ export default initSqlJs;
28
+ export type { Database, SqlJsStatic, QueryExecResult };
29
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Sync manager — extracted from android-bootstrap.ts for use in both
3
+ * main-thread and Worker contexts.
4
+ *
5
+ * Platform-specific dependencies are injected:
6
+ * - emitEvent: posts events to the UI
7
+ * - vlog: verbose remote logging
8
+ * - createTcpTransport: factory for TCP transport (BridgeTcpTransport or WorkerTcpTransport)
9
+ */
10
+ import type { WebMailxDB } from "./db.js";
11
+ import type { WebMessageStore } from "./web-message-store.js";
12
+ import type { WebSyncManager } from "./web-service.js";
13
+ import type { Folder, AccountConfig } from "@bobfrankston/mailx-types";
14
+ export interface SyncManagerDeps {
15
+ emitEvent: (event: any) => void;
16
+ vlog: (msg: string) => void;
17
+ createTcpTransport: () => any;
18
+ }
19
+ export declare class SyncManager implements WebSyncManager {
20
+ private db;
21
+ private bodyStore;
22
+ private providers;
23
+ private tokenProviders;
24
+ private deps;
25
+ constructor(db: WebMailxDB, bodyStore: WebMessageStore, deps: SyncManagerDeps);
26
+ on(_event: string, _handler: (...args: any[]) => void): void;
27
+ emit(event: string, ...args: any[]): void;
28
+ addAccount(account: AccountConfig): Promise<void>;
29
+ setTokenProvider(accountId: string, provider: () => Promise<string>): void;
30
+ private isGmailAccount;
31
+ private getProvider;
32
+ syncAll(): Promise<void>;
33
+ syncAccount(accountId: string): Promise<void>;
34
+ syncFolders(accountId: string): Promise<Folder[]>;
35
+ syncFolder(accountId: string, folderId: number): Promise<void>;
36
+ private storeProviderMessages;
37
+ fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Uint8Array | null>;
38
+ updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void>;
39
+ trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
40
+ trashMessages(accountId: string, messages: {
41
+ uid: number;
42
+ folderId: number;
43
+ }[]): Promise<void>;
44
+ moveMessage(accountId: string, uid: number, folderId: number, targetFolderId: number): Promise<void>;
45
+ moveMessages(accountId: string, messages: {
46
+ uid: number;
47
+ folderId: number;
48
+ }[], targetFolderId: number): Promise<void>;
49
+ moveMessageCrossAccount(): Promise<void>;
50
+ undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
51
+ /** Q112: drain queued move/flag/trash actions to the provider. Android is
52
+ * standalone — it pushes state changes to Gmail (or other provider) the
53
+ * same way desktop does, so local actions propagate without needing a
54
+ * desktop to relay them. Called from android-bootstrap on startup and
55
+ * every 2-min sync tick. `send` actions are drained separately by
56
+ * processSendQueue. */
57
+ processSyncActions(accountId: string): Promise<void>;
58
+ markFolderRead(folderId: number): Promise<void>;
59
+ emptyFolder(accountId: string, folderId: number): Promise<void>;
60
+ queueOutgoingLocal(accountId: string, rawMessage: string): void;
61
+ private sendViaSmtpDirect;
62
+ saveDraft(_accountId: string, _raw: string, _prevUid?: number, _draftId?: string): Promise<number | null>;
63
+ deleteDraft(_accountId: string, _uid: number): Promise<void>;
64
+ reauthenticate(_accountId: string): Promise<boolean>;
65
+ searchOnServer(_accountId: string, _query: string): Promise<any[]>;
66
+ syncAllContacts(): Promise<void>;
67
+ }
68
+ //# sourceMappingURL=sync-manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-manager.d.ts","sourceRoot":"","sources":["sync-manager.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Sync manager — extracted from android-bootstrap.ts for use in both
3
+ * main-thread and Worker contexts.
4
+ *
5
+ * Platform-specific dependencies are injected:
6
+ * - emitEvent: posts events to the UI
7
+ * - vlog: verbose remote logging
8
+ * - createTcpTransport: factory for TCP transport (BridgeTcpTransport or WorkerTcpTransport)
9
+ */
10
+ import { GmailApiWebProvider } from "./gmail-api-web.js";
11
+ import { ImapWebProvider } from "./imap-web-provider.js";
12
+ import { SmtpClient } from "@bobfrankston/smtp-direct";
13
+ function toEmailAddress(addr) {
14
+ return { name: addr?.name || "", address: addr?.address || "" };
15
+ }
16
+ export class SyncManager {
17
+ db;
18
+ bodyStore;
19
+ providers = new Map();
20
+ tokenProviders = new Map();
21
+ deps;
22
+ constructor(db, bodyStore, deps) {
23
+ this.db = db;
24
+ this.bodyStore = bodyStore;
25
+ this.deps = deps;
26
+ }
27
+ on(_event, _handler) { }
28
+ emit(event, ...args) { this.deps.emitEvent({ type: event, ...args[0] }); }
29
+ async addAccount(account) {
30
+ this.deps.vlog(`addAccount id=${account.id} email=${account.email} host=${account.imap?.host} auth=${account.imap?.auth}`);
31
+ this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
32
+ if (this.isGmailAccount(account)) {
33
+ const tokenProvider = this.tokenProviders.get(account.id);
34
+ if (tokenProvider) {
35
+ this.providers.set(account.id, new GmailApiWebProvider(tokenProvider));
36
+ console.log(`[sync] ${account.id}: Gmail API provider registered`);
37
+ }
38
+ else {
39
+ console.warn(`[sync] ${account.id}: no token provider`);
40
+ }
41
+ }
42
+ else if (account.imap?.host && account.imap?.user) {
43
+ try {
44
+ const provider = new ImapWebProvider({
45
+ server: account.imap.host,
46
+ port: account.imap.port || 993,
47
+ username: account.imap.user,
48
+ password: account.imap.password,
49
+ inactivityTimeout: 300000,
50
+ fetchChunkSize: 10,
51
+ fetchChunkSizeMax: 100,
52
+ }, this.deps.createTcpTransport);
53
+ this.providers.set(account.id, provider);
54
+ this.deps.vlog(`addAccount ${account.id}: IMAP provider registered (${account.imap.host}:${account.imap.port})`);
55
+ console.log(`[sync] ${account.id}: IMAP provider registered (${account.imap.host})`);
56
+ }
57
+ catch (e) {
58
+ this.deps.vlog(`addAccount ${account.id}: IMAP provider FAILED: ${e.message}`);
59
+ console.error(`[sync] ${account.id}: IMAP provider failed: ${e.message}`);
60
+ }
61
+ }
62
+ else {
63
+ this.deps.vlog(`addAccount ${account.id}: no imap config, skipping`);
64
+ }
65
+ }
66
+ setTokenProvider(accountId, provider) {
67
+ this.tokenProviders.set(accountId, provider);
68
+ }
69
+ isGmailAccount(account) {
70
+ return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false;
71
+ }
72
+ getProvider(accountId) {
73
+ return this.providers.get(accountId) || null;
74
+ }
75
+ async syncAll() {
76
+ const accounts = this.db.getAccounts();
77
+ this.deps.vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map((a) => a.id).join(",")}`);
78
+ // Phase 1: Sync INBOX for every account first — user sees new mail fast.
79
+ for (const account of accounts) {
80
+ if (!this.providers.has(account.id))
81
+ continue;
82
+ try {
83
+ const folders = await this.syncFolders(account.id);
84
+ const inbox = folders.find((f) => f.specialUse === "inbox");
85
+ if (inbox) {
86
+ await this.syncFolder(account.id, inbox.id);
87
+ this.deps.emitEvent({ type: "syncComplete", accountId: account.id });
88
+ }
89
+ }
90
+ catch (e) {
91
+ console.error(`[sync] ${account.id} inbox: ${e.message}`);
92
+ }
93
+ }
94
+ // Phase 2: Remaining folders.
95
+ for (const account of accounts) {
96
+ if (!this.providers.has(account.id))
97
+ continue;
98
+ try {
99
+ const folders = this.db.getFolders(account.id);
100
+ const remaining = folders.filter((f) => f.specialUse !== "inbox");
101
+ for (const folder of remaining) {
102
+ try {
103
+ await this.syncFolder(account.id, folder.id);
104
+ }
105
+ catch (e) {
106
+ console.error(`[sync] Skip ${folder.path}: ${e.message}`);
107
+ }
108
+ }
109
+ this.db.updateLastSync(account.id, Date.now());
110
+ this.deps.emitEvent({ type: "syncComplete", accountId: account.id });
111
+ }
112
+ catch (e) {
113
+ console.error(`[sync] ${account.id}: ${e.message}`);
114
+ this.deps.vlog(`syncAll: ${account.id} ERROR: ${e.message}`);
115
+ this.deps.emitEvent({ type: "syncError", accountId: account.id, error: e.message });
116
+ }
117
+ }
118
+ }
119
+ async syncAccount(accountId) {
120
+ const folders = await this.syncFolders(accountId);
121
+ for (const folder of folders) {
122
+ try {
123
+ await this.syncFolder(accountId, folder.id);
124
+ }
125
+ catch (e) {
126
+ console.error(`[sync] Skip ${folder.path}: ${e.message}`);
127
+ }
128
+ }
129
+ this.db.updateLastSync(accountId, Date.now());
130
+ this.deps.emitEvent({ type: "syncComplete", accountId });
131
+ }
132
+ async syncFolders(accountId) {
133
+ const provider = this.getProvider(accountId);
134
+ if (!provider) {
135
+ const existing = this.db.getFolders(accountId);
136
+ this.deps.vlog(`syncFolders: ${accountId} no provider, returning ${existing.length} cached folders`);
137
+ return existing;
138
+ }
139
+ this.deps.emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 0 });
140
+ const providerFolders = await provider.listFolders();
141
+ for (const folder of providerFolders) {
142
+ const flags = folder.flags || [];
143
+ if (flags.some((f) => f.toLowerCase() === "\\noselect"))
144
+ continue;
145
+ this.db.upsertFolder(accountId, folder.path, folder.name, folder.specialUse, folder.delimiter);
146
+ }
147
+ this.deps.emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 100 });
148
+ const dbFolders = this.db.getFolders(accountId);
149
+ this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
150
+ return dbFolders;
151
+ }
152
+ async syncFolder(accountId, folderId) {
153
+ const provider = this.getProvider(accountId);
154
+ if (!provider)
155
+ return;
156
+ const folders = this.db.getFolders(accountId);
157
+ const folder = folders.find((f) => f.id === folderId);
158
+ if (!folder)
159
+ return;
160
+ this.deps.emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 0 });
161
+ const highestUid = this.db.getHighestUid(accountId, folderId);
162
+ const startDate = new Date(Date.now() - 30 * 86400000);
163
+ let messages;
164
+ if (highestUid > 0) {
165
+ messages = await provider.fetchSince(folder.path, highestUid, { source: false });
166
+ messages = messages.filter((m) => m.uid > highestUid);
167
+ }
168
+ else {
169
+ const tomorrow = new Date(Date.now() + 86400000);
170
+ messages = await provider.fetchByDate(folder.path, startDate, tomorrow, { source: false });
171
+ }
172
+ if (messages.length > 0) {
173
+ console.log(`[sync] ${folder.path}: ${messages.length} messages`);
174
+ this.storeProviderMessages(accountId, folderId, messages);
175
+ this.db.recalcFolderCounts(folderId);
176
+ this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
177
+ }
178
+ // Reconcile deletions
179
+ try {
180
+ const serverUidsArr = await provider.getUids(folder.path);
181
+ const serverUids = new Set(serverUidsArr);
182
+ const localUids = this.db.getUidsForFolder(accountId, folderId);
183
+ if (serverUidsArr.length === 0 && localUids.length > 0) {
184
+ console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`);
185
+ }
186
+ else {
187
+ const toDelete = localUids.filter((uid) => !serverUids.has(uid));
188
+ const RECONCILE_DELETE_THRESHOLD = 0.5;
189
+ if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
190
+ console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length}`);
191
+ }
192
+ else {
193
+ for (const uid of toDelete) {
194
+ this.db.deleteMessage(accountId, uid);
195
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
196
+ }
197
+ if (toDelete.length > 0) {
198
+ console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`);
199
+ this.db.recalcFolderCounts(folderId);
200
+ this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
201
+ }
202
+ }
203
+ }
204
+ }
205
+ catch (e) {
206
+ console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`);
207
+ }
208
+ this.deps.emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] });
209
+ this.deps.emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
210
+ }
211
+ storeProviderMessages(accountId, folderId, messages) {
212
+ this.db.beginTransaction();
213
+ try {
214
+ for (const msg of messages) {
215
+ const flags = [];
216
+ if (msg.seen)
217
+ flags.push("\\Seen");
218
+ if (msg.flagged)
219
+ flags.push("\\Flagged");
220
+ if (msg.answered)
221
+ flags.push("\\Answered");
222
+ if (msg.draft)
223
+ flags.push("\\Draft");
224
+ const bodyPath = msg.providerId ? `gmail:${msg.providerId}` : "";
225
+ this.db.upsertMessage({
226
+ accountId, folderId, uid: msg.uid,
227
+ messageId: msg.messageId || "", inReplyTo: "", references: [],
228
+ date: msg.date ? msg.date.getTime() : Date.now(),
229
+ subject: msg.subject || "",
230
+ from: toEmailAddress(msg.from?.[0]),
231
+ to: msg.to.map((a) => toEmailAddress(a)),
232
+ cc: msg.cc.map((a) => toEmailAddress(a)),
233
+ flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath,
234
+ });
235
+ }
236
+ this.db.commitTransaction();
237
+ }
238
+ catch (e) {
239
+ this.db.rollbackTransaction();
240
+ console.error(`[sync] storeMessages error: ${e.message}`);
241
+ }
242
+ }
243
+ async fetchMessageBody(accountId, folderId, uid) {
244
+ const t0 = Date.now();
245
+ // Cache first — IndexedDB lookup is O(1) and should always win on a
246
+ // previously-fetched body. If this path misses on something the user
247
+ // clearly fetched before, the cache is broken (wrong key shape, wiped
248
+ // IndexedDB, account-id change) and needs investigation — log so it's
249
+ // visible.
250
+ if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
251
+ const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
252
+ console.log(`[fetchBody] cache hit ${accountId}/${folderId}/${uid} (${Date.now() - t0}ms)`);
253
+ return cached;
254
+ }
255
+ console.log(`[fetchBody] cache miss ${accountId}/${folderId}/${uid} — fetching from provider`);
256
+ const provider = this.getProvider(accountId);
257
+ if (!provider) {
258
+ console.warn(`[fetchBody] no provider for ${accountId}`);
259
+ return null;
260
+ }
261
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
262
+ const bp = envelope?.bodyPath || "";
263
+ // Wall-clock timeout — without this, an IMAP provider that dangles
264
+ // (Dovecot silently dropped the socket, BridgeTransport stalled) hung
265
+ // the viewer forever. 60 s is generous for a single-message BODY[]
266
+ // fetch on a phone connection; legit large bodies finish in seconds,
267
+ // and anything longer means the socket is dead and retrying on a fresh
268
+ // one is faster than waiting.
269
+ const FETCH_TIMEOUT_MS = 60_000;
270
+ const fetchPromise = (async () => {
271
+ if (bp.startsWith("gmail:") && provider.fetchById) {
272
+ const providerId = bp.substring(6);
273
+ return provider.fetchById(providerId, { source: true });
274
+ }
275
+ const folders = this.db.getFolders(accountId);
276
+ const folder = folders.find((f) => f.id === folderId);
277
+ if (!folder)
278
+ return null;
279
+ return provider.fetchOne(folder.path, uid, { source: true });
280
+ })();
281
+ let msg = null;
282
+ try {
283
+ msg = await Promise.race([
284
+ fetchPromise,
285
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`body-fetch timeout ${FETCH_TIMEOUT_MS / 1000}s (${accountId}/${folderId}/${uid})`)), FETCH_TIMEOUT_MS)),
286
+ ]);
287
+ }
288
+ catch (e) {
289
+ console.error(`[fetchBody] failed ${accountId}/${folderId}/${uid} after ${Date.now() - t0}ms: ${e?.message || e}`);
290
+ throw e;
291
+ }
292
+ if (!msg?.source) {
293
+ console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, ${Date.now() - t0}ms)`);
294
+ return null;
295
+ }
296
+ const raw = new TextEncoder().encode(msg.source);
297
+ await this.bodyStore.putMessage(accountId, folderId, uid, raw);
298
+ this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
299
+ console.log(`[fetchBody] fetched + cached ${accountId}/${folderId}/${uid} (${raw.byteLength} bytes, ${Date.now() - t0}ms)`);
300
+ return raw;
301
+ }
302
+ async updateFlagsLocal(accountId, uid, folderId, flags) {
303
+ this.db.updateMessageFlags(accountId, uid, flags);
304
+ this.db.recalcFolderCounts(folderId);
305
+ this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
306
+ this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
307
+ }
308
+ async trashMessage(accountId, folderId, uid) {
309
+ this.db.deleteMessage(accountId, uid);
310
+ this.db.queueSyncAction(accountId, "trash", uid, folderId);
311
+ this.deps.emitEvent({ type: "messageDeleted", accountId, folderId, uid });
312
+ this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
313
+ }
314
+ async trashMessages(accountId, messages) {
315
+ for (const m of messages)
316
+ await this.trashMessage(accountId, m.folderId, m.uid);
317
+ }
318
+ async moveMessage(accountId, uid, folderId, targetFolderId) {
319
+ this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId });
320
+ this.deps.emitEvent({ type: "messageMoved", accountId, fromFolderId: folderId, toFolderId: targetFolderId, uid });
321
+ }
322
+ async moveMessages(accountId, messages, targetFolderId) {
323
+ for (const m of messages)
324
+ await this.moveMessage(accountId, m.uid, m.folderId, targetFolderId);
325
+ }
326
+ async moveMessageCrossAccount() {
327
+ throw new Error("Cross-account move not supported on mobile");
328
+ }
329
+ async undeleteMessage(accountId, uid, folderId) {
330
+ this.db.queueSyncAction(accountId, "undelete", uid, folderId);
331
+ }
332
+ /** Q112: drain queued move/flag/trash actions to the provider. Android is
333
+ * standalone — it pushes state changes to Gmail (or other provider) the
334
+ * same way desktop does, so local actions propagate without needing a
335
+ * desktop to relay them. Called from android-bootstrap on startup and
336
+ * every 2-min sync tick. `send` actions are drained separately by
337
+ * processSendQueue. */
338
+ async processSyncActions(accountId) {
339
+ const provider = this.getProvider(accountId);
340
+ if (!provider)
341
+ return;
342
+ const pending = this.db.getPendingSyncActions(accountId)
343
+ .filter((a) => a.action !== "send");
344
+ if (pending.length === 0)
345
+ return;
346
+ const folders = this.db.getFolders(accountId);
347
+ const folderPath = (id) => {
348
+ const f = folders.find((x) => x.id === id);
349
+ return f?.path || null;
350
+ };
351
+ for (const p of pending) {
352
+ const path = folderPath(p.folderId);
353
+ if (!path) {
354
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`);
355
+ continue;
356
+ }
357
+ try {
358
+ if (p.action === "flags" && typeof provider.setFlags === "function") {
359
+ await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
360
+ }
361
+ else if (p.action === "trash" && typeof provider.trashMessage === "function") {
362
+ await provider.trashMessage(path, p.uid);
363
+ }
364
+ else if (p.action === "move" && typeof provider.moveMessage === "function") {
365
+ const toId = p.targetFolderId;
366
+ const toPath = folderPath(toId);
367
+ if (!toPath) {
368
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`);
369
+ continue;
370
+ }
371
+ await provider.moveMessage(path, p.uid, toPath);
372
+ }
373
+ else {
374
+ // Unsupported action for this provider — don't loop forever.
375
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
376
+ continue;
377
+ }
378
+ this.db.completeSyncActionByUid(accountId, p.action, p.uid);
379
+ }
380
+ catch (e) {
381
+ const msg = e?.message || String(e);
382
+ console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
383
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
384
+ }
385
+ }
386
+ }
387
+ async markFolderRead(folderId) {
388
+ this.db.markFolderRead(folderId);
389
+ }
390
+ async emptyFolder(accountId, folderId) {
391
+ const uids = this.db.getUidsForFolder(accountId, folderId);
392
+ for (const uid of uids) {
393
+ this.db.deleteMessage(accountId, uid);
394
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
395
+ }
396
+ this.db.recalcFolderCounts(folderId);
397
+ this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
398
+ }
399
+ queueOutgoingLocal(accountId, rawMessage) {
400
+ const provider = this.getProvider(accountId);
401
+ if (provider && typeof provider.sendRaw === "function") {
402
+ provider.sendRaw(rawMessage)
403
+ .then((result) => {
404
+ console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
405
+ this.deps.emitEvent({ type: "sendComplete", accountId, messageId: result.id });
406
+ })
407
+ .catch((e) => {
408
+ console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
409
+ this.deps.emitEvent({ type: "sendError", accountId, error: e.message });
410
+ });
411
+ return;
412
+ }
413
+ const accounts = this.db.getAccountConfigs();
414
+ const row = accounts.find((a) => a.id === accountId);
415
+ if (!row) {
416
+ const e = "Unknown account";
417
+ this.deps.emitEvent({ type: "sendError", accountId, error: e });
418
+ throw new Error(e);
419
+ }
420
+ let account;
421
+ try {
422
+ account = JSON.parse(row.configJson);
423
+ }
424
+ catch {
425
+ const e = "Account config malformed";
426
+ this.deps.emitEvent({ type: "sendError", accountId, error: e });
427
+ throw new Error(e);
428
+ }
429
+ if (!account.smtp) {
430
+ const e = "No SMTP config for this account";
431
+ this.deps.emitEvent({ type: "sendError", accountId, error: e });
432
+ throw new Error(e);
433
+ }
434
+ this.sendViaSmtpDirect(accountId, account, rawMessage)
435
+ .then((result) => {
436
+ console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
437
+ this.deps.emitEvent({ type: "sendComplete", accountId });
438
+ })
439
+ .catch((e) => {
440
+ console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
441
+ this.deps.emitEvent({ type: "sendError", accountId, error: e.message });
442
+ });
443
+ }
444
+ async sendViaSmtpDirect(accountId, account, raw) {
445
+ const smtp = account.smtp;
446
+ const smtpPort = smtp.port || 587;
447
+ const smtpHost = smtp.host || account.imap?.host;
448
+ if (!smtpHost)
449
+ throw new Error("No SMTP host");
450
+ const smtpUser = smtp.user || account.imap?.user || account.email;
451
+ const authType = smtp.auth || (account.imap?.password ? "password" : undefined);
452
+ let auth;
453
+ if (authType === "password") {
454
+ const pass = smtp.password || account.imap?.password;
455
+ if (!pass)
456
+ throw new Error("SMTP password not configured");
457
+ auth = { method: "PLAIN", user: smtpUser, pass };
458
+ }
459
+ else if (authType === "oauth2") {
460
+ const tp = this.tokenProviders.get(accountId);
461
+ if (!tp)
462
+ throw new Error("OAuth token provider not registered");
463
+ const token = await tp();
464
+ auth = { method: "XOAUTH2", user: smtpUser, token };
465
+ }
466
+ const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
467
+ const toMatch = raw.match(/^To:\s*(.+)$/mi);
468
+ const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
469
+ const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
470
+ const fromMatch = raw.match(/^From:\s*(.+)$/mi);
471
+ const recipients = [
472
+ ...(toMatch ? parseAddrs(toMatch[1]) : []),
473
+ ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
474
+ ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
475
+ ];
476
+ const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
477
+ if (recipients.length === 0)
478
+ throw new Error("No recipients");
479
+ const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
480
+ const client = new SmtpClient({
481
+ host: smtpHost,
482
+ port: smtpPort,
483
+ secure: smtpPort === 465,
484
+ auth,
485
+ localname: "mailx-android",
486
+ }, this.deps.createTcpTransport);
487
+ try {
488
+ await client.connect();
489
+ return await client.sendMail({ from: sender, to: recipients }, rawToSend);
490
+ }
491
+ finally {
492
+ try {
493
+ await client.quit();
494
+ }
495
+ catch { /* ignore */ }
496
+ }
497
+ }
498
+ async saveDraft(_accountId, _raw, _prevUid, _draftId) {
499
+ return null;
500
+ }
501
+ async deleteDraft(_accountId, _uid) { }
502
+ async reauthenticate(_accountId) { return false; }
503
+ async searchOnServer(_accountId, _query) { return []; }
504
+ async syncAllContacts() { }
505
+ }
506
+ //# sourceMappingURL=sync-manager.js.map