@bobfrankston/mailx 1.0.450 → 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.
Files changed (198) hide show
  1. package/bin/mailx.js.map +1 -0
  2. package/bin/mailx.ts +1498 -0
  3. package/bin/postinstall.js.map +1 -0
  4. package/bin/postinstall.ts +41 -0
  5. package/bin/tsconfig.json +10 -0
  6. package/client/.gitattributes +10 -0
  7. package/client/app.js +51 -2
  8. package/client/app.js.map +1 -0
  9. package/client/app.ts +3112 -0
  10. package/client/components/address-book.js.map +1 -0
  11. package/client/components/address-book.ts +204 -0
  12. package/client/components/alarms.js.map +1 -0
  13. package/client/components/alarms.ts +276 -0
  14. package/client/components/calendar-sidebar.js.map +1 -0
  15. package/client/components/calendar-sidebar.ts +474 -0
  16. package/client/components/calendar.js.map +1 -0
  17. package/client/components/calendar.ts +211 -0
  18. package/client/components/context-menu.js.map +1 -0
  19. package/client/components/context-menu.ts +95 -0
  20. package/client/components/folder-picker.js.map +1 -0
  21. package/client/components/folder-picker.ts +127 -0
  22. package/client/components/folder-tree.js.map +1 -0
  23. package/client/components/folder-tree.ts +1069 -0
  24. package/client/components/message-list.js.map +1 -0
  25. package/client/components/message-list.ts +1129 -0
  26. package/client/components/message-viewer.js.map +1 -0
  27. package/client/components/message-viewer.ts +1257 -0
  28. package/client/components/outbox-view.js.map +1 -0
  29. package/client/components/outbox-view.ts +102 -0
  30. package/client/components/tasks.js.map +1 -0
  31. package/client/components/tasks.ts +234 -0
  32. package/client/compose/compose.js.map +1 -0
  33. package/client/compose/compose.ts +1231 -0
  34. package/client/compose/editor.js.map +1 -0
  35. package/client/compose/editor.ts +599 -0
  36. package/client/compose/ghost-text.js.map +1 -0
  37. package/client/compose/ghost-text.ts +140 -0
  38. package/client/index.html +1 -0
  39. package/client/lib/android-bootstrap.js.map +1 -0
  40. package/client/lib/android-bootstrap.ts +9 -0
  41. package/client/lib/api-client.js.map +1 -0
  42. package/client/lib/api-client.ts +439 -0
  43. package/client/lib/local-service.js.map +1 -0
  44. package/client/lib/local-service.ts +646 -0
  45. package/client/lib/local-store.js.map +1 -0
  46. package/client/lib/local-store.ts +283 -0
  47. package/client/lib/message-state.js.map +1 -0
  48. package/client/lib/message-state.ts +140 -0
  49. package/client/tsconfig.json +19 -0
  50. package/package.json +15 -15
  51. package/packages/mailx-api/.gitattributes +10 -0
  52. package/packages/mailx-api/index.d.ts.map +1 -0
  53. package/packages/mailx-api/index.js.map +1 -0
  54. package/packages/mailx-api/index.ts +283 -0
  55. package/packages/mailx-api/tsconfig.json +9 -0
  56. package/packages/mailx-compose/.gitattributes +10 -0
  57. package/packages/mailx-compose/index.d.ts.map +1 -0
  58. package/packages/mailx-compose/index.js.map +1 -0
  59. package/packages/mailx-compose/index.ts +85 -0
  60. package/packages/mailx-compose/tsconfig.json +9 -0
  61. package/packages/mailx-core/index.d.ts.map +1 -0
  62. package/packages/mailx-core/index.js.map +1 -0
  63. package/packages/mailx-core/index.ts +424 -0
  64. package/packages/mailx-core/ipc.d.ts.map +1 -0
  65. package/packages/mailx-core/ipc.js.map +1 -0
  66. package/packages/mailx-core/ipc.ts +62 -0
  67. package/packages/mailx-core/tsconfig.json +9 -0
  68. package/packages/mailx-host/.gitattributes +10 -0
  69. package/packages/mailx-host/index.d.ts.map +1 -0
  70. package/packages/mailx-host/index.js.map +1 -0
  71. package/packages/mailx-host/index.ts +38 -0
  72. package/packages/mailx-host/package.json +10 -2
  73. package/packages/mailx-host/tsconfig.json +9 -0
  74. package/packages/mailx-send/.gitattributes +10 -0
  75. package/packages/mailx-send/cli-queue.d.ts.map +1 -0
  76. package/packages/mailx-send/cli-queue.js.map +1 -0
  77. package/packages/mailx-send/cli-queue.ts +62 -0
  78. package/packages/mailx-send/cli-send.d.ts.map +1 -0
  79. package/packages/mailx-send/cli-send.js.map +1 -0
  80. package/packages/mailx-send/cli-send.ts +83 -0
  81. package/packages/mailx-send/cli.d.ts.map +1 -0
  82. package/packages/mailx-send/cli.js.map +1 -0
  83. package/packages/mailx-send/cli.ts +126 -0
  84. package/packages/mailx-send/index.d.ts.map +1 -0
  85. package/packages/mailx-send/index.js.map +1 -0
  86. package/packages/mailx-send/index.ts +333 -0
  87. package/packages/mailx-send/mailsend/cli.d.ts.map +1 -0
  88. package/packages/mailx-send/mailsend/cli.js.map +1 -0
  89. package/packages/mailx-send/mailsend/cli.ts +81 -0
  90. package/packages/mailx-send/mailsend/index.d.ts.map +1 -0
  91. package/packages/mailx-send/mailsend/index.js.map +1 -0
  92. package/packages/mailx-send/mailsend/index.ts +333 -0
  93. package/packages/mailx-send/mailsend/package-lock.json +65 -0
  94. package/packages/mailx-send/mailsend/tsconfig.json +21 -0
  95. package/packages/mailx-send/package-lock.json +65 -0
  96. package/packages/mailx-send/package.json +1 -1
  97. package/packages/mailx-send/tsconfig.json +21 -0
  98. package/packages/mailx-server/.gitattributes +10 -0
  99. package/packages/mailx-server/index.d.ts.map +1 -0
  100. package/packages/mailx-server/index.js.map +1 -0
  101. package/packages/mailx-server/index.ts +429 -0
  102. package/packages/mailx-server/tsconfig.json +9 -0
  103. package/packages/mailx-service/google-sync.d.ts.map +1 -0
  104. package/packages/mailx-service/google-sync.js.map +1 -0
  105. package/packages/mailx-service/google-sync.ts +238 -0
  106. package/packages/mailx-service/index.d.ts.map +1 -0
  107. package/packages/mailx-service/index.js.map +1 -0
  108. package/packages/mailx-service/index.ts +2461 -0
  109. package/packages/mailx-service/jsonrpc.d.ts.map +1 -0
  110. package/packages/mailx-service/jsonrpc.js.map +1 -0
  111. package/packages/mailx-service/jsonrpc.ts +268 -0
  112. package/packages/mailx-service/tsconfig.json +9 -0
  113. package/packages/mailx-settings/.gitattributes +10 -0
  114. package/packages/mailx-settings/cloud.d.ts.map +1 -0
  115. package/packages/mailx-settings/cloud.js.map +1 -0
  116. package/packages/mailx-settings/cloud.ts +388 -0
  117. package/packages/mailx-settings/index.d.ts.map +1 -0
  118. package/packages/mailx-settings/index.js.map +1 -0
  119. package/packages/mailx-settings/index.ts +892 -0
  120. package/packages/mailx-settings/tsconfig.json +9 -0
  121. package/packages/mailx-store/.gitattributes +10 -0
  122. package/packages/mailx-store/db.d.ts.map +1 -0
  123. package/packages/mailx-store/db.js.map +1 -0
  124. package/packages/mailx-store/db.ts +2007 -0
  125. package/packages/mailx-store/file-store.d.ts.map +1 -0
  126. package/packages/mailx-store/file-store.js.map +1 -0
  127. package/packages/mailx-store/file-store.ts +82 -0
  128. package/packages/mailx-store/index.d.ts.map +1 -0
  129. package/packages/mailx-store/index.js.map +1 -0
  130. package/packages/mailx-store/index.ts +7 -0
  131. package/packages/mailx-store/tsconfig.json +9 -0
  132. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -0
  133. package/packages/mailx-store-web/android-bootstrap.js.map +1 -0
  134. package/packages/mailx-store-web/android-bootstrap.ts +1262 -0
  135. package/packages/mailx-store-web/db.d.ts.map +1 -0
  136. package/packages/mailx-store-web/db.js.map +1 -0
  137. package/packages/mailx-store-web/db.ts +756 -0
  138. package/packages/mailx-store-web/gmail-api-web.d.ts.map +1 -0
  139. package/packages/mailx-store-web/gmail-api-web.js.map +1 -0
  140. package/packages/mailx-store-web/gmail-api-web.ts +11 -0
  141. package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -0
  142. package/packages/mailx-store-web/imap-web-provider.js.map +1 -0
  143. package/packages/mailx-store-web/imap-web-provider.ts +156 -0
  144. package/packages/mailx-store-web/index.d.ts.map +1 -0
  145. package/packages/mailx-store-web/index.js.map +1 -0
  146. package/packages/mailx-store-web/index.ts +10 -0
  147. package/packages/mailx-store-web/main-thread-host.d.ts.map +1 -0
  148. package/packages/mailx-store-web/main-thread-host.js.map +1 -0
  149. package/packages/mailx-store-web/main-thread-host.ts +322 -0
  150. package/packages/mailx-store-web/package.json +4 -4
  151. package/packages/mailx-store-web/provider-types.d.ts.map +1 -0
  152. package/packages/mailx-store-web/provider-types.js.map +1 -0
  153. package/packages/mailx-store-web/provider-types.ts +7 -0
  154. package/packages/mailx-store-web/sync-manager.d.ts.map +1 -0
  155. package/packages/mailx-store-web/sync-manager.js.map +1 -0
  156. package/packages/mailx-store-web/sync-manager.ts +508 -0
  157. package/packages/mailx-store-web/tsconfig.json +10 -0
  158. package/packages/mailx-store-web/web-jsonrpc.d.ts.map +1 -0
  159. package/packages/mailx-store-web/web-jsonrpc.js.map +1 -0
  160. package/packages/mailx-store-web/web-jsonrpc.ts +116 -0
  161. package/packages/mailx-store-web/web-message-store.d.ts.map +1 -0
  162. package/packages/mailx-store-web/web-message-store.js.map +1 -0
  163. package/packages/mailx-store-web/web-message-store.ts +97 -0
  164. package/packages/mailx-store-web/web-service.d.ts.map +1 -0
  165. package/packages/mailx-store-web/web-service.js.map +1 -0
  166. package/packages/mailx-store-web/web-service.ts +616 -0
  167. package/packages/mailx-store-web/web-settings.d.ts.map +1 -0
  168. package/packages/mailx-store-web/web-settings.js.map +1 -0
  169. package/packages/mailx-store-web/web-settings.ts +522 -0
  170. package/packages/mailx-store-web/worker-entry.d.ts.map +1 -0
  171. package/packages/mailx-store-web/worker-entry.js.map +1 -0
  172. package/packages/mailx-store-web/worker-entry.ts +215 -0
  173. package/packages/mailx-store-web/worker-tcp-transport.d.ts.map +1 -0
  174. package/packages/mailx-store-web/worker-tcp-transport.js.map +1 -0
  175. package/packages/mailx-store-web/worker-tcp-transport.ts +101 -0
  176. package/packages/mailx-types/.gitattributes +10 -0
  177. package/packages/mailx-types/index.d.ts.map +1 -0
  178. package/packages/mailx-types/index.js.map +1 -0
  179. package/packages/mailx-types/index.ts +498 -0
  180. package/packages/mailx-types/tsconfig.json +9 -0
  181. package/tsconfig.base.json +2 -1
  182. package/tsconfig.json +9 -0
  183. package/build-apk.cmd +0 -3
  184. package/npmg.bat +0 -6
  185. package/packages/mailx-imap/index.d.ts +0 -442
  186. package/packages/mailx-imap/index.js +0 -3669
  187. package/packages/mailx-imap/package.json +0 -25
  188. package/packages/mailx-imap/providers/gmail-api.d.ts +0 -8
  189. package/packages/mailx-imap/providers/gmail-api.js +0 -8
  190. package/packages/mailx-imap/providers/types.d.ts +0 -9
  191. package/packages/mailx-imap/providers/types.js +0 -9
  192. package/packages/mailx-imap/tsconfig.tsbuildinfo +0 -1
  193. package/rebuild.cmd +0 -23
  194. package/tdview.cmd +0 -2
  195. package/temp.ps1 +0 -10
  196. package/test-smtp-direct.mjs +0 -4
  197. package/unbash.cmd +0 -55
  198. package/unwedge.cmd +0 -1
