@bobfrankston/mailx 1.0.178 → 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,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-compatible MailxService for Android/browser.
|
|
3
|
+
* Replaces @bobfrankston/mailx-service which depends on Node.js (fs, dns, mailparser).
|
|
4
|
+
*
|
|
5
|
+
* Key differences from desktop:
|
|
6
|
+
* - Uses WebMailxDB (wa-sqlite) instead of MailxDB (node:sqlite)
|
|
7
|
+
* - Uses WebMessageStore (IndexedDB) instead of FileMessageStore (filesystem)
|
|
8
|
+
* - Uses postal-mime or manual header parsing instead of mailparser's simpleParser
|
|
9
|
+
* - Settings via IndexedDB + GDrive API instead of filesystem
|
|
10
|
+
* - No dns.resolveMx — provider detection is static (Gmail/Outlook/Yahoo/iCloud)
|
|
11
|
+
*/
|
|
12
|
+
import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo } from "./web-settings.js";
|
|
13
|
+
// ── HTML sanitizer (same logic as desktop) ──
|
|
14
|
+
export function sanitizeHtml(html) {
|
|
15
|
+
let hasRemoteContent = false;
|
|
16
|
+
let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
|
|
17
|
+
clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
18
|
+
clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
|
|
19
|
+
const url = src.slice(1, -1);
|
|
20
|
+
if (url.startsWith("data:") || url.startsWith("cid:"))
|
|
21
|
+
return match;
|
|
22
|
+
hasRemoteContent = true;
|
|
23
|
+
return `<img${before}src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect fill='%23888' width='20' height='20' rx='3'/%3E%3Ctext x='10' y='14' text-anchor='middle' fill='white' font-size='12'%3E⊘%3C/text%3E%3C/svg%3E" data-blocked-src=${src} title="Remote image blocked"`;
|
|
24
|
+
});
|
|
25
|
+
clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
|
|
26
|
+
hasRemoteContent = true;
|
|
27
|
+
return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
|
|
28
|
+
});
|
|
29
|
+
clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (_match, _q, url) => {
|
|
30
|
+
hasRemoteContent = true;
|
|
31
|
+
return `url("") /* blocked: ${url} */`;
|
|
32
|
+
});
|
|
33
|
+
clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
|
|
34
|
+
clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
|
|
35
|
+
return { html: clean, hasRemoteContent };
|
|
36
|
+
}
|
|
37
|
+
/** Parse an RFC 2822 message from raw bytes. Handles basic MIME. */
|
|
38
|
+
function parseEmailSource(raw) {
|
|
39
|
+
const headers = new Map();
|
|
40
|
+
const headerEnd = raw.indexOf("\r\n\r\n");
|
|
41
|
+
const headerSection = headerEnd >= 0 ? raw.substring(0, headerEnd) : raw;
|
|
42
|
+
const body = headerEnd >= 0 ? raw.substring(headerEnd + 4) : "";
|
|
43
|
+
// Parse headers (handle continuations)
|
|
44
|
+
const headerLines = headerSection.split("\r\n");
|
|
45
|
+
let lastKey = "";
|
|
46
|
+
for (const line of headerLines) {
|
|
47
|
+
if (line.startsWith(" ") || line.startsWith("\t")) {
|
|
48
|
+
// Continuation
|
|
49
|
+
if (lastKey) {
|
|
50
|
+
headers.set(lastKey, (headers.get(lastKey) || "") + " " + line.trim());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const colon = line.indexOf(":");
|
|
55
|
+
if (colon > 0) {
|
|
56
|
+
lastKey = line.substring(0, colon).toLowerCase().trim();
|
|
57
|
+
headers.set(lastKey, line.substring(colon + 1).trim());
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const contentType = headers.get("content-type") || "text/plain";
|
|
62
|
+
const transferEncoding = (headers.get("content-transfer-encoding") || "").toLowerCase();
|
|
63
|
+
const attachments = [];
|
|
64
|
+
// Check for multipart
|
|
65
|
+
const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/i);
|
|
66
|
+
if (boundaryMatch) {
|
|
67
|
+
const boundary = boundaryMatch[1];
|
|
68
|
+
return parseMimeParts(body, boundary, headers);
|
|
69
|
+
}
|
|
70
|
+
// Single part
|
|
71
|
+
let decoded = decodeBody(body, transferEncoding);
|
|
72
|
+
const isHtml = contentType.includes("text/html");
|
|
73
|
+
return {
|
|
74
|
+
html: isHtml ? decoded : "",
|
|
75
|
+
text: isHtml ? "" : decoded,
|
|
76
|
+
headers,
|
|
77
|
+
attachments,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function parseMimeParts(body, boundary, topHeaders) {
|
|
81
|
+
const parts = body.split("--" + boundary);
|
|
82
|
+
let html = "";
|
|
83
|
+
let text = "";
|
|
84
|
+
const attachments = [];
|
|
85
|
+
for (let i = 1; i < parts.length; i++) {
|
|
86
|
+
const part = parts[i];
|
|
87
|
+
if (part.startsWith("--"))
|
|
88
|
+
break; // End marker
|
|
89
|
+
const partHeaderEnd = part.indexOf("\r\n\r\n");
|
|
90
|
+
if (partHeaderEnd < 0)
|
|
91
|
+
continue;
|
|
92
|
+
const partHeaderSection = part.substring(0, partHeaderEnd);
|
|
93
|
+
const partBody = part.substring(partHeaderEnd + 4).replace(/\r?\n$/, "");
|
|
94
|
+
// Parse part headers
|
|
95
|
+
const partHeaders = new Map();
|
|
96
|
+
const partHeaderLines = partHeaderSection.split("\r\n");
|
|
97
|
+
let lastKey = "";
|
|
98
|
+
for (const line of partHeaderLines) {
|
|
99
|
+
if (line.startsWith(" ") || line.startsWith("\t")) {
|
|
100
|
+
if (lastKey)
|
|
101
|
+
partHeaders.set(lastKey, (partHeaders.get(lastKey) || "") + " " + line.trim());
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const colon = line.indexOf(":");
|
|
105
|
+
if (colon > 0) {
|
|
106
|
+
lastKey = line.substring(0, colon).toLowerCase().trim();
|
|
107
|
+
partHeaders.set(lastKey, line.substring(colon + 1).trim());
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const partType = partHeaders.get("content-type") || "text/plain";
|
|
112
|
+
const partEncoding = (partHeaders.get("content-transfer-encoding") || "").toLowerCase();
|
|
113
|
+
const disposition = partHeaders.get("content-disposition") || "";
|
|
114
|
+
// Nested multipart
|
|
115
|
+
const nestedBoundary = partType.match(/boundary="?([^";\s]+)"?/i);
|
|
116
|
+
if (nestedBoundary) {
|
|
117
|
+
const nested = parseMimeParts(partBody, nestedBoundary[1], topHeaders);
|
|
118
|
+
if (!html && nested.html)
|
|
119
|
+
html = nested.html;
|
|
120
|
+
if (!text && nested.text)
|
|
121
|
+
text = nested.text;
|
|
122
|
+
attachments.push(...nested.attachments);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (disposition.includes("attachment") || (partType.includes("application/") && !partType.includes("text/"))) {
|
|
126
|
+
const filenameMatch = disposition.match(/filename="?([^";\r\n]+)"?/i) || partType.match(/name="?([^";\r\n]+)"?/i);
|
|
127
|
+
const decoded = decodeBody(partBody, partEncoding);
|
|
128
|
+
attachments.push({
|
|
129
|
+
filename: filenameMatch?.[1]?.trim() || `attachment-${attachments.length}`,
|
|
130
|
+
contentType: partType.split(";")[0].trim(),
|
|
131
|
+
size: decoded.length,
|
|
132
|
+
contentId: (partHeaders.get("content-id") || "").replace(/[<>]/g, ""),
|
|
133
|
+
content: new TextEncoder().encode(decoded),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else if (partType.includes("text/html")) {
|
|
137
|
+
html = decodeBody(partBody, partEncoding);
|
|
138
|
+
}
|
|
139
|
+
else if (partType.includes("text/plain")) {
|
|
140
|
+
text = decodeBody(partBody, partEncoding);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { html, text, headers: topHeaders, attachments };
|
|
144
|
+
}
|
|
145
|
+
function decodeBody(body, encoding) {
|
|
146
|
+
if (encoding === "base64") {
|
|
147
|
+
try {
|
|
148
|
+
return atob(body.replace(/\s/g, ""));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return body;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (encoding === "quoted-printable") {
|
|
155
|
+
return body
|
|
156
|
+
.replace(/=\r?\n/g, "")
|
|
157
|
+
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
158
|
+
}
|
|
159
|
+
return body;
|
|
160
|
+
}
|
|
161
|
+
// ── Quoted-printable encoding (for compose/send) ──
|
|
162
|
+
function encodeQuotedPrintable(text) {
|
|
163
|
+
const encoder = new TextEncoder();
|
|
164
|
+
const bytes = encoder.encode(text);
|
|
165
|
+
let line = "";
|
|
166
|
+
let result = "";
|
|
167
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
168
|
+
const b = bytes[i];
|
|
169
|
+
let encoded;
|
|
170
|
+
if (b === 0x0D && bytes[i + 1] === 0x0A) {
|
|
171
|
+
result += line + "\r\n";
|
|
172
|
+
line = "";
|
|
173
|
+
i++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
else if (b === 0x0A) {
|
|
177
|
+
result += line + "\r\n";
|
|
178
|
+
line = "";
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {
|
|
182
|
+
encoded = String.fromCharCode(b);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
encoded = "=" + b.toString(16).toUpperCase().padStart(2, "0");
|
|
186
|
+
}
|
|
187
|
+
if (line.length + encoded.length > 75) {
|
|
188
|
+
result += line + "=\r\n";
|
|
189
|
+
line = "";
|
|
190
|
+
}
|
|
191
|
+
line += encoded;
|
|
192
|
+
}
|
|
193
|
+
result += line;
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
// ── Service ──
|
|
197
|
+
export class WebMailxService {
|
|
198
|
+
db;
|
|
199
|
+
bodyStore;
|
|
200
|
+
syncManager;
|
|
201
|
+
constructor(db, bodyStore, syncManager) {
|
|
202
|
+
this.db = db;
|
|
203
|
+
this.bodyStore = bodyStore;
|
|
204
|
+
this.syncManager = syncManager;
|
|
205
|
+
}
|
|
206
|
+
// ── Accounts ──
|
|
207
|
+
async getAccounts() {
|
|
208
|
+
const dbAccounts = this.db.getAccounts();
|
|
209
|
+
const settings = await loadSettings();
|
|
210
|
+
const ordered = [];
|
|
211
|
+
for (const cfg of settings.accounts) {
|
|
212
|
+
const a = dbAccounts.find(d => d.id === cfg.id);
|
|
213
|
+
if (a)
|
|
214
|
+
ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false });
|
|
215
|
+
}
|
|
216
|
+
for (const a of dbAccounts) {
|
|
217
|
+
if (!ordered.find((o) => o.id === a.id))
|
|
218
|
+
ordered.push(a);
|
|
219
|
+
}
|
|
220
|
+
return ordered;
|
|
221
|
+
}
|
|
222
|
+
// ── Folders ──
|
|
223
|
+
getFolders(accountId) {
|
|
224
|
+
return this.db.getFolders(accountId);
|
|
225
|
+
}
|
|
226
|
+
// ── Messages ──
|
|
227
|
+
getUnifiedInbox(page = 1, pageSize = 50) {
|
|
228
|
+
return this.db.getUnifiedInbox(page, pageSize);
|
|
229
|
+
}
|
|
230
|
+
getMessages(accountId, folderId, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search) {
|
|
231
|
+
return this.db.getMessages({ accountId, folderId, page, pageSize, sort: sort, sortDir: sortDir, search });
|
|
232
|
+
}
|
|
233
|
+
async getMessage(accountId, uid, allowRemote = false, folderId) {
|
|
234
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
235
|
+
if (!envelope)
|
|
236
|
+
throw new Error("Message not found");
|
|
237
|
+
let bodyHtml = "";
|
|
238
|
+
let bodyText = "";
|
|
239
|
+
let hasRemoteContent = false;
|
|
240
|
+
let attachments = [];
|
|
241
|
+
let raw = null;
|
|
242
|
+
try {
|
|
243
|
+
raw = await this.syncManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
|
|
244
|
+
}
|
|
245
|
+
catch (fetchErr) {
|
|
246
|
+
return {
|
|
247
|
+
...envelope, bodyHtml: "", bodyText: `[Message body unavailable: ${fetchErr.message || "fetch failed"}]`,
|
|
248
|
+
hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (!raw) {
|
|
252
|
+
bodyText = "[Message body not available. Try again or re-sync the folder.]";
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const source = new TextDecoder().decode(raw);
|
|
256
|
+
const parsed = parseEmailSource(source);
|
|
257
|
+
bodyHtml = parsed.html || "";
|
|
258
|
+
bodyText = parsed.text || "";
|
|
259
|
+
attachments = (parsed.attachments || []).map((a, i) => ({
|
|
260
|
+
id: i,
|
|
261
|
+
filename: a.filename || `attachment-${i}`,
|
|
262
|
+
mimeType: a.contentType || "application/octet-stream",
|
|
263
|
+
size: a.size || 0,
|
|
264
|
+
contentId: a.contentId || ""
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
// Sanitize HTML
|
|
268
|
+
if (bodyHtml && !allowRemote) {
|
|
269
|
+
const allowList = await loadAllowlist();
|
|
270
|
+
const senderAddr = envelope.from?.address || "";
|
|
271
|
+
const senderDomain = senderAddr.split("@")[1] || "";
|
|
272
|
+
const toAddrs = (envelope.to || []).map((a) => a.address);
|
|
273
|
+
const isAllowed = allowList.senders.includes(senderAddr) ||
|
|
274
|
+
allowList.domains.includes(senderDomain) ||
|
|
275
|
+
toAddrs.some((a) => allowList.recipients?.includes(a));
|
|
276
|
+
if (isAllowed) {
|
|
277
|
+
allowRemote = true;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
const result = sanitizeHtml(bodyHtml);
|
|
281
|
+
bodyHtml = result.html;
|
|
282
|
+
hasRemoteContent = result.hasRemoteContent;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
|
|
287
|
+
attachments, deliveredTo: "", returnPath: "", listUnsubscribe: "",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
async updateFlags(accountId, uid, flags) {
|
|
291
|
+
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
292
|
+
await this.syncManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
|
|
293
|
+
}
|
|
294
|
+
// ── Remote content allow-list ──
|
|
295
|
+
async allowRemoteContent(type, value) {
|
|
296
|
+
const list = await loadAllowlist();
|
|
297
|
+
if (type === "sender" && !list.senders.includes(value))
|
|
298
|
+
list.senders.push(value);
|
|
299
|
+
else if (type === "domain" && !list.domains.includes(value))
|
|
300
|
+
list.domains.push(value);
|
|
301
|
+
else if (type === "recipient") {
|
|
302
|
+
if (!list.recipients)
|
|
303
|
+
list.recipients = [];
|
|
304
|
+
if (!list.recipients.includes(value))
|
|
305
|
+
list.recipients.push(value);
|
|
306
|
+
}
|
|
307
|
+
await saveAllowlist(list);
|
|
308
|
+
}
|
|
309
|
+
// ── Search ──
|
|
310
|
+
async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
|
|
311
|
+
if (!q.trim())
|
|
312
|
+
return { items: [], total: 0, page, pageSize };
|
|
313
|
+
// On mobile, always use local search (no server-side IMAP search)
|
|
314
|
+
if (scope === "current" && accountId && folderId) {
|
|
315
|
+
return this.db.searchMessages(q, page, pageSize, accountId, folderId);
|
|
316
|
+
}
|
|
317
|
+
return this.db.searchMessages(q, page, pageSize);
|
|
318
|
+
}
|
|
319
|
+
// ── Sync ──
|
|
320
|
+
getSyncPending() {
|
|
321
|
+
return { pending: this.db.getTotalPendingSyncCount() };
|
|
322
|
+
}
|
|
323
|
+
async syncAll() {
|
|
324
|
+
await this.syncManager.syncAll();
|
|
325
|
+
}
|
|
326
|
+
async syncAccount(accountId) {
|
|
327
|
+
const folders = await this.syncManager.syncFolders(accountId);
|
|
328
|
+
const sorted = [...folders].sort((a, b) => {
|
|
329
|
+
if (a.specialUse === "inbox")
|
|
330
|
+
return -1;
|
|
331
|
+
if (b.specialUse === "inbox")
|
|
332
|
+
return 1;
|
|
333
|
+
return 0;
|
|
334
|
+
});
|
|
335
|
+
for (const folder of sorted) {
|
|
336
|
+
try {
|
|
337
|
+
await this.syncManager.syncFolder(accountId, folder.id);
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
console.error(` Skipping folder ${folder.path}: ${e.message}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async reauthenticate(accountId) {
|
|
345
|
+
return this.syncManager.reauthenticate(accountId);
|
|
346
|
+
}
|
|
347
|
+
// ── Send ──
|
|
348
|
+
async send(msg) {
|
|
349
|
+
const settings = await loadSettings();
|
|
350
|
+
const account = settings.accounts.find(a => a.id === msg.from);
|
|
351
|
+
if (!account)
|
|
352
|
+
throw new Error(`Unknown account: ${msg.from}`);
|
|
353
|
+
const fromHeader = msg.fromAddress || `${account.name} <${account.email}>`;
|
|
354
|
+
const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
|
|
355
|
+
const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
|
|
356
|
+
const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
|
|
357
|
+
const body = msg.bodyHtml || msg.bodyText || "";
|
|
358
|
+
const bodyEncoded = encodeQuotedPrintable(body);
|
|
359
|
+
const domain = account.email.split("@")[1] || "mailx.local";
|
|
360
|
+
const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
|
|
361
|
+
const headers = [
|
|
362
|
+
`From: ${fromHeader}`, `To: ${to}`,
|
|
363
|
+
cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
|
|
364
|
+
`Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
|
|
365
|
+
`Message-ID: ${messageId}`,
|
|
366
|
+
msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
|
|
367
|
+
msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
|
|
368
|
+
`MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
|
|
369
|
+
].filter(h => h !== null).join("\r\n");
|
|
370
|
+
const rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
|
|
371
|
+
this.syncManager.queueOutgoingLocal(account.id, rawMessage);
|
|
372
|
+
for (const addr of msg.to)
|
|
373
|
+
this.db.recordSentAddress(addr.name, addr.address);
|
|
374
|
+
if (msg.cc)
|
|
375
|
+
for (const addr of msg.cc)
|
|
376
|
+
this.db.recordSentAddress(addr.name, addr.address);
|
|
377
|
+
if (msg.bcc)
|
|
378
|
+
for (const addr of msg.bcc)
|
|
379
|
+
this.db.recordSentAddress(addr.name, addr.address);
|
|
380
|
+
}
|
|
381
|
+
// ── Delete / Move ──
|
|
382
|
+
async deleteMessage(accountId, uid) {
|
|
383
|
+
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
384
|
+
if (!envelope)
|
|
385
|
+
throw new Error("Message not found");
|
|
386
|
+
await this.syncManager.trashMessage(accountId, envelope.folderId, envelope.uid);
|
|
387
|
+
}
|
|
388
|
+
async deleteMessages(accountId, uids) {
|
|
389
|
+
const messages = uids.map(uid => {
|
|
390
|
+
const env = this.db.getMessageByUid(accountId, uid);
|
|
391
|
+
if (!env)
|
|
392
|
+
return null;
|
|
393
|
+
return { uid: env.uid, folderId: env.folderId };
|
|
394
|
+
}).filter(m => m !== null);
|
|
395
|
+
await this.syncManager.trashMessages(accountId, messages);
|
|
396
|
+
}
|
|
397
|
+
async moveMessage(accountId, uid, targetFolderId, targetAccountId) {
|
|
398
|
+
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
399
|
+
if (!envelope)
|
|
400
|
+
throw new Error("Message not found");
|
|
401
|
+
if (targetAccountId && targetAccountId !== accountId) {
|
|
402
|
+
await this.syncManager.moveMessageCrossAccount(accountId, envelope.uid, envelope.folderId, targetAccountId, targetFolderId);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
await this.syncManager.moveMessage(accountId, envelope.uid, envelope.folderId, targetFolderId);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async moveMessages(accountId, uids, targetFolderId) {
|
|
409
|
+
const messages = uids.map(uid => {
|
|
410
|
+
const env = this.db.getMessageByUid(accountId, uid);
|
|
411
|
+
if (!env)
|
|
412
|
+
return null;
|
|
413
|
+
return { uid: env.uid, folderId: env.folderId };
|
|
414
|
+
}).filter(m => m !== null);
|
|
415
|
+
await this.syncManager.moveMessages(accountId, messages, targetFolderId);
|
|
416
|
+
}
|
|
417
|
+
async undeleteMessage(accountId, uid, folderId) {
|
|
418
|
+
await this.syncManager.undeleteMessage(accountId, uid, folderId);
|
|
419
|
+
}
|
|
420
|
+
// ── Drafts ──
|
|
421
|
+
async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId) {
|
|
422
|
+
const settings = await loadSettings();
|
|
423
|
+
const account = settings.accounts.find(a => a.id === accountId);
|
|
424
|
+
if (!account)
|
|
425
|
+
throw new Error(`Unknown account: ${accountId}`);
|
|
426
|
+
const id = draftId || `mailx-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
427
|
+
const body = bodyHtml || bodyText || "";
|
|
428
|
+
const bodyEncoded = encodeQuotedPrintable(body);
|
|
429
|
+
const headers = [
|
|
430
|
+
`From: ${account.name} <${account.email}>`,
|
|
431
|
+
to ? `To: ${to}` : null, cc ? `Cc: ${cc}` : null,
|
|
432
|
+
`Subject: ${subject || "(no subject)"}`, `Date: ${new Date().toUTCString()}`,
|
|
433
|
+
`X-Mailx-Draft-ID: ${id}`,
|
|
434
|
+
`MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
|
|
435
|
+
].filter(h => h !== null).join("\r\n");
|
|
436
|
+
const raw = `${headers}\r\n\r\n${bodyEncoded}`;
|
|
437
|
+
const uid = await this.syncManager.saveDraft(accountId, raw, previousDraftUid, id);
|
|
438
|
+
return { uid, draftId: id };
|
|
439
|
+
}
|
|
440
|
+
async deleteDraft(accountId, draftUid) {
|
|
441
|
+
await this.syncManager.deleteDraft(accountId, draftUid);
|
|
442
|
+
}
|
|
443
|
+
// ── Contacts ──
|
|
444
|
+
searchContacts(query) {
|
|
445
|
+
if (query.length < 1)
|
|
446
|
+
return [];
|
|
447
|
+
return this.db.searchContacts(query);
|
|
448
|
+
}
|
|
449
|
+
// ── Settings ──
|
|
450
|
+
async getSettings() {
|
|
451
|
+
return loadSettings();
|
|
452
|
+
}
|
|
453
|
+
async saveSettingsData(settings) {
|
|
454
|
+
await saveSettings(settings);
|
|
455
|
+
}
|
|
456
|
+
getStorageInfo() {
|
|
457
|
+
return getStorageInfo();
|
|
458
|
+
}
|
|
459
|
+
// ── Folder management (limited on mobile — read-only) ──
|
|
460
|
+
markFolderRead(folderId) {
|
|
461
|
+
this.db.markFolderRead(folderId);
|
|
462
|
+
}
|
|
463
|
+
// ── Autocomplete ──
|
|
464
|
+
async getAutocompleteSettings() {
|
|
465
|
+
return loadAutocomplete();
|
|
466
|
+
}
|
|
467
|
+
async saveAutocompleteSettings(settings) {
|
|
468
|
+
await saveAutocomplete(settings);
|
|
469
|
+
}
|
|
470
|
+
async autocomplete(_req) {
|
|
471
|
+
// Autocomplete disabled on mobile by default
|
|
472
|
+
return { suggestion: "" };
|
|
473
|
+
}
|
|
474
|
+
// ── Reset ──
|
|
475
|
+
async resetStore() {
|
|
476
|
+
await this.db.resetStore();
|
|
477
|
+
await this.bodyStore.clear();
|
|
478
|
+
console.log("[service] Store reset complete");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
//# sourceMappingURL=web-service.js.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-compatible settings for Android/WebView.
|
|
3
|
+
* Replaces @bobfrankston/mailx-settings which depends on node:fs.
|
|
4
|
+
*
|
|
5
|
+
* Settings are stored in IndexedDB and synced to Google Drive
|
|
6
|
+
* via the GDrive API (same API as desktop cloud mode).
|
|
7
|
+
*
|
|
8
|
+
* On first run, settings are fetched from GDrive. Subsequent reads
|
|
9
|
+
* use the local IndexedDB cache. Writes go to both.
|
|
10
|
+
*/
|
|
11
|
+
import type { AccountConfig, MailxSettings, AutocompleteSettings } from "@bobfrankston/mailx-types";
|
|
12
|
+
export declare function setGDriveTokenProvider(provider: () => Promise<string>): void;
|
|
13
|
+
export declare function setGDriveFolderId(folderId: string): void;
|
|
14
|
+
declare const DEFAULT_PREFERENCES: {
|
|
15
|
+
ui: {
|
|
16
|
+
theme: "system";
|
|
17
|
+
editor: "quill";
|
|
18
|
+
folderWidth: number;
|
|
19
|
+
listViewerSplit: number;
|
|
20
|
+
fontSize: number;
|
|
21
|
+
};
|
|
22
|
+
sync: {
|
|
23
|
+
intervalMinutes: number;
|
|
24
|
+
historyDays: number;
|
|
25
|
+
prefetch: boolean;
|
|
26
|
+
};
|
|
27
|
+
autocomplete: {
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
provider: "off";
|
|
30
|
+
ollamaUrl: string;
|
|
31
|
+
ollamaModel: string;
|
|
32
|
+
cloudApiKey: string;
|
|
33
|
+
cloudModel: string;
|
|
34
|
+
debounceMs: number;
|
|
35
|
+
maxTokens: number;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
declare const DEFAULT_ALLOWLIST: {
|
|
39
|
+
senders: string[];
|
|
40
|
+
domains: string[];
|
|
41
|
+
recipients: string[];
|
|
42
|
+
};
|
|
43
|
+
/** Load accounts — first from IndexedDB cache, then GDrive */
|
|
44
|
+
export declare function loadAccounts(): Promise<AccountConfig[]>;
|
|
45
|
+
/** Save accounts to IndexedDB and GDrive */
|
|
46
|
+
export declare function saveAccounts(accounts: AccountConfig[]): Promise<void>;
|
|
47
|
+
/** Load preferences */
|
|
48
|
+
export declare function loadPreferences(): Promise<typeof DEFAULT_PREFERENCES>;
|
|
49
|
+
/** Save preferences */
|
|
50
|
+
export declare function savePreferences(prefs: any): Promise<void>;
|
|
51
|
+
/** Load full settings (accounts + preferences combined) */
|
|
52
|
+
export declare function loadSettings(): Promise<MailxSettings>;
|
|
53
|
+
/** Save full settings */
|
|
54
|
+
export declare function saveSettings(settings: MailxSettings): Promise<void>;
|
|
55
|
+
/** Load allowlist */
|
|
56
|
+
export declare function loadAllowlist(): Promise<typeof DEFAULT_ALLOWLIST>;
|
|
57
|
+
/** Save allowlist */
|
|
58
|
+
export declare function saveAllowlist(list: typeof DEFAULT_ALLOWLIST): Promise<void>;
|
|
59
|
+
/** Load autocomplete settings */
|
|
60
|
+
export declare function loadAutocomplete(): Promise<AutocompleteSettings>;
|
|
61
|
+
/** Save autocomplete settings */
|
|
62
|
+
export declare function saveAutocomplete(settings: AutocompleteSettings): Promise<void>;
|
|
63
|
+
/** Get history days — read from preferences */
|
|
64
|
+
export declare function getHistoryDays(): Promise<number>;
|
|
65
|
+
/** Get prefetch setting */
|
|
66
|
+
export declare function getPrefetch(): Promise<boolean>;
|
|
67
|
+
/** Get storage info */
|
|
68
|
+
export declare function getStorageInfo(): {
|
|
69
|
+
provider: string;
|
|
70
|
+
mode: string;
|
|
71
|
+
};
|
|
72
|
+
/** Clear all cached settings — used for "Reset Store" */
|
|
73
|
+
export declare function clearSettings(): Promise<void>;
|
|
74
|
+
/** Get or create a stable device ID (UUID stored in localStorage) */
|
|
75
|
+
export declare function getDeviceId(): string;
|
|
76
|
+
/** Save device-specific settings to GDrive (devices/<deviceId>/state.json) */
|
|
77
|
+
export declare function saveDeviceState(state: any): Promise<void>;
|
|
78
|
+
/** Load device-specific settings */
|
|
79
|
+
export declare function loadDeviceState(): Promise<any>;
|
|
80
|
+
export {};
|
|
81
|
+
//# sourceMappingURL=web-settings.d.ts.map
|