@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.
- package/client/android.html +156 -0
- package/client/components/message-viewer.js +5 -3
- package/client/lib/android-bootstrap.js +9 -0
- package/client/lib/api-client.js +83 -75
- package/package.json +6 -4
- package/packages/mailx-imap/index.js +24 -16
- package/packages/mailx-imap/providers/gmail-api.js +4 -4
- package/packages/mailx-store-web/android-bootstrap.d.ts +16 -0
- package/packages/mailx-store-web/android-bootstrap.js +340 -0
- package/packages/mailx-store-web/db.d.ts +112 -0
- package/packages/mailx-store-web/db.js +508 -0
- package/packages/mailx-store-web/gmail-api-web.d.ts +28 -0
- package/packages/mailx-store-web/gmail-api-web.js +231 -0
- package/packages/mailx-store-web/index.d.ts +10 -0
- package/packages/mailx-store-web/index.js +10 -0
- package/packages/mailx-store-web/package.json +19 -0
- package/packages/mailx-store-web/provider-types.d.ts +50 -0
- package/packages/mailx-store-web/provider-types.js +7 -0
- package/packages/mailx-store-web/sql.js.d.ts +29 -0
- package/packages/mailx-store-web/web-jsonrpc.d.ts +20 -0
- package/packages/mailx-store-web/web-jsonrpc.js +94 -0
- package/packages/mailx-store-web/web-message-store.d.ts +16 -0
- package/packages/mailx-store-web/web-message-store.js +89 -0
- package/packages/mailx-store-web/web-service.d.ts +92 -0
- package/packages/mailx-store-web/web-service.js +481 -0
- package/packages/mailx-store-web/web-settings.d.ts +81 -0
- package/packages/mailx-store-web/web-settings.js +421 -0
|
@@ -0,0 +1,508 @@
|
|
|
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 initSqlJs from "sql.js";
|
|
10
|
+
const SCHEMA = `
|
|
11
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
12
|
+
id TEXT PRIMARY KEY,
|
|
13
|
+
name TEXT NOT NULL,
|
|
14
|
+
email TEXT NOT NULL,
|
|
15
|
+
config_json TEXT NOT NULL,
|
|
16
|
+
last_sync INTEGER DEFAULT 0
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
account_id TEXT NOT NULL REFERENCES accounts(id),
|
|
22
|
+
path TEXT NOT NULL,
|
|
23
|
+
name TEXT NOT NULL,
|
|
24
|
+
special_use TEXT,
|
|
25
|
+
delimiter TEXT DEFAULT '/',
|
|
26
|
+
total_count INTEGER DEFAULT 0,
|
|
27
|
+
unread_count INTEGER DEFAULT 0,
|
|
28
|
+
uidvalidity INTEGER DEFAULT 0,
|
|
29
|
+
highest_modseq TEXT DEFAULT '0',
|
|
30
|
+
UNIQUE(account_id, path)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
account_id TEXT NOT NULL,
|
|
36
|
+
folder_id INTEGER NOT NULL REFERENCES folders(id),
|
|
37
|
+
uid INTEGER NOT NULL,
|
|
38
|
+
message_id TEXT,
|
|
39
|
+
in_reply_to TEXT,
|
|
40
|
+
refs TEXT,
|
|
41
|
+
date INTEGER NOT NULL,
|
|
42
|
+
subject TEXT DEFAULT '',
|
|
43
|
+
from_address TEXT DEFAULT '',
|
|
44
|
+
from_name TEXT DEFAULT '',
|
|
45
|
+
to_json TEXT DEFAULT '[]',
|
|
46
|
+
cc_json TEXT DEFAULT '[]',
|
|
47
|
+
flags_json TEXT DEFAULT '[]',
|
|
48
|
+
size INTEGER DEFAULT 0,
|
|
49
|
+
has_attachments INTEGER DEFAULT 0,
|
|
50
|
+
preview TEXT DEFAULT '',
|
|
51
|
+
body_path TEXT,
|
|
52
|
+
cached_at INTEGER NOT NULL,
|
|
53
|
+
UNIQUE(account_id, folder_id, uid)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_messages_folder_date
|
|
57
|
+
ON messages(account_id, folder_id, date DESC);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_messages_message_id
|
|
60
|
+
ON messages(message_id);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS queue (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
65
|
+
created_at INTEGER NOT NULL,
|
|
66
|
+
send_after INTEGER NOT NULL,
|
|
67
|
+
attempts INTEGER DEFAULT 0,
|
|
68
|
+
last_attempt INTEGER DEFAULT 0,
|
|
69
|
+
error TEXT,
|
|
70
|
+
from_account TEXT NOT NULL,
|
|
71
|
+
to_json TEXT NOT NULL,
|
|
72
|
+
cc_json TEXT DEFAULT '[]',
|
|
73
|
+
bcc_json TEXT DEFAULT '[]',
|
|
74
|
+
subject TEXT DEFAULT '',
|
|
75
|
+
body_html TEXT DEFAULT '',
|
|
76
|
+
body_text TEXT DEFAULT '',
|
|
77
|
+
in_reply_to TEXT,
|
|
78
|
+
refs TEXT
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
source TEXT NOT NULL DEFAULT 'sent',
|
|
84
|
+
google_id TEXT,
|
|
85
|
+
name TEXT DEFAULT '',
|
|
86
|
+
email TEXT NOT NULL,
|
|
87
|
+
organization TEXT DEFAULT '',
|
|
88
|
+
last_used INTEGER DEFAULT 0,
|
|
89
|
+
use_count INTEGER DEFAULT 0,
|
|
90
|
+
updated_at INTEGER NOT NULL,
|
|
91
|
+
UNIQUE(email)
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
|
96
|
+
|
|
97
|
+
CREATE TABLE IF NOT EXISTS sync_actions (
|
|
98
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
99
|
+
account_id TEXT NOT NULL,
|
|
100
|
+
action TEXT NOT NULL,
|
|
101
|
+
uid INTEGER,
|
|
102
|
+
folder_id INTEGER,
|
|
103
|
+
target_folder_id INTEGER,
|
|
104
|
+
flags_json TEXT,
|
|
105
|
+
raw_message TEXT,
|
|
106
|
+
created_at INTEGER NOT NULL,
|
|
107
|
+
attempts INTEGER DEFAULT 0,
|
|
108
|
+
last_error TEXT,
|
|
109
|
+
UNIQUE(account_id, action, uid, folder_id)
|
|
110
|
+
);
|
|
111
|
+
`;
|
|
112
|
+
const IDB_NAME = "mailx-sqldb";
|
|
113
|
+
const IDB_STORE = "database";
|
|
114
|
+
const IDB_KEY = "mailx.db";
|
|
115
|
+
// ── IndexedDB persistence ──
|
|
116
|
+
function openIdb() {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const req = indexedDB.open(IDB_NAME, 1);
|
|
119
|
+
req.onupgradeneeded = () => {
|
|
120
|
+
const db = req.result;
|
|
121
|
+
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
|
122
|
+
db.createObjectStore(IDB_STORE);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
req.onsuccess = () => resolve(req.result);
|
|
126
|
+
req.onerror = () => reject(req.error);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async function loadDbFromIdb() {
|
|
130
|
+
const idb = await openIdb();
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const tx = idb.transaction(IDB_STORE, "readonly");
|
|
133
|
+
const req = tx.objectStore(IDB_STORE).get(IDB_KEY);
|
|
134
|
+
req.onsuccess = () => resolve(req.result ? new Uint8Array(req.result) : null);
|
|
135
|
+
req.onerror = () => reject(req.error);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async function saveDbToIdb(data) {
|
|
139
|
+
const idb = await openIdb();
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const tx = idb.transaction(IDB_STORE, "readwrite");
|
|
142
|
+
tx.objectStore(IDB_STORE).put(data.buffer, IDB_KEY);
|
|
143
|
+
tx.oncomplete = () => resolve();
|
|
144
|
+
tx.onerror = () => reject(tx.error);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async function clearIdb() {
|
|
148
|
+
const idb = await openIdb();
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const tx = idb.transaction(IDB_STORE, "readwrite");
|
|
151
|
+
tx.objectStore(IDB_STORE).delete(IDB_KEY);
|
|
152
|
+
tx.oncomplete = () => resolve();
|
|
153
|
+
tx.onerror = () => reject(tx.error);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// ── Helper: convert sql.js result to array of objects ──
|
|
157
|
+
function resultToRows(result) {
|
|
158
|
+
if (!result || result.length === 0)
|
|
159
|
+
return [];
|
|
160
|
+
const { columns, values } = result[0];
|
|
161
|
+
return values.map((row) => {
|
|
162
|
+
const obj = {};
|
|
163
|
+
columns.forEach((col, i) => { obj[col] = row[i]; });
|
|
164
|
+
return obj;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
export class WebMailxDB {
|
|
168
|
+
db = null;
|
|
169
|
+
ready;
|
|
170
|
+
saveTimer = null;
|
|
171
|
+
constructor(dbName = "mailx") {
|
|
172
|
+
this.ready = this.init(dbName);
|
|
173
|
+
}
|
|
174
|
+
async init(_dbName) {
|
|
175
|
+
const SQL = await initSqlJs({
|
|
176
|
+
locateFile: (file) => `https://sql.js.org/dist/${file}`
|
|
177
|
+
});
|
|
178
|
+
// Try to load existing DB from IndexedDB
|
|
179
|
+
const existing = await loadDbFromIdb();
|
|
180
|
+
if (existing) {
|
|
181
|
+
this.db = new SQL.Database(existing);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
this.db = new SQL.Database();
|
|
185
|
+
}
|
|
186
|
+
this.db.run("PRAGMA foreign_keys = ON");
|
|
187
|
+
this.db.run(SCHEMA);
|
|
188
|
+
this.scheduleSave();
|
|
189
|
+
}
|
|
190
|
+
/** Wait for DB initialization */
|
|
191
|
+
async waitReady() {
|
|
192
|
+
await this.ready;
|
|
193
|
+
}
|
|
194
|
+
/** Persist DB to IndexedDB (debounced — batches rapid writes) */
|
|
195
|
+
scheduleSave() {
|
|
196
|
+
if (this.saveTimer)
|
|
197
|
+
return;
|
|
198
|
+
this.saveTimer = setTimeout(async () => {
|
|
199
|
+
this.saveTimer = null;
|
|
200
|
+
if (this.db) {
|
|
201
|
+
const data = this.db.export();
|
|
202
|
+
await saveDbToIdb(data);
|
|
203
|
+
}
|
|
204
|
+
}, 1000);
|
|
205
|
+
}
|
|
206
|
+
/** Force immediate persist */
|
|
207
|
+
async flush() {
|
|
208
|
+
if (this.saveTimer) {
|
|
209
|
+
clearTimeout(this.saveTimer);
|
|
210
|
+
this.saveTimer = null;
|
|
211
|
+
}
|
|
212
|
+
if (this.db) {
|
|
213
|
+
const data = this.db.export();
|
|
214
|
+
await saveDbToIdb(data);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
close() {
|
|
218
|
+
if (this.db) {
|
|
219
|
+
this.flush();
|
|
220
|
+
this.db.close();
|
|
221
|
+
this.db = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
run(sql, params) {
|
|
225
|
+
this.db.run(sql, params);
|
|
226
|
+
this.scheduleSave();
|
|
227
|
+
}
|
|
228
|
+
get(sql, params) {
|
|
229
|
+
const result = this.db.exec(sql, params);
|
|
230
|
+
const rows = resultToRows(result);
|
|
231
|
+
return rows[0] || null;
|
|
232
|
+
}
|
|
233
|
+
all(sql, params) {
|
|
234
|
+
const result = this.db.exec(sql, params);
|
|
235
|
+
return resultToRows(result);
|
|
236
|
+
}
|
|
237
|
+
lastId() {
|
|
238
|
+
const row = this.get("SELECT last_insert_rowid() as id");
|
|
239
|
+
return row?.id || 0;
|
|
240
|
+
}
|
|
241
|
+
// ── Accounts ──
|
|
242
|
+
upsertAccount(id, name, email, configJson) {
|
|
243
|
+
this.run(`INSERT INTO accounts (id, name, email, config_json)
|
|
244
|
+
VALUES (?, ?, ?, ?)
|
|
245
|
+
ON CONFLICT(id) DO UPDATE SET name=?, email=?, config_json=?`, [id, name, email, configJson, name, email, configJson]);
|
|
246
|
+
}
|
|
247
|
+
getAccounts() {
|
|
248
|
+
return this.all("SELECT id, name, email, last_sync as lastSync FROM accounts");
|
|
249
|
+
}
|
|
250
|
+
getAccountConfigs() {
|
|
251
|
+
return this.all("SELECT id, name, email, config_json as configJson FROM accounts");
|
|
252
|
+
}
|
|
253
|
+
updateLastSync(accountId, timestamp) {
|
|
254
|
+
this.run("UPDATE accounts SET last_sync = ? WHERE id = ?", [timestamp, accountId]);
|
|
255
|
+
}
|
|
256
|
+
// ── Folders ──
|
|
257
|
+
upsertFolder(accountId, folderPath, name, specialUse, delimiter) {
|
|
258
|
+
const existing = this.get("SELECT id FROM folders WHERE account_id = ? AND path = ?", [accountId, folderPath]);
|
|
259
|
+
if (existing) {
|
|
260
|
+
this.run("UPDATE folders SET name = ?, special_use = ?, delimiter = ? WHERE id = ?", [name, specialUse, delimiter, existing.id]);
|
|
261
|
+
return existing.id;
|
|
262
|
+
}
|
|
263
|
+
this.run("INSERT INTO folders (account_id, path, name, special_use, delimiter) VALUES (?, ?, ?, ?, ?)", [accountId, folderPath, name, specialUse, delimiter]);
|
|
264
|
+
return this.lastId();
|
|
265
|
+
}
|
|
266
|
+
getFolders(accountId) {
|
|
267
|
+
const rows = this.all("SELECT * FROM folders WHERE account_id = ? ORDER BY path", [accountId]);
|
|
268
|
+
return rows.map(r => ({
|
|
269
|
+
id: r.id, accountId: r.account_id, path: r.path, name: r.name,
|
|
270
|
+
specialUse: r.special_use, delimiter: r.delimiter,
|
|
271
|
+
totalCount: r.total_count, unreadCount: r.unread_count,
|
|
272
|
+
children: []
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
deleteFolder(folderId) {
|
|
276
|
+
this.run("DELETE FROM messages WHERE folder_id = ?", [folderId]);
|
|
277
|
+
this.run("DELETE FROM folders WHERE id = ?", [folderId]);
|
|
278
|
+
}
|
|
279
|
+
markFolderRead(folderId) {
|
|
280
|
+
this.run(`UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]')
|
|
281
|
+
WHERE folder_id = ? AND flags_json NOT LIKE '%\\\\Seen%'`, [folderId]);
|
|
282
|
+
this.recalcFolderCounts(folderId);
|
|
283
|
+
}
|
|
284
|
+
deleteAllMessages(accountId, folderId) {
|
|
285
|
+
this.run("DELETE FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
|
|
286
|
+
this.recalcFolderCounts(folderId);
|
|
287
|
+
}
|
|
288
|
+
updateFolderCounts(folderId, total, unread) {
|
|
289
|
+
this.run("UPDATE folders SET total_count = ?, unread_count = ? WHERE id = ?", [total, unread, folderId]);
|
|
290
|
+
}
|
|
291
|
+
updateFolderSync(folderId, uidvalidity, highestModseq) {
|
|
292
|
+
this.run("UPDATE folders SET uidvalidity = ?, highest_modseq = ? WHERE id = ?", [uidvalidity, highestModseq, folderId]);
|
|
293
|
+
}
|
|
294
|
+
getFolderSync(folderId) {
|
|
295
|
+
const row = this.get("SELECT uidvalidity, highest_modseq as highestModseq FROM folders WHERE id = ?", [folderId]);
|
|
296
|
+
return row || { uidvalidity: 0, highestModseq: "0" };
|
|
297
|
+
}
|
|
298
|
+
// ── Messages ──
|
|
299
|
+
upsertMessage(msg) {
|
|
300
|
+
const existing = this.get("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?", [msg.accountId, msg.folderId, msg.uid]);
|
|
301
|
+
if (existing) {
|
|
302
|
+
this.run(`UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ? WHERE id = ?`, [JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id]);
|
|
303
|
+
return existing.id;
|
|
304
|
+
}
|
|
305
|
+
this.run(`INSERT INTO messages (
|
|
306
|
+
account_id, folder_id, uid, message_id, in_reply_to, refs,
|
|
307
|
+
date, subject, from_address, from_name, to_json, cc_json,
|
|
308
|
+
flags_json, size, has_attachments, preview, body_path, cached_at
|
|
309
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
310
|
+
msg.accountId, msg.folderId, msg.uid, msg.messageId,
|
|
311
|
+
msg.inReplyTo, JSON.stringify(msg.references),
|
|
312
|
+
msg.date, msg.subject, msg.from.address, msg.from.name,
|
|
313
|
+
JSON.stringify(msg.to), JSON.stringify(msg.cc),
|
|
314
|
+
JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0,
|
|
315
|
+
msg.preview, msg.bodyPath, Date.now()
|
|
316
|
+
]);
|
|
317
|
+
return this.lastId();
|
|
318
|
+
}
|
|
319
|
+
getMessages(query) {
|
|
320
|
+
const page = query.page || 1;
|
|
321
|
+
const pageSize = query.pageSize || 50;
|
|
322
|
+
const offset = (page - 1) * pageSize;
|
|
323
|
+
const sortCol = query.sort === "from" ? "from_name" : query.sort === "subject" ? "subject" : "date";
|
|
324
|
+
const sortDir = query.sortDir || "desc";
|
|
325
|
+
let where = "account_id = ? AND folder_id = ?";
|
|
326
|
+
const params = [query.accountId, query.folderId];
|
|
327
|
+
if (query.search) {
|
|
328
|
+
where += " AND (subject LIKE ? OR from_name LIKE ? OR from_address LIKE ?)";
|
|
329
|
+
const term = `%${query.search}%`;
|
|
330
|
+
params.push(term, term, term);
|
|
331
|
+
}
|
|
332
|
+
const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, params);
|
|
333
|
+
const total = countRow?.cnt || 0;
|
|
334
|
+
const rows = this.all(`SELECT * FROM messages WHERE ${where} ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
|
|
335
|
+
return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
|
|
336
|
+
}
|
|
337
|
+
getUnifiedInbox(page = 1, pageSize = 50) {
|
|
338
|
+
const offset = (page - 1) * pageSize;
|
|
339
|
+
const inboxRows = this.all("SELECT id FROM folders WHERE special_use = 'inbox'");
|
|
340
|
+
if (inboxRows.length === 0)
|
|
341
|
+
return { items: [], total: 0, page, pageSize };
|
|
342
|
+
const ids = inboxRows.map(r => r.id);
|
|
343
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
344
|
+
const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE folder_id IN (${placeholders})`, ids);
|
|
345
|
+
const total = countRow?.cnt || 0;
|
|
346
|
+
const rows = this.all(`SELECT * FROM messages WHERE folder_id IN (${placeholders}) ORDER BY date DESC LIMIT ? OFFSET ?`, [...ids, pageSize, offset]);
|
|
347
|
+
return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
|
|
348
|
+
}
|
|
349
|
+
getMessageByUid(accountId, uid, folderId) {
|
|
350
|
+
const sql = folderId != null
|
|
351
|
+
? "SELECT * FROM messages WHERE account_id = ? AND uid = ? AND folder_id = ?"
|
|
352
|
+
: "SELECT * FROM messages WHERE account_id = ? AND uid = ?";
|
|
353
|
+
const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
|
|
354
|
+
const r = this.get(sql, params);
|
|
355
|
+
if (!r)
|
|
356
|
+
return null;
|
|
357
|
+
return this.rowToEnvelope(r);
|
|
358
|
+
}
|
|
359
|
+
getMessageBodyPath(accountId, uid) {
|
|
360
|
+
const r = this.get("SELECT body_path FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
|
|
361
|
+
return r?.body_path || "";
|
|
362
|
+
}
|
|
363
|
+
updateMessageFlags(accountId, uid, flags) {
|
|
364
|
+
this.run("UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?", [JSON.stringify(flags), accountId, uid]);
|
|
365
|
+
}
|
|
366
|
+
updateBodyPath(accountId, uid, bodyPath) {
|
|
367
|
+
this.run("UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?", [bodyPath, accountId, uid]);
|
|
368
|
+
}
|
|
369
|
+
getMessagesWithoutBody(accountId, limit = 50) {
|
|
370
|
+
return this.all("SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path = '') ORDER BY date DESC LIMIT ?", [accountId, limit]);
|
|
371
|
+
}
|
|
372
|
+
getHighestUid(accountId, folderId) {
|
|
373
|
+
const r = this.get("SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
|
|
374
|
+
return r?.maxUid || 0;
|
|
375
|
+
}
|
|
376
|
+
getOldestDate(accountId, folderId) {
|
|
377
|
+
const r = this.get("SELECT MIN(date) as minDate FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
|
|
378
|
+
return r?.minDate || 0;
|
|
379
|
+
}
|
|
380
|
+
getMessageCount(accountId, folderId) {
|
|
381
|
+
const r = this.get("SELECT count(*) as cnt FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
|
|
382
|
+
return r?.cnt || 0;
|
|
383
|
+
}
|
|
384
|
+
getUidsForFolder(accountId, folderId) {
|
|
385
|
+
return this.all("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]).map(r => r.uid);
|
|
386
|
+
}
|
|
387
|
+
deleteMessage(accountId, uid) {
|
|
388
|
+
const msg = this.get("SELECT folder_id FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
|
|
389
|
+
this.run("DELETE FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
|
|
390
|
+
if (msg)
|
|
391
|
+
this.recalcFolderCounts(msg.folder_id);
|
|
392
|
+
}
|
|
393
|
+
recalcFolderCounts(folderId) {
|
|
394
|
+
const counts = this.get(`SELECT COUNT(*) as total,
|
|
395
|
+
SUM(CASE WHEN flags_json NOT LIKE '%\\\\Seen%' THEN 1 ELSE 0 END) as unread
|
|
396
|
+
FROM messages WHERE folder_id = ?`, [folderId]);
|
|
397
|
+
this.updateFolderCounts(folderId, counts?.total || 0, counts?.unread || 0);
|
|
398
|
+
}
|
|
399
|
+
beginTransaction() { this.db.run("BEGIN"); }
|
|
400
|
+
commitTransaction() { this.db.run("COMMIT"); this.scheduleSave(); }
|
|
401
|
+
rollbackTransaction() { this.db.run("ROLLBACK"); }
|
|
402
|
+
// ── Contacts ──
|
|
403
|
+
recordSentAddress(name, email) {
|
|
404
|
+
const now = Date.now();
|
|
405
|
+
const existing = this.get("SELECT id FROM contacts WHERE email = ?", [email]);
|
|
406
|
+
if (existing) {
|
|
407
|
+
this.run("UPDATE contacts SET name = CASE WHEN ? != '' THEN ? ELSE name END, last_used = ?, use_count = use_count + 1, updated_at = ? WHERE email = ?", [name, name, now, now, email]);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
this.run("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('sent', ?, ?, ?, 1, ?)", [name, email, now, now]);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
seedContactsFromMessages() {
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
const rows = this.all(`SELECT from_name, from_address, COUNT(*) as cnt, MAX(date) as last
|
|
416
|
+
FROM messages WHERE from_address != '' GROUP BY from_address`);
|
|
417
|
+
let added = 0;
|
|
418
|
+
for (const r of rows) {
|
|
419
|
+
const existing = this.get("SELECT id FROM contacts WHERE email = ?", [r.from_address]);
|
|
420
|
+
if (!existing) {
|
|
421
|
+
this.run("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)", [r.from_name || "", r.from_address, r.last, r.cnt, now]);
|
|
422
|
+
added++;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return added;
|
|
426
|
+
}
|
|
427
|
+
searchContacts(query, limit = 10) {
|
|
428
|
+
const q = `%${query}%`;
|
|
429
|
+
return this.all(`SELECT name, email, source, use_count as useCount FROM contacts
|
|
430
|
+
WHERE email LIKE ? OR name LIKE ?
|
|
431
|
+
ORDER BY use_count DESC, last_used DESC LIMIT ?`, [q, q, limit]);
|
|
432
|
+
}
|
|
433
|
+
// ── Search ──
|
|
434
|
+
searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
|
|
435
|
+
const offset = (page - 1) * pageSize;
|
|
436
|
+
const term = `%${query}%`;
|
|
437
|
+
let where = "(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)";
|
|
438
|
+
const params = [term, term, term, term];
|
|
439
|
+
if (accountId && folderId) {
|
|
440
|
+
where += " AND account_id = ? AND folder_id = ?";
|
|
441
|
+
params.push(accountId, folderId);
|
|
442
|
+
}
|
|
443
|
+
else if (accountId) {
|
|
444
|
+
where += " AND account_id = ?";
|
|
445
|
+
params.push(accountId);
|
|
446
|
+
}
|
|
447
|
+
const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, params);
|
|
448
|
+
const total = countRow?.cnt || 0;
|
|
449
|
+
const rows = this.all(`SELECT * FROM messages WHERE ${where} ORDER BY date DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
|
|
450
|
+
return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
|
|
451
|
+
}
|
|
452
|
+
// ── Sync Actions ──
|
|
453
|
+
queueSyncAction(accountId, action, uid, folderId, extra) {
|
|
454
|
+
try {
|
|
455
|
+
this.run(`INSERT OR REPLACE INTO sync_actions (account_id, action, uid, folder_id, target_folder_id, flags_json, raw_message, created_at)
|
|
456
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [accountId, action, uid, folderId,
|
|
457
|
+
extra?.targetFolderId || null,
|
|
458
|
+
extra?.flags ? JSON.stringify(extra.flags) : null,
|
|
459
|
+
extra?.rawMessage || null,
|
|
460
|
+
Date.now()]);
|
|
461
|
+
}
|
|
462
|
+
catch { /* UNIQUE constraint */ }
|
|
463
|
+
}
|
|
464
|
+
getPendingSyncActions(accountId) {
|
|
465
|
+
const rows = this.all("SELECT * FROM sync_actions WHERE account_id = ? ORDER BY created_at", [accountId]);
|
|
466
|
+
return rows.map(r => ({
|
|
467
|
+
id: r.id, action: r.action, uid: r.uid, folderId: r.folder_id,
|
|
468
|
+
targetFolderId: r.target_folder_id,
|
|
469
|
+
flags: r.flags_json ? JSON.parse(r.flags_json) : [],
|
|
470
|
+
rawMessage: r.raw_message, attempts: r.attempts,
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
completeSyncAction(id) { this.run("DELETE FROM sync_actions WHERE id = ?", [id]); }
|
|
474
|
+
failSyncAction(id, error) {
|
|
475
|
+
this.run("UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE id = ?", [error, id]);
|
|
476
|
+
}
|
|
477
|
+
getPendingSyncCount(accountId) {
|
|
478
|
+
const r = this.get("SELECT COUNT(*) as cnt FROM sync_actions WHERE account_id = ?", [accountId]);
|
|
479
|
+
return r?.cnt || 0;
|
|
480
|
+
}
|
|
481
|
+
getTotalPendingSyncCount() {
|
|
482
|
+
const r = this.get("SELECT COUNT(*) as cnt FROM sync_actions");
|
|
483
|
+
return r?.cnt || 0;
|
|
484
|
+
}
|
|
485
|
+
/** Reset the entire database */
|
|
486
|
+
async resetStore() {
|
|
487
|
+
this.run("DELETE FROM sync_actions");
|
|
488
|
+
this.run("DELETE FROM contacts");
|
|
489
|
+
this.run("DELETE FROM messages");
|
|
490
|
+
this.run("DELETE FROM folders");
|
|
491
|
+
this.run("DELETE FROM accounts");
|
|
492
|
+
this.run("DELETE FROM queue");
|
|
493
|
+
await clearIdb();
|
|
494
|
+
}
|
|
495
|
+
// ── Helpers ──
|
|
496
|
+
rowToEnvelope(r) {
|
|
497
|
+
return {
|
|
498
|
+
id: r.id, accountId: r.account_id, folderId: r.folder_id,
|
|
499
|
+
uid: r.uid, messageId: r.message_id || "", inReplyTo: r.in_reply_to || "",
|
|
500
|
+
references: JSON.parse(r.refs || "[]"), date: r.date, subject: r.subject,
|
|
501
|
+
from: { name: r.from_name, address: r.from_address },
|
|
502
|
+
to: JSON.parse(r.to_json), cc: JSON.parse(r.cc_json),
|
|
503
|
+
flags: JSON.parse(r.flags_json), size: r.size,
|
|
504
|
+
hasAttachments: !!r.has_attachments, preview: r.preview
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
//# sourceMappingURL=db.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-compatible Gmail API provider.
|
|
3
|
+
* Identical to packages/mailx-imap/providers/gmail-api.ts but uses
|
|
4
|
+
* atob() instead of Buffer.from() for base64 decoding (no Node.js deps).
|
|
5
|
+
*
|
|
6
|
+
* This file is a standalone copy — not imported from mailx-imap — because
|
|
7
|
+
* mailx-imap depends on Node.js modules (node:events, node:fs, mailparser, etc.)
|
|
8
|
+
* that aren't available in a WebView.
|
|
9
|
+
*/
|
|
10
|
+
import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./provider-types.js";
|
|
11
|
+
export declare class GmailApiWebProvider implements MailProvider {
|
|
12
|
+
private tokenProvider;
|
|
13
|
+
constructor(tokenProvider: () => Promise<string>);
|
|
14
|
+
private apiFetch;
|
|
15
|
+
listFolders(): Promise<ProviderFolder[]>;
|
|
16
|
+
private listMessageIds;
|
|
17
|
+
private batchFetch;
|
|
18
|
+
private parseMessage;
|
|
19
|
+
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
20
|
+
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
21
|
+
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
22
|
+
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
23
|
+
getUids(folder: string): Promise<number[]>;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
private folderToLabel;
|
|
26
|
+
private formatDate;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=gmail-api-web.d.ts.map
|