@bobfrankston/mailx 1.0.179 → 1.0.180

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.
@@ -0,0 +1,340 @@
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
+ import { WebMailxDB } from "./db.js";
15
+ import { WebMessageStore } from "./web-message-store.js";
16
+ import { WebMailxService } from "./web-service.js";
17
+ import { loadAccounts, clearSettings } from "./web-settings.js";
18
+ import { GmailApiWebProvider } from "./gmail-api-web.js";
19
+ // ── State ──
20
+ let db;
21
+ let bodyStore;
22
+ let service;
23
+ let syncManager;
24
+ const eventHandlers = [];
25
+ // ── Event emitter ──
26
+ function emitEvent(event) {
27
+ for (const h of eventHandlers) {
28
+ try {
29
+ h(event);
30
+ }
31
+ catch { /* ignore */ }
32
+ }
33
+ if (typeof window._mailxapiEvent === "function") {
34
+ window._mailxapiEvent(event);
35
+ }
36
+ }
37
+ // ── Helpers ──
38
+ function toEmailAddress(addr) {
39
+ return { name: addr?.name || "", address: addr?.address || "" };
40
+ }
41
+ // ── Sync Manager ──
42
+ class AndroidSyncManager {
43
+ db;
44
+ bodyStore;
45
+ providers = new Map();
46
+ tokenProviders = new Map();
47
+ constructor(db, bodyStore) {
48
+ this.db = db;
49
+ this.bodyStore = bodyStore;
50
+ }
51
+ on(_event, _handler) { }
52
+ emit(event, ...args) { emitEvent({ type: event, ...args[0] }); }
53
+ async addAccount(account) {
54
+ this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
55
+ if (this.isGmailAccount(account)) {
56
+ const tokenProvider = this.tokenProviders.get(account.id);
57
+ if (tokenProvider) {
58
+ this.providers.set(account.id, new GmailApiWebProvider(tokenProvider));
59
+ console.log(`[sync] ${account.id}: Gmail API provider registered`);
60
+ }
61
+ else {
62
+ console.warn(`[sync] ${account.id}: no token provider`);
63
+ }
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
+ for (const account of accounts) {
78
+ try {
79
+ const folders = await this.syncFolders(account.id);
80
+ const sorted = [...folders].sort((a, b) => {
81
+ if (a.specialUse === "inbox")
82
+ return -1;
83
+ if (b.specialUse === "inbox")
84
+ return 1;
85
+ return 0;
86
+ });
87
+ for (const folder of sorted.slice(0, 5)) {
88
+ try {
89
+ await this.syncFolder(account.id, folder.id);
90
+ }
91
+ catch (e) {
92
+ console.error(`[sync] Skip ${folder.path}: ${e.message}`);
93
+ }
94
+ }
95
+ this.db.updateLastSync(account.id, Date.now());
96
+ emitEvent({ type: "syncComplete", accountId: account.id });
97
+ }
98
+ catch (e) {
99
+ console.error(`[sync] ${account.id}: ${e.message}`);
100
+ emitEvent({ type: "syncError", accountId: account.id, error: e.message });
101
+ }
102
+ }
103
+ }
104
+ async syncFolders(accountId) {
105
+ const provider = this.getProvider(accountId);
106
+ if (!provider)
107
+ return this.db.getFolders(accountId);
108
+ emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 0 });
109
+ const providerFolders = await provider.listFolders();
110
+ for (const folder of providerFolders) {
111
+ const flags = folder.flags || [];
112
+ if (flags.some(f => f.toLowerCase() === "\\noselect"))
113
+ continue;
114
+ this.db.upsertFolder(accountId, folder.path, folder.name, folder.specialUse, folder.delimiter);
115
+ }
116
+ emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 100 });
117
+ const dbFolders = this.db.getFolders(accountId);
118
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
119
+ return dbFolders;
120
+ }
121
+ async syncFolder(accountId, folderId) {
122
+ const provider = this.getProvider(accountId);
123
+ if (!provider)
124
+ return;
125
+ const folders = this.db.getFolders(accountId);
126
+ const folder = folders.find(f => f.id === folderId);
127
+ if (!folder)
128
+ return;
129
+ emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 0 });
130
+ const highestUid = this.db.getHighestUid(accountId, folderId);
131
+ const startDate = new Date(Date.now() - 30 * 86400000);
132
+ let messages;
133
+ if (highestUid > 0) {
134
+ messages = await provider.fetchSince(folder.path, highestUid, { source: false });
135
+ messages = messages.filter(m => m.uid > highestUid);
136
+ }
137
+ else {
138
+ const tomorrow = new Date(Date.now() + 86400000);
139
+ messages = await provider.fetchByDate(folder.path, startDate, tomorrow, { source: false });
140
+ }
141
+ if (messages.length > 0) {
142
+ console.log(`[sync] ${folder.path}: ${messages.length} messages`);
143
+ this.storeProviderMessages(accountId, folderId, messages);
144
+ this.db.recalcFolderCounts(folderId);
145
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
146
+ }
147
+ emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
148
+ }
149
+ storeProviderMessages(accountId, folderId, messages) {
150
+ this.db.beginTransaction();
151
+ try {
152
+ for (const msg of messages) {
153
+ const flags = [];
154
+ if (msg.seen)
155
+ flags.push("\\Seen");
156
+ if (msg.flagged)
157
+ flags.push("\\Flagged");
158
+ if (msg.answered)
159
+ flags.push("\\Answered");
160
+ if (msg.draft)
161
+ flags.push("\\Draft");
162
+ this.db.upsertMessage({
163
+ accountId, folderId, uid: msg.uid,
164
+ messageId: msg.messageId || "", inReplyTo: "", references: [],
165
+ date: msg.date ? msg.date.getTime() : Date.now(),
166
+ subject: msg.subject || "",
167
+ from: toEmailAddress(msg.from?.[0]),
168
+ to: msg.to.map(a => toEmailAddress(a)),
169
+ cc: msg.cc.map(a => toEmailAddress(a)),
170
+ flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath: "",
171
+ });
172
+ }
173
+ this.db.commitTransaction();
174
+ }
175
+ catch (e) {
176
+ this.db.rollbackTransaction();
177
+ console.error(`[sync] storeMessages error: ${e.message}`);
178
+ }
179
+ }
180
+ async fetchMessageBody(accountId, folderId, uid) {
181
+ if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
182
+ return this.bodyStore.getMessage(accountId, folderId, uid);
183
+ }
184
+ const provider = this.getProvider(accountId);
185
+ if (!provider)
186
+ return null;
187
+ const folders = this.db.getFolders(accountId);
188
+ const folder = folders.find(f => f.id === folderId);
189
+ if (!folder)
190
+ return null;
191
+ const msg = await provider.fetchOne(folder.path, uid, { source: true });
192
+ if (!msg?.source)
193
+ return null;
194
+ const raw = new TextEncoder().encode(msg.source);
195
+ await this.bodyStore.putMessage(accountId, folderId, uid, raw);
196
+ this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
197
+ return raw;
198
+ }
199
+ async updateFlagsLocal(accountId, uid, folderId, flags) {
200
+ this.db.updateMessageFlags(accountId, uid, flags);
201
+ this.db.recalcFolderCounts(folderId);
202
+ this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
203
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
204
+ }
205
+ async trashMessage(accountId, folderId, uid) {
206
+ this.db.deleteMessage(accountId, uid);
207
+ this.db.queueSyncAction(accountId, "trash", uid, folderId);
208
+ emitEvent({ type: "messageDeleted", accountId, folderId, uid });
209
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
210
+ }
211
+ async trashMessages(accountId, messages) {
212
+ for (const m of messages)
213
+ await this.trashMessage(accountId, m.folderId, m.uid);
214
+ }
215
+ async moveMessage(accountId, uid, folderId, targetFolderId) {
216
+ this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId });
217
+ emitEvent({ type: "messageMoved", accountId, fromFolderId: folderId, toFolderId: targetFolderId, uid });
218
+ }
219
+ async moveMessages(accountId, messages, targetFolderId) {
220
+ for (const m of messages)
221
+ await this.moveMessage(accountId, m.uid, m.folderId, targetFolderId);
222
+ }
223
+ async moveMessageCrossAccount() {
224
+ throw new Error("Cross-account move not supported on mobile");
225
+ }
226
+ async undeleteMessage(accountId, uid, folderId) {
227
+ this.db.queueSyncAction(accountId, "undelete", uid, folderId);
228
+ }
229
+ queueOutgoingLocal(accountId, _rawMessage) {
230
+ console.log(`[send] Queued outgoing for ${accountId}`);
231
+ }
232
+ async saveDraft(_accountId, _raw, _prevUid, _draftId) {
233
+ return null;
234
+ }
235
+ async deleteDraft(_accountId, _draftUid) { }
236
+ async reauthenticate(_accountId) { return false; }
237
+ async searchOnServer() { return []; }
238
+ async syncAllContacts() { }
239
+ }
240
+ // ── Initialization ──
241
+ export async function initAndroid() {
242
+ console.log("[android] Initializing mailx...");
243
+ db = new WebMailxDB("mailx");
244
+ await db.waitReady();
245
+ bodyStore = new WebMessageStore();
246
+ syncManager = new AndroidSyncManager(db, bodyStore);
247
+ service = new WebMailxService(db, bodyStore, syncManager);
248
+ const accounts = await loadAccounts();
249
+ console.log(`[android] ${accounts.length} account(s) found`);
250
+ for (const account of accounts) {
251
+ if (!account.enabled)
252
+ continue;
253
+ await syncManager.addAccount(account);
254
+ }
255
+ installBridge();
256
+ setTimeout(() => {
257
+ syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
258
+ }, 1000);
259
+ console.log("[android] Initialization complete");
260
+ emitEvent({ type: "connected" });
261
+ }
262
+ export async function resetStore() {
263
+ await service.resetStore();
264
+ await clearSettings();
265
+ console.log("[android] Store reset");
266
+ }
267
+ // ── mailxapi Bridge ──
268
+ function installBridge() {
269
+ const api = {
270
+ isApp: true,
271
+ platform: "android",
272
+ getAccounts: () => service.getAccounts(),
273
+ getFolders: (accountId) => service.getFolders(accountId),
274
+ getMessages: (accountId, folderId, page, pageSize) => service.getMessages(accountId, folderId, page, pageSize),
275
+ getUnifiedInbox: (page, pageSize) => service.getUnifiedInbox(page, pageSize),
276
+ getMessage: (accountId, uid, allowRemote, folderId) => service.getMessage(accountId, uid, allowRemote, folderId),
277
+ updateFlags: async (accountId, uid, flags) => {
278
+ await service.updateFlags(accountId, uid, flags);
279
+ return { ok: true };
280
+ },
281
+ deleteMessage: async (accountId, uid) => {
282
+ await service.deleteMessage(accountId, uid);
283
+ return { ok: true };
284
+ },
285
+ deleteMessages: async (accountId, uids) => {
286
+ await service.deleteMessages(accountId, uids);
287
+ return { ok: true, count: uids.length };
288
+ },
289
+ undeleteMessage: async (accountId, uid, folderId) => {
290
+ await service.undeleteMessage(accountId, uid, folderId);
291
+ return { ok: true };
292
+ },
293
+ moveMessage: async (accountId, uid, targetFolderId, targetAccountId) => {
294
+ await service.moveMessage(accountId, uid, targetFolderId, targetAccountId);
295
+ return { ok: true };
296
+ },
297
+ moveMessages: async (accountId, uids, targetFolderId) => {
298
+ await service.moveMessages(accountId, uids, targetFolderId);
299
+ return { ok: true, count: uids.length };
300
+ },
301
+ sendMessage: async (msg) => { await service.send(msg); return { ok: true }; },
302
+ saveDraft: (p) => service.saveDraft(p.accountId, p.subject, p.bodyHtml, p.bodyText, p.to, p.cc, p.previousDraftUid, p.draftId),
303
+ deleteDraft: async (accountId, draftUid) => {
304
+ await service.deleteDraft(accountId, draftUid);
305
+ return { ok: true };
306
+ },
307
+ searchMessages: (query, page, pageSize) => service.search(query, page, pageSize),
308
+ searchContacts: (query) => service.searchContacts(query),
309
+ syncAll: async () => { await service.syncAll(); return { ok: true }; },
310
+ syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
311
+ getSyncPending: () => service.getSyncPending(),
312
+ reauthenticate: async (accountId) => ({ ok: await service.reauthenticate(accountId) }),
313
+ markFolderRead: (_accountId, folderId) => { service.markFolderRead(folderId); return { ok: true }; },
314
+ createFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
315
+ renameFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
316
+ deleteFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
317
+ emptyFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
318
+ allowRemoteContent: async (type, value) => {
319
+ await service.allowRemoteContent(type, value);
320
+ return { ok: true };
321
+ },
322
+ getSettings: () => service.getSettings(),
323
+ saveSettingsData: async (data) => { await service.saveSettingsData(data); return { ok: true }; },
324
+ getVersion: async () => {
325
+ const settings = await service.getSettings();
326
+ return { version: "1.0.0-android", theme: settings.ui?.theme || "system", storage: service.getStorageInfo(), platform: "android" };
327
+ },
328
+ getAutocompleteSettings: () => service.getAutocompleteSettings(),
329
+ saveAutocompleteSettings: async (settings) => { await service.saveAutocompleteSettings(settings); return { ok: true }; },
330
+ setupAccount: async () => ({ ok: false, error: "Use desktop for initial setup" }),
331
+ repairAccounts: async () => ({ ok: false, error: "Use desktop for repair" }),
332
+ resetStore: () => resetStore(),
333
+ restart: () => { location.reload(); },
334
+ onEvent: (handler) => { eventHandlers.push(handler); },
335
+ };
336
+ window.mailxapi = api;
337
+ window.dispatchEvent(new CustomEvent("mailxapiready"));
338
+ console.log("[android] mailxapi bridge installed");
339
+ }
340
+ //# sourceMappingURL=android-bootstrap.js.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * WebAssembly SQLite metadata index for mailx (Android/browser).
3
+ * API-compatible with @bobfrankston/mailx-store's MailxDB.
4
+ * Uses sql.js (SQLite compiled to WebAssembly) for in-browser SQLite.
5
+ *
6
+ * Database is persisted to IndexedDB on every write operation.
7
+ * Bodies are stored in IndexedDB via WebMessageStore (not filesystem).
8
+ */
9
+ import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery } from "@bobfrankston/mailx-types";
10
+ export declare class WebMailxDB {
11
+ private db;
12
+ private ready;
13
+ private saveTimer;
14
+ constructor(dbName?: string);
15
+ private init;
16
+ /** Wait for DB initialization */
17
+ waitReady(): Promise<void>;
18
+ /** Persist DB to IndexedDB (debounced — batches rapid writes) */
19
+ private scheduleSave;
20
+ /** Force immediate persist */
21
+ flush(): Promise<void>;
22
+ close(): void;
23
+ private run;
24
+ private get;
25
+ private all;
26
+ private lastId;
27
+ upsertAccount(id: string, name: string, email: string, configJson: string): void;
28
+ getAccounts(): {
29
+ id: string;
30
+ name: string;
31
+ email: string;
32
+ lastSync: number;
33
+ }[];
34
+ getAccountConfigs(): {
35
+ id: string;
36
+ name: string;
37
+ email: string;
38
+ configJson: string;
39
+ }[];
40
+ updateLastSync(accountId: string, timestamp: number): void;
41
+ upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number;
42
+ getFolders(accountId: string): Folder[];
43
+ deleteFolder(folderId: number): void;
44
+ markFolderRead(folderId: number): void;
45
+ deleteAllMessages(accountId: string, folderId: number): void;
46
+ updateFolderCounts(folderId: number, total: number, unread: number): void;
47
+ updateFolderSync(folderId: number, uidvalidity: number, highestModseq: string): void;
48
+ getFolderSync(folderId: number): {
49
+ uidvalidity: number;
50
+ highestModseq: string;
51
+ };
52
+ upsertMessage(msg: {
53
+ accountId: string;
54
+ folderId: number;
55
+ uid: number;
56
+ messageId: string;
57
+ inReplyTo: string;
58
+ references: string[];
59
+ date: number;
60
+ subject: string;
61
+ from: EmailAddress;
62
+ to: EmailAddress[];
63
+ cc: EmailAddress[];
64
+ flags: string[];
65
+ size: number;
66
+ hasAttachments: boolean;
67
+ preview: string;
68
+ bodyPath: string;
69
+ }): number;
70
+ getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
71
+ getUnifiedInbox(page?: number, pageSize?: number): PagedResult<MessageEnvelope>;
72
+ getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope;
73
+ getMessageBodyPath(accountId: string, uid: number): string;
74
+ updateMessageFlags(accountId: string, uid: number, flags: string[]): void;
75
+ updateBodyPath(accountId: string, uid: number, bodyPath: string): void;
76
+ getMessagesWithoutBody(accountId: string, limit?: number): {
77
+ uid: number;
78
+ folderId: number;
79
+ }[];
80
+ getHighestUid(accountId: string, folderId: number): number;
81
+ getOldestDate(accountId: string, folderId: number): number;
82
+ getMessageCount(accountId: string, folderId: number): number;
83
+ getUidsForFolder(accountId: string, folderId: number): number[];
84
+ deleteMessage(accountId: string, uid: number): void;
85
+ recalcFolderCounts(folderId: number): void;
86
+ beginTransaction(): void;
87
+ commitTransaction(): void;
88
+ rollbackTransaction(): void;
89
+ recordSentAddress(name: string, email: string): void;
90
+ seedContactsFromMessages(): number;
91
+ searchContacts(query: string, limit?: number): {
92
+ name: string;
93
+ email: string;
94
+ source: string;
95
+ useCount: number;
96
+ }[];
97
+ searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
98
+ queueSyncAction(accountId: string, action: string, uid: number, folderId: number, extra?: {
99
+ targetFolderId?: number;
100
+ flags?: string[];
101
+ rawMessage?: string;
102
+ }): void;
103
+ getPendingSyncActions(accountId: string): any[];
104
+ completeSyncAction(id: number): void;
105
+ failSyncAction(id: number, error: string): void;
106
+ getPendingSyncCount(accountId: string): number;
107
+ getTotalPendingSyncCount(): number;
108
+ /** Reset the entire database */
109
+ resetStore(): Promise<void>;
110
+ private rowToEnvelope;
111
+ }
112
+ //# sourceMappingURL=db.d.ts.map