@@ -0,0 +1,756 @@
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
+
10
+ import initSqlJs, { type Database } from "sql.js";
11
+ import { parseSearchQuery } from "@bobfrankston/mailx-types";
12
+ import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery } from "@bobfrankston/mailx-types";
13
+
14
+ const SCHEMA = `
15
+ CREATE TABLE IF NOT EXISTS accounts (
16
+ id TEXT PRIMARY KEY,
17
+ name TEXT NOT NULL,
18
+ email TEXT NOT NULL,
19
+ config_json TEXT NOT NULL,
20
+ last_sync INTEGER DEFAULT 0
21
+ );
22
+
23
+ CREATE TABLE IF NOT EXISTS folders (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ account_id TEXT NOT NULL REFERENCES accounts(id),
26
+ path TEXT NOT NULL,
27
+ name TEXT NOT NULL,
28
+ special_use TEXT,
29
+ delimiter TEXT DEFAULT '/',
30
+ total_count INTEGER DEFAULT 0,
31
+ unread_count INTEGER DEFAULT 0,
32
+ uidvalidity INTEGER DEFAULT 0,
33
+ highest_modseq TEXT DEFAULT '0',
34
+ UNIQUE(account_id, path)
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS messages (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ account_id TEXT NOT NULL,
40
+ folder_id INTEGER NOT NULL REFERENCES folders(id),
41
+ uid INTEGER NOT NULL,
42
+ message_id TEXT,
43
+ in_reply_to TEXT,
44
+ refs TEXT,
45
+ date INTEGER NOT NULL,
46
+ subject TEXT DEFAULT '',
47
+ from_address TEXT DEFAULT '',
48
+ from_name TEXT DEFAULT '',
49
+ to_json TEXT DEFAULT '[]',
50
+ cc_json TEXT DEFAULT '[]',
51
+ flags_json TEXT DEFAULT '[]',
52
+ size INTEGER DEFAULT 0,
53
+ has_attachments INTEGER DEFAULT 0,
54
+ preview TEXT DEFAULT '',
55
+ body_path TEXT,
56
+ cached_at INTEGER NOT NULL,
57
+ UNIQUE(account_id, folder_id, uid)
58
+ );
59
+
60
+ CREATE INDEX IF NOT EXISTS idx_messages_folder_date
61
+ ON messages(account_id, folder_id, date DESC);
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_messages_message_id
64
+ ON messages(message_id);
65
+
66
+ CREATE TABLE IF NOT EXISTS queue (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ status TEXT NOT NULL DEFAULT 'pending',
69
+ created_at INTEGER NOT NULL,
70
+ send_after INTEGER NOT NULL,
71
+ attempts INTEGER DEFAULT 0,
72
+ last_attempt INTEGER DEFAULT 0,
73
+ error TEXT,
74
+ from_account TEXT NOT NULL,
75
+ to_json TEXT NOT NULL,
76
+ cc_json TEXT DEFAULT '[]',
77
+ bcc_json TEXT DEFAULT '[]',
78
+ subject TEXT DEFAULT '',
79
+ body_html TEXT DEFAULT '',
80
+ body_text TEXT DEFAULT '',
81
+ in_reply_to TEXT,
82
+ refs TEXT
83
+ );
84
+
85
+ CREATE TABLE IF NOT EXISTS contacts (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ source TEXT NOT NULL DEFAULT 'sent',
88
+ google_id TEXT,
89
+ name TEXT DEFAULT '',
90
+ email TEXT NOT NULL,
91
+ organization TEXT DEFAULT '',
92
+ last_used INTEGER DEFAULT 0,
93
+ use_count INTEGER DEFAULT 0,
94
+ updated_at INTEGER NOT NULL,
95
+ UNIQUE(email)
96
+ );
97
+
98
+ CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
99
+ CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
100
+
101
+ CREATE TABLE IF NOT EXISTS sync_actions (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ account_id TEXT NOT NULL,
104
+ action TEXT NOT NULL,
105
+ uid INTEGER,
106
+ folder_id INTEGER,
107
+ target_folder_id INTEGER,
108
+ flags_json TEXT,
109
+ raw_message TEXT,
110
+ created_at INTEGER NOT NULL,
111
+ attempts INTEGER DEFAULT 0,
112
+ last_error TEXT,
113
+ UNIQUE(account_id, action, uid, folder_id)
114
+ );
115
+
116
+ -- Calendar events two-way cache (Android parity with desktop's
117
+ -- packages/mailx-store/db.ts calendar_events table). uuid = stable
118
+ -- local identity, provider_id = Google Calendar event id when known,
119
+ -- dirty = local edit not yet pushed, deleted = tombstone pending delete.
120
+ CREATE TABLE IF NOT EXISTS calendar_events (
121
+ uuid TEXT PRIMARY KEY,
122
+ account_id TEXT NOT NULL,
123
+ provider_id TEXT,
124
+ calendar_id TEXT DEFAULT 'primary',
125
+ title TEXT NOT NULL DEFAULT '',
126
+ start_ms INTEGER NOT NULL,
127
+ end_ms INTEGER NOT NULL,
128
+ all_day INTEGER DEFAULT 0,
129
+ location TEXT DEFAULT '',
130
+ notes TEXT DEFAULT '',
131
+ etag TEXT,
132
+ last_synced INTEGER DEFAULT 0,
133
+ dirty INTEGER DEFAULT 0,
134
+ deleted INTEGER DEFAULT 0,
135
+ updated_at INTEGER NOT NULL
136
+ );
137
+ CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
138
+
139
+ CREATE TABLE IF NOT EXISTS tasks (
140
+ uuid TEXT PRIMARY KEY,
141
+ account_id TEXT NOT NULL,
142
+ provider_id TEXT,
143
+ list_id TEXT DEFAULT '@default',
144
+ title TEXT NOT NULL DEFAULT '',
145
+ notes TEXT DEFAULT '',
146
+ due_ms INTEGER,
147
+ completed_ms INTEGER,
148
+ etag TEXT,
149
+ last_synced INTEGER DEFAULT 0,
150
+ dirty INTEGER DEFAULT 0,
151
+ deleted INTEGER DEFAULT 0,
152
+ updated_at INTEGER NOT NULL
153
+ );
154
+ CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
155
+
156
+ -- Generic store-sync queue for non-message domains (calendar/tasks/contacts).
157
+ CREATE TABLE IF NOT EXISTS store_sync (
158
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
159
+ kind TEXT NOT NULL,
160
+ op TEXT NOT NULL,
161
+ account_id TEXT NOT NULL,
162
+ target_uuid TEXT NOT NULL,
163
+ payload TEXT,
164
+ attempts INTEGER DEFAULT 0,
165
+ last_error TEXT,
166
+ created_at INTEGER NOT NULL,
167
+ UNIQUE(kind, target_uuid, op)
168
+ );
169
+ `;
170
+
171
+ const IDB_NAME = "mailx-sqldb";
172
+ const IDB_STORE = "database";
173
+ const IDB_KEY = "mailx.db";
174
+
175
+ // ── IndexedDB persistence ──
176
+
177
+ function openIdb(): Promise<IDBDatabase> {
178
+ return new Promise((resolve, reject) => {
179
+ const req = indexedDB.open(IDB_NAME, 1);
180
+ req.onupgradeneeded = () => {
181
+ const db = req.result;
182
+ if (!db.objectStoreNames.contains(IDB_STORE)) {
183
+ db.createObjectStore(IDB_STORE);
184
+ }
185
+ };
186
+ req.onsuccess = () => resolve(req.result);
187
+ req.onerror = () => reject(req.error);
188
+ });
189
+ }
190
+
191
+ async function loadDbFromIdb(): Promise<Uint8Array | null> {
192
+ const idb = await openIdb();
193
+ return new Promise((resolve, reject) => {
194
+ const tx = idb.transaction(IDB_STORE, "readonly");
195
+ const req = tx.objectStore(IDB_STORE).get(IDB_KEY);
196
+ req.onsuccess = () => resolve(req.result ? new Uint8Array(req.result) : null);
197
+ req.onerror = () => reject(req.error);
198
+ });
199
+ }
200
+
201
+ async function saveDbToIdb(data: Uint8Array): Promise<void> {
202
+ const idb = await openIdb();
203
+ return new Promise((resolve, reject) => {
204
+ const tx = idb.transaction(IDB_STORE, "readwrite");
205
+ tx.objectStore(IDB_STORE).put(data.buffer, IDB_KEY);
206
+ tx.oncomplete = () => resolve();
207
+ tx.onerror = () => reject(tx.error);
208
+ });
209
+ }
210
+
211
+ async function clearIdb(): Promise<void> {
212
+ const idb = await openIdb();
213
+ return new Promise((resolve, reject) => {
214
+ const tx = idb.transaction(IDB_STORE, "readwrite");
215
+ tx.objectStore(IDB_STORE).delete(IDB_KEY);
216
+ tx.oncomplete = () => resolve();
217
+ tx.onerror = () => reject(tx.error);
218
+ });
219
+ }
220
+
221
+ // ── Helper: convert sql.js result to array of objects ──
222
+
223
+ function resultToRows(result: any[]): any[] {
224
+ if (!result || result.length === 0) return [];
225
+ const { columns, values } = result[0];
226
+ return values.map((row: any[]) => {
227
+ const obj: any = {};
228
+ columns.forEach((col: string, i: number) => { obj[col] = row[i]; });
229
+ return obj;
230
+ });
231
+ }
232
+
233
+ export class WebMailxDB {
234
+ private db: Database | null = null;
235
+ private ready: Promise<void>;
236
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
237
+
238
+ constructor(dbName = "mailx") {
239
+ this.ready = this.init(dbName);
240
+ }
241
+
242
+ private async init(_dbName: string): Promise<void> {
243
+ const SQL = await initSqlJs({
244
+ locateFile: (file: string) => `https://sql.js.org/dist/${file}`
245
+ });
246
+
247
+ // Try to load existing DB from IndexedDB
248
+ const existing = await loadDbFromIdb();
249
+ if (existing) {
250
+ this.db = new SQL.Database(existing);
251
+ } else {
252
+ this.db = new SQL.Database();
253
+ }
254
+
255
+ this.db.run("PRAGMA foreign_keys = ON");
256
+ this.db.run(SCHEMA);
257
+ this.scheduleSave();
258
+ }
259
+
260
+ /** Wait for DB initialization */
261
+ async waitReady(): Promise<void> {
262
+ await this.ready;
263
+ }
264
+
265
+ /** Persist DB to IndexedDB (debounced — batches rapid writes) */
266
+ private scheduleSave(): void {
267
+ if (this.saveTimer) return;
268
+ this.saveTimer = setTimeout(async () => {
269
+ this.saveTimer = null;
270
+ if (this.db) {
271
+ const data = this.db.export();
272
+ await saveDbToIdb(data);
273
+ }
274
+ }, 1000);
275
+ }
276
+
277
+ /** Force immediate persist */
278
+ async flush(): Promise<void> {
279
+ if (this.saveTimer) {
280
+ clearTimeout(this.saveTimer);
281
+ this.saveTimer = null;
282
+ }
283
+ if (this.db) {
284
+ const data = this.db.export();
285
+ await saveDbToIdb(data);
286
+ }
287
+ }
288
+
289
+ close(): void {
290
+ if (this.db) {
291
+ this.flush();
292
+ this.db.close();
293
+ this.db = null;
294
+ }
295
+ }
296
+
297
+ private run(sql: string, params?: any[]): void {
298
+ this.db!.run(sql, params);
299
+ this.scheduleSave();
300
+ }
301
+
302
+ private get(sql: string, params?: any[]): any {
303
+ const result = this.db!.exec(sql, params);
304
+ const rows = resultToRows(result);
305
+ return rows[0] || null;
306
+ }
307
+
308
+ private all(sql: string, params?: any[]): any[] {
309
+ const result = this.db!.exec(sql, params);
310
+ return resultToRows(result);
311
+ }
312
+
313
+ private lastId(): number {
314
+ const row = this.get("SELECT last_insert_rowid() as id");
315
+ return row?.id || 0;
316
+ }
317
+
318
+ // ── Accounts ──
319
+
320
+ upsertAccount(id: string, name: string, email: string, configJson: string): void {
321
+ this.run(
322
+ `INSERT INTO accounts (id, name, email, config_json)
323
+ VALUES (?, ?, ?, ?)
324
+ ON CONFLICT(id) DO UPDATE SET name=?, email=?, config_json=?`,
325
+ [id, name, email, configJson, name, email, configJson]);
326
+ }
327
+
328
+ getAccounts(): { id: string; name: string; email: string; lastSync: number }[] {
329
+ return this.all("SELECT id, name, email, last_sync as lastSync FROM accounts");
330
+ }
331
+
332
+ getAccountConfigs(): { id: string; name: string; email: string; configJson: string }[] {
333
+ return this.all("SELECT id, name, email, config_json as configJson FROM accounts");
334
+ }
335
+
336
+ updateLastSync(accountId: string, timestamp: number): void {
337
+ this.run("UPDATE accounts SET last_sync = ? WHERE id = ?", [timestamp, accountId]);
338
+ }
339
+
340
+ // ── Folders ──
341
+
342
+ upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number {
343
+ const existing = this.get(
344
+ "SELECT id FROM folders WHERE account_id = ? AND path = ?",
345
+ [accountId, folderPath]);
346
+ if (existing) {
347
+ this.run("UPDATE folders SET name = ?, special_use = ?, delimiter = ? WHERE id = ?",
348
+ [name, specialUse, delimiter, existing.id]);
349
+ return existing.id;
350
+ }
351
+ this.run("INSERT INTO folders (account_id, path, name, special_use, delimiter) VALUES (?, ?, ?, ?, ?)",
352
+ [accountId, folderPath, name, specialUse, delimiter]);
353
+ return this.lastId();
354
+ }
355
+
356
+ getFolders(accountId: string): Folder[] {
357
+ const rows = this.all("SELECT * FROM folders WHERE account_id = ? ORDER BY path", [accountId]);
358
+ return rows.map(r => ({
359
+ id: r.id, accountId: r.account_id, path: r.path, name: r.name,
360
+ specialUse: r.special_use, delimiter: r.delimiter,
361
+ totalCount: r.total_count, unreadCount: r.unread_count,
362
+ children: [] as Folder[]
363
+ }));
364
+ }
365
+
366
+ deleteFolder(folderId: number): void {
367
+ this.run("DELETE FROM messages WHERE folder_id = ?", [folderId]);
368
+ this.run("DELETE FROM folders WHERE id = ?", [folderId]);
369
+ }
370
+
371
+ markFolderRead(folderId: number): void {
372
+ this.run(
373
+ `UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]')
374
+ WHERE folder_id = ? AND flags_json NOT LIKE '%\\\\Seen%'`, [folderId]);
375
+ this.recalcFolderCounts(folderId);
376
+ }
377
+
378
+ deleteAllMessages(accountId: string, folderId: number): void {
379
+ this.run("DELETE FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
380
+ this.recalcFolderCounts(folderId);
381
+ }
382
+
383
+ updateFolderCounts(folderId: number, total: number, unread: number): void {
384
+ this.run("UPDATE folders SET total_count = ?, unread_count = ? WHERE id = ?", [total, unread, folderId]);
385
+ }
386
+
387
+ updateFolderSync(folderId: number, uidvalidity: number, highestModseq: string): void {
388
+ this.run("UPDATE folders SET uidvalidity = ?, highest_modseq = ? WHERE id = ?",
389
+ [uidvalidity, highestModseq, folderId]);
390
+ }
391
+
392
+ getFolderSync(folderId: number): { uidvalidity: number; highestModseq: string } {
393
+ const row = this.get("SELECT uidvalidity, highest_modseq as highestModseq FROM folders WHERE id = ?", [folderId]);
394
+ return row || { uidvalidity: 0, highestModseq: "0" };
395
+ }
396
+
397
+ // ── Messages ──
398
+
399
+ upsertMessage(msg: {
400
+ accountId: string; folderId: number; uid: number; messageId: string;
401
+ inReplyTo: string; references: string[]; date: number; subject: string;
402
+ from: EmailAddress; to: EmailAddress[]; cc: EmailAddress[];
403
+ flags: string[]; size: number; hasAttachments: boolean; preview: string; bodyPath: string;
404
+ }): number {
405
+ const existing = this.get(
406
+ "SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?",
407
+ [msg.accountId, msg.folderId, msg.uid]);
408
+ if (existing) {
409
+ this.run(`UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ? WHERE id = ?`,
410
+ [JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id]);
411
+ return existing.id;
412
+ }
413
+ this.run(
414
+ `INSERT INTO messages (
415
+ account_id, folder_id, uid, message_id, in_reply_to, refs,
416
+ date, subject, from_address, from_name, to_json, cc_json,
417
+ flags_json, size, has_attachments, preview, body_path, cached_at
418
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
419
+ [
420
+ msg.accountId, msg.folderId, msg.uid, msg.messageId,
421
+ msg.inReplyTo, JSON.stringify(msg.references),
422
+ msg.date, msg.subject, msg.from.address, msg.from.name,
423
+ JSON.stringify(msg.to), JSON.stringify(msg.cc),
424
+ JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0,
425
+ msg.preview, msg.bodyPath, Date.now()
426
+ ]);
427
+ return this.lastId();
428
+ }
429
+
430
+ getMessages(query: MessageQuery): PagedResult<MessageEnvelope> {
431
+ const page = query.page || 1;
432
+ const pageSize = query.pageSize || 50;
433
+ const offset = (page - 1) * pageSize;
434
+ const sortCol = query.sort === "from" ? "from_name" : query.sort === "subject" ? "subject" : "date";
435
+ const sortDir = query.sortDir || "desc";
436
+
437
+ let where = "account_id = ? AND folder_id = ?";
438
+ const params: any[] = [query.accountId, query.folderId];
439
+ if (query.search) {
440
+ where += " AND (subject LIKE ? OR from_name LIKE ? OR from_address LIKE ?)";
441
+ const term = `%${query.search}%`;
442
+ params.push(term, term, term);
443
+ }
444
+
445
+ const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, params);
446
+ const total = countRow?.cnt || 0;
447
+ const rows = this.all(
448
+ `SELECT * FROM messages WHERE ${where} ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`,
449
+ [...params, pageSize, offset]);
450
+ return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
451
+ }
452
+
453
+ getUnifiedInbox(page = 1, pageSize = 50): PagedResult<MessageEnvelope> {
454
+ const offset = (page - 1) * pageSize;
455
+ const inboxRows = this.all("SELECT id FROM folders WHERE special_use = 'inbox'");
456
+ if (inboxRows.length === 0) return { items: [], total: 0, page, pageSize };
457
+ const ids = inboxRows.map(r => r.id);
458
+ const placeholders = ids.map(() => "?").join(",");
459
+ const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE folder_id IN (${placeholders})`, ids);
460
+ const total = countRow?.cnt || 0;
461
+ const rows = this.all(
462
+ `SELECT * FROM messages WHERE folder_id IN (${placeholders}) ORDER BY date DESC LIMIT ? OFFSET ?`,
463
+ [...ids, pageSize, offset]);
464
+ return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
465
+ }
466
+
467
+ getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope {
468
+ const sql = folderId != null
469
+ ? "SELECT * FROM messages WHERE account_id = ? AND uid = ? AND folder_id = ?"
470
+ : "SELECT * FROM messages WHERE account_id = ? AND uid = ?";
471
+ const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
472
+ const r = this.get(sql, params);
473
+ if (!r) return null as any;
474
+ return this.rowToEnvelope(r);
475
+ }
476
+
477
+ getMessageBodyPath(accountId: string, uid: number): string {
478
+ const r = this.get("SELECT body_path FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
479
+ return r?.body_path || "";
480
+ }
481
+
482
+ updateMessageFlags(accountId: string, uid: number, flags: string[]): void {
483
+ this.run("UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?",
484
+ [JSON.stringify(flags), accountId, uid]);
485
+ }
486
+
487
+ updateBodyPath(accountId: string, uid: number, bodyPath: string): void {
488
+ this.run("UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?",
489
+ [bodyPath, accountId, uid]);
490
+ }
491
+
492
+ getMessagesWithoutBody(accountId: string, limit = 50): { uid: number; folderId: number }[] {
493
+ // "idb:<acct>/<folder>/<uid>" means the body is cached in IndexedDB via
494
+ // WebMessageStore. Anything else (NULL, "", "gmail:<id>", legacy paths)
495
+ // still needs fetching.
496
+ return this.all(
497
+ "SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path NOT LIKE 'idb:%') ORDER BY date DESC LIMIT ?",
498
+ [accountId, limit]);
499
+ }
500
+
501
+ getHighestUid(accountId: string, folderId: number): number {
502
+ const r = this.get("SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?",
503
+ [accountId, folderId]);
504
+ return r?.maxUid || 0;
505
+ }
506
+
507
+ getOldestDate(accountId: string, folderId: number): number {
508
+ const r = this.get("SELECT MIN(date) as minDate FROM messages WHERE account_id = ? AND folder_id = ?",
509
+ [accountId, folderId]);
510
+ return r?.minDate || 0;
511
+ }
512
+
513
+ getMessageCount(accountId: string, folderId: number): number {
514
+ const r = this.get("SELECT count(*) as cnt FROM messages WHERE account_id = ? AND folder_id = ?",
515
+ [accountId, folderId]);
516
+ return r?.cnt || 0;
517
+ }
518
+
519
+ getUidsForFolder(accountId: string, folderId: number): number[] {
520
+ return this.all("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?",
521
+ [accountId, folderId]).map(r => r.uid);
522
+ }
523
+
524
+ deleteMessage(accountId: string, uid: number): void {
525
+ const msg = this.get("SELECT folder_id FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
526
+ this.run("DELETE FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
527
+ if (msg) this.recalcFolderCounts(msg.folder_id);
528
+ }
529
+
530
+ recalcFolderCounts(folderId: number): void {
531
+ const counts = this.get(
532
+ `SELECT COUNT(*) as total,
533
+ SUM(CASE WHEN flags_json NOT LIKE '%\\\\Seen%' THEN 1 ELSE 0 END) as unread
534
+ FROM messages WHERE folder_id = ?`, [folderId]);
535
+ this.updateFolderCounts(folderId, counts?.total || 0, counts?.unread || 0);
536
+ }
537
+
538
+ beginTransaction(): void { this.db!.run("BEGIN"); }
539
+ commitTransaction(): void { this.db!.run("COMMIT"); this.scheduleSave(); }
540
+ rollbackTransaction(): void { this.db!.run("ROLLBACK"); }
541
+
542
+ // ── Contacts ──
543
+
544
+ recordSentAddress(name: string, email: string): void {
545
+ const now = Date.now();
546
+ const existing = this.get("SELECT id FROM contacts WHERE email = ?", [email]);
547
+ if (existing) {
548
+ this.run(
549
+ "UPDATE contacts SET name = CASE WHEN ? != '' THEN ? ELSE name END, last_used = ?, use_count = use_count + 1, updated_at = ? WHERE email = ?",
550
+ [name, name, now, now, email]);
551
+ } else {
552
+ this.run(
553
+ "INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('sent', ?, ?, ?, 1, ?)",
554
+ [name, email, now, now]);
555
+ }
556
+ }
557
+
558
+ seedContactsFromMessages(): number {
559
+ const now = Date.now();
560
+ const rows = this.all(
561
+ `SELECT from_name, from_address, COUNT(*) as cnt, MAX(date) as last
562
+ FROM messages WHERE from_address != '' GROUP BY from_address`);
563
+ let added = 0;
564
+ for (const r of rows) {
565
+ const existing = this.get("SELECT id FROM contacts WHERE email = ?", [r.from_address]);
566
+ if (!existing) {
567
+ this.run(
568
+ "INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)",
569
+ [r.from_name || "", r.from_address, r.last, r.cnt, now]);
570
+ added++;
571
+ }
572
+ }
573
+ return added;
574
+ }
575
+
576
+ searchContacts(query: string, limit = 10): { name: string; email: string; source: string; useCount: number }[] {
577
+ query = (query || "").trim();
578
+ if (!query) return [];
579
+ const q = `%${query}%`;
580
+ return this.all(
581
+ `SELECT name, email, source, use_count as useCount FROM contacts
582
+ WHERE email LIKE ? OR name LIKE ?
583
+ ORDER BY use_count DESC, last_used DESC LIMIT ?`, [q, q, limit]);
584
+ }
585
+
586
+ /** Address-book listing. Same shape as mailx-store/db.ts:listContacts so
587
+ * the address-book modal renders identically on desktop and Android. */
588
+ listContacts(query: string, page = 1, pageSize = 100): { items: any[]; total: number; page: number; pageSize: number } {
589
+ query = (query || "").trim();
590
+ const hasQuery = !!query;
591
+ const q = `%${query}%`;
592
+ const whereClause = hasQuery ? "WHERE email LIKE ? OR name LIKE ?" : "";
593
+ const params: any[] = hasQuery ? [q, q] : [];
594
+ const totalRow = this.get(`SELECT COUNT(*) as c FROM contacts ${whereClause}`, params);
595
+ const offset = (page - 1) * pageSize;
596
+ const rows = this.all(
597
+ `SELECT name, email, source, google_id, use_count, last_used FROM contacts
598
+ ${whereClause}
599
+ ORDER BY use_count DESC, last_used DESC
600
+ LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
601
+ return {
602
+ items: rows.map((r: any) => ({
603
+ name: r.name, email: r.email, source: r.source,
604
+ googleId: r.google_id || null,
605
+ useCount: r.use_count, lastUsed: r.last_used,
606
+ })),
607
+ total: (totalRow as any)?.c || 0,
608
+ page, pageSize,
609
+ };
610
+ }
611
+
612
+ upsertContact(name: string, email: string): void {
613
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
614
+ throw new Error(`Invalid email: ${email}`);
615
+ }
616
+ const now = Date.now();
617
+ const existing = this.get("SELECT id FROM contacts WHERE email = ?", [email]);
618
+ if (existing) {
619
+ this.run("UPDATE contacts SET name = ?, updated_at = ? WHERE email = ?",
620
+ [name || "", now, email]);
621
+ } else {
622
+ this.run("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('manual', ?, ?, 0, 0, ?)",
623
+ [name || "", email, now]);
624
+ }
625
+ }
626
+
627
+ deleteContactLocal(email: string): void {
628
+ this.run("DELETE FROM contacts WHERE email = ?", [email]);
629
+ }
630
+
631
+ /** Q49 heuristic: has the user ever sent to `recipientEmail` with a
632
+ * non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
633
+ * the Cc row on reply to a frequent-Cc'd recipient. */
634
+ hasCcHistoryTo(recipientEmail: string): boolean {
635
+ const email = (recipientEmail || "").trim().toLowerCase();
636
+ if (!email) return false;
637
+ try {
638
+ const row = this.get(`
639
+ SELECT 1 FROM messages m
640
+ JOIN folders f ON m.folder_id = f.id
641
+ WHERE f.special_use = 'sent'
642
+ AND lower(m.to_json) LIKE ?
643
+ AND m.cc_json IS NOT NULL AND m.cc_json != '[]' AND m.cc_json != ''
644
+ LIMIT 1
645
+ `, [`%"${email}"%`]);
646
+ return !!row;
647
+ } catch {
648
+ return false;
649
+ }
650
+ }
651
+
652
+ // ── Search ──
653
+
654
+ searchMessages(query: string, page = 1, pageSize = 50, accountId?: string, folderId?: number): PagedResult<MessageEnvelope> {
655
+ const offset = (page - 1) * pageSize;
656
+ const parsed = parseSearchQuery(query);
657
+ const allParams: any[] = [...parsed.params];
658
+ let where = parsed.conditions.length > 0 ? parsed.conditions.join(" AND ") : "1=0";
659
+ if (accountId && folderId) {
660
+ where += " AND account_id = ? AND folder_id = ?";
661
+ allParams.push(accountId, folderId);
662
+ } else if (accountId) {
663
+ where += " AND account_id = ?";
664
+ allParams.push(accountId);
665
+ }
666
+ const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, allParams);
667
+ const total = countRow?.cnt || 0;
668
+ const rows = this.all(
669
+ `SELECT * FROM messages WHERE ${where} ORDER BY date DESC LIMIT ? OFFSET ?`,
670
+ [...allParams, pageSize, offset]);
671
+ return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
672
+ }
673
+
674
+ // ── Sync Actions ──
675
+
676
+ queueSyncAction(accountId: string, action: string, uid: number, folderId: number, extra?: {
677
+ targetFolderId?: number; flags?: string[]; rawMessage?: string;
678
+ }): void {
679
+ try {
680
+ this.run(
681
+ `INSERT OR REPLACE INTO sync_actions (account_id, action, uid, folder_id, target_folder_id, flags_json, raw_message, created_at)
682
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
683
+ [accountId, action, uid, folderId,
684
+ extra?.targetFolderId || null,
685
+ extra?.flags ? JSON.stringify(extra.flags) : null,
686
+ extra?.rawMessage || null,
687
+ Date.now()]);
688
+ } catch { /* UNIQUE constraint */ }
689
+ }
690
+
691
+ getPendingSyncActions(accountId: string): any[] {
692
+ const rows = this.all("SELECT * FROM sync_actions WHERE account_id = ? ORDER BY created_at", [accountId]);
693
+ return rows.map(r => ({
694
+ id: r.id, action: r.action, uid: r.uid, folderId: r.folder_id,
695
+ targetFolderId: r.target_folder_id,
696
+ flags: r.flags_json ? JSON.parse(r.flags_json) : [],
697
+ rawMessage: r.raw_message, attempts: r.attempts,
698
+ }));
699
+ }
700
+
701
+ completeSyncAction(id: number): void { this.run("DELETE FROM sync_actions WHERE id = ?", [id]); }
702
+
703
+ failSyncAction(id: number, error: string): void {
704
+ this.run("UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE id = ?", [error, id]);
705
+ }
706
+
707
+ /** Delete a queued sync action by (accountId, action, uid) — used by the
708
+ * send path which tracks queued sends via a unique negative uid rather
709
+ * than threading the row id through the async send pipeline. */
710
+ completeSyncActionByUid(accountId: string, action: string, uid: number): void {
711
+ this.run("DELETE FROM sync_actions WHERE account_id = ? AND action = ? AND uid = ?",
712
+ [accountId, action, uid]);
713
+ }
714
+
715
+ /** Mark a send-queue action failed by uid — same tracking-key rationale. */
716
+ failSyncActionByUid(accountId: string, action: string, uid: number, error: string): void {
717
+ this.run("UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE account_id = ? AND action = ? AND uid = ?",
718
+ [error, accountId, action, uid]);
719
+ }
720
+
721
+ getPendingSyncCount(accountId: string): number {
722
+ const r = this.get("SELECT COUNT(*) as cnt FROM sync_actions WHERE account_id = ?", [accountId]);
723
+ return r?.cnt || 0;
724
+ }
725
+
726
+ getTotalPendingSyncCount(): number {
727
+ const r = this.get("SELECT COUNT(*) as cnt FROM sync_actions");
728
+ return r?.cnt || 0;
729
+ }
730
+
731
+ /** Reset the entire database */
732
+ async resetStore(): Promise<void> {
733
+ this.run("DELETE FROM sync_actions");
734
+ this.run("DELETE FROM contacts");
735
+ this.run("DELETE FROM messages");
736
+ this.run("DELETE FROM folders");
737
+ this.run("DELETE FROM accounts");
738
+ this.run("DELETE FROM queue");
739
+ await clearIdb();
740
+ }
741
+
742
+ // ── Helpers ──
743
+
744
+ private rowToEnvelope(r: any): MessageEnvelope {
745
+ return {
746
+ id: r.id, accountId: r.account_id, folderId: r.folder_id,
747
+ uid: r.uid, messageId: r.message_id || "", inReplyTo: r.in_reply_to || "",
748
+ references: JSON.parse(r.refs || "[]"), date: r.date, subject: r.subject,
749
+ from: { name: r.from_name, address: r.from_address },
750
+ to: JSON.parse(r.to_json), cc: JSON.parse(r.cc_json),
751
+ flags: JSON.parse(r.flags_json), size: r.size,
752
+ hasAttachments: !!r.has_attachments, preview: r.preview,
753
+ bodyPath: r.body_path || ""
754
+ };
755
+ }
756
+ }