@bobfrankston/mailx 1.0.50 → 1.0.57

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.
@@ -1,317 +1,65 @@
1
1
  /**
2
2
  * @bobfrankston/mailx-api
3
- * Express Router with all REST endpoints for the mailx client.
3
+ * Thin Express Router delegates all logic to mailx-service.
4
4
  */
5
5
  import { Router } from "express";
6
- import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, getStorePath } from "@bobfrankston/mailx-settings";
7
- import { simpleParser } from "mailparser";
8
- /** Sanitize HTML email body — strip remote content (images, CSS, scripts, event handlers) */
9
- function sanitizeHtml(html) {
10
- let hasRemoteContent = false;
11
- // Remove <script> tags and content
12
- let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
13
- // Remove event handlers (onclick, onload, onerror, etc.)
14
- clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
15
- // Replace remote images with placeholder — keep data: and cid: URIs
16
- clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
17
- const url = src.slice(1, -1); // remove quotes
18
- if (url.startsWith("data:") || url.startsWith("cid:"))
19
- return match;
20
- hasRemoteContent = true;
21
- 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"`;
22
- });
23
- // Block remote CSS via <link> tags
24
- clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
25
- hasRemoteContent = true;
26
- return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
27
- });
28
- // Block background images in inline styles
29
- clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (match, _q, url) => {
30
- hasRemoteContent = true;
31
- return `url("") /* blocked: ${url} */`;
32
- });
33
- // Remove <form> tags
34
- clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
35
- // Remove <iframe> tags (within email body)
36
- clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
37
- return { html: clean, hasRemoteContent };
38
- }
6
+ import { MailxService } from "@bobfrankston/mailx-service";
39
7
  export function createApiRouter(db, imapManager) {
8
+ const svc = new MailxService(db, imapManager);
40
9
  const router = Router();
41
10
  router.use((req, res, next) => { res.type("json"); res.set("Cache-Control", "no-store"); next(); });
42
11
  // ── Accounts ──
43
- router.get("/accounts", (req, res) => {
44
- const accounts = db.getAccounts();
45
- const settings = loadSettings();
46
- // Merge settings flags
47
- const enriched = accounts.map(a => {
48
- const cfg = settings.accounts.find(s => s.id === a.id);
49
- return { ...a, label: cfg?.label, defaultSend: cfg?.defaultSend || false };
50
- });
51
- res.json(enriched);
52
- });
12
+ router.get("/accounts", (req, res) => { res.json(svc.getAccounts()); });
53
13
  // ── Folders ──
54
- router.get("/folders/:accountId", (req, res) => {
55
- const folders = db.getFolders(req.params.accountId);
56
- res.json(folders);
57
- });
14
+ router.get("/folders/:accountId", (req, res) => { res.json(svc.getFolders(req.params.accountId)); });
58
15
  // ── Messages ──
59
- // Unified inbox — MUST be before :accountId/:folderId route
60
16
  router.get("/messages/unified/inbox", (req, res) => {
61
- const page = Number(req.query.page) || 1;
62
- const pageSize = Number(req.query.pageSize) || 50;
63
- const result = db.getUnifiedInbox(page, pageSize);
64
- res.json(result);
17
+ res.json(svc.getUnifiedInbox(Number(req.query.page) || 1, Number(req.query.pageSize) || 50));
65
18
  });
66
- // Per-folder messages (after unified to avoid route conflict)
67
19
  router.get("/messages/:accountId/:folderId", (req, res) => {
68
- const result = db.getMessages({
69
- accountId: req.params.accountId,
70
- folderId: Number(req.params.folderId),
71
- page: Number(req.query.page) || 1,
72
- pageSize: Number(req.query.pageSize) || 50,
73
- sort: req.query.sort || "date",
74
- sortDir: req.query.sortDir || "desc",
75
- search: req.query.search
76
- });
77
- res.json(result);
20
+ res.json(svc.getMessages(req.params.accountId, Number(req.params.folderId), Number(req.query.page) || 1, Number(req.query.pageSize) || 50, req.query.sort || "date", req.query.sortDir || "desc", req.query.search));
78
21
  });
79
22
  router.get("/message/:accountId/:uid", async (req, res) => {
80
23
  try {
81
- const { accountId, uid } = req.params;
82
- let allowRemote = req.query.allowRemote === "true";
83
- const folderId = req.query.folderId ? Number(req.query.folderId) : undefined;
84
- const envelope = db.getMessageByUid(accountId, Number(uid), folderId);
85
- if (!envelope)
86
- return res.status(404).json({ error: "Message not found" });
87
- // Load body from store or fetch on demand from IMAP
88
- let bodyHtml = "";
89
- let bodyText = "";
90
- let hasRemoteContent = false;
91
- let attachments = [];
92
- const t0 = Date.now();
93
- let raw = null;
94
- try {
95
- raw = await imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
96
- }
97
- catch (fetchErr) {
98
- console.error(` [fetch] Failed UID ${uid}: ${fetchErr.message || fetchErr}`);
99
- // Return envelope with error instead of 500 — user can still see headers
100
- return res.json({ ...envelope, bodyHtml: "", bodyText: `[Message body unavailable: ${fetchErr.message || "IMAP connection failed"}]`, hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: "" });
101
- }
102
- const t1 = Date.now();
103
- if (raw) {
104
- const parsed = await simpleParser(raw);
105
- const t2 = Date.now();
106
- if (t2 - t0 > 2000)
107
- console.log(` [slow] Message ${uid}: fetch=${t1 - t0}ms parse=${t2 - t1}ms total=${t2 - t0}ms (${raw.length} bytes)`);
108
- bodyHtml = parsed.html || "";
109
- bodyText = parsed.text || "";
110
- attachments = (parsed.attachments || []).map((a, i) => ({
111
- id: i,
112
- filename: a.filename || `attachment-${i}`,
113
- mimeType: a.contentType || "application/octet-stream",
114
- size: a.size || 0,
115
- contentId: a.contentId || ""
116
- }));
117
- }
118
- // Sanitize HTML — block remote content unless allowed or sender is on allow-list
119
- if (bodyHtml && !allowRemote) {
120
- const allowList = loadAllowlist();
121
- const senderAddr = envelope.from?.address || "";
122
- const senderDomain = senderAddr.split("@")[1] || "";
123
- const toAddrs = (envelope.to || []).map((a) => a.address);
124
- const isAllowed = allowList.senders.includes(senderAddr) ||
125
- allowList.domains.includes(senderDomain) ||
126
- toAddrs.some((a) => allowList.recipients?.includes(a));
127
- if (isAllowed) {
128
- allowRemote = true;
129
- }
130
- else {
131
- const result = sanitizeHtml(bodyHtml);
132
- bodyHtml = result.html;
133
- hasRemoteContent = result.hasRemoteContent;
134
- }
135
- }
136
- // Build .eml file path
137
- const storePath = getStorePath();
138
- const emlPath = `${storePath}/${accountId}/${envelope.folderId}/${envelope.uid}.eml`;
139
- // Extract useful headers (values may be strings or structured objects)
140
- let deliveredTo = "";
141
- let returnPath = "";
142
- let listUnsubscribe = "";
143
- if (raw) {
144
- const parsed2 = await simpleParser(raw);
145
- const hdr = (key) => {
146
- let v = parsed2.headers.get(key);
147
- if (!v)
148
- return "";
149
- if (Array.isArray(v))
150
- v = v[0];
151
- if (typeof v === "string")
152
- return v;
153
- if (typeof v === "object" && v !== null) {
154
- if ("text" in v)
155
- return v.text || "";
156
- if ("value" in v)
157
- return String(v.value);
158
- if ("address" in v)
159
- return v.address || "";
160
- }
161
- return String(v);
162
- };
163
- // Get the real Delivered-To, skipping relay domains from account config
164
- const msgSettings = loadSettings();
165
- const acctConfig = msgSettings.accounts.find((a) => a.id === accountId);
166
- const relayDomains = acctConfig?.relayDomains || [];
167
- const prefixes = acctConfig?.deliveredToPrefix || [];
168
- const rawDelivered = parsed2.headers.get("delivered-to");
169
- if (rawDelivered) {
170
- const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
171
- for (let i = deliveredList.length - 1; i >= 0; i--) {
172
- const d = deliveredList[i];
173
- const addr = typeof d === "string" ? d : d?.text || d?.address || String(d);
174
- if (!relayDomains.some(rd => addr.includes(`@${rd}`))) {
175
- deliveredTo = addr;
176
- break;
177
- }
178
- }
179
- if (!deliveredTo && deliveredList.length > 0) {
180
- const d = deliveredList[deliveredList.length - 1];
181
- deliveredTo = typeof d === "string" ? d : d?.text || d?.address || String(d);
182
- }
183
- // Strip prefix from local part to get clean alias
184
- if (deliveredTo && prefixes.length > 0) {
185
- const [local, domain] = deliveredTo.split("@");
186
- for (const prefix of prefixes) {
187
- if (local.startsWith(prefix)) {
188
- deliveredTo = `${local.slice(prefix.length)}@${domain}`;
189
- break;
190
- }
191
- }
192
- }
193
- }
194
- returnPath = hdr("return-path").replace(/[<>]/g, "");
195
- // mailparser merges List-* headers into a "list" object
196
- const listHeaders = parsed2.headers.get("list");
197
- if (listHeaders?.unsubscribe) {
198
- const unsub = listHeaders.unsubscribe;
199
- listUnsubscribe = unsub.url || (unsub.mail ? `mailto:${unsub.mail}` : "");
200
- }
201
- }
202
- const message = {
203
- ...envelope,
204
- bodyHtml,
205
- bodyText,
206
- hasRemoteContent,
207
- remoteAllowed: allowRemote,
208
- attachments,
209
- emlPath,
210
- deliveredTo,
211
- returnPath,
212
- listUnsubscribe,
213
- };
214
- res.json(message);
24
+ const msg = await svc.getMessage(req.params.accountId, Number(req.params.uid), req.query.allowRemote === "true", req.query.folderId ? Number(req.query.folderId) : undefined);
25
+ res.json(msg);
215
26
  }
216
27
  catch (e) {
217
- res.status(500).json({ error: e.message });
28
+ res.status(e.message === "Message not found" ? 404 : 500).json({ error: e.message });
218
29
  }
219
30
  });
220
31
  router.patch("/message/:accountId/:uid/flags", async (req, res) => {
221
- const { accountId, uid } = req.params;
222
- const { flags } = req.body;
223
- const envelope = db.getMessageByUid(accountId, Number(uid));
224
- // Local-first: update DB, queue IMAP sync
225
- await imapManager.updateFlagsLocal(accountId, Number(uid), envelope?.folderId || 0, flags);
226
- res.json({ ok: true });
32
+ try {
33
+ await svc.updateFlags(req.params.accountId, Number(req.params.uid), req.body.flags);
34
+ res.json({ ok: true });
35
+ }
36
+ catch (e) {
37
+ res.status(500).json({ error: e.message });
38
+ }
227
39
  });
228
40
  // ── Remote content allow-list ──
229
41
  router.post("/settings/allow-remote", (req, res) => {
230
- const { type, value } = req.body;
231
- const list = loadAllowlist();
232
- if (type === "sender" && !list.senders.includes(value)) {
233
- list.senders.push(value);
234
- }
235
- else if (type === "domain" && !list.domains.includes(value)) {
236
- list.domains.push(value);
237
- }
238
- else if (type === "recipient") {
239
- if (!list.recipients)
240
- list.recipients = [];
241
- if (!list.recipients.includes(value))
242
- list.recipients.push(value);
243
- }
244
- saveAllowlist(list);
245
- console.log(` [allow] Added ${type}: ${value}`);
42
+ svc.allowRemoteContent(req.body.type, req.body.value);
246
43
  res.json({ ok: true });
247
44
  });
248
45
  // ── Search ──
249
46
  router.get("/search", async (req, res) => {
250
- const q = req.query.q || "";
251
- const page = Number(req.query.page) || 1;
252
- const pageSize = Number(req.query.pageSize) || 50;
253
- const scope = req.query.scope || "all";
254
- const accountId = req.query.accountId || "";
255
- const folderId = Number(req.query.folderId) || 0;
256
- if (!q.trim())
257
- return res.json({ items: [], total: 0, page, pageSize });
258
47
  try {
259
- if (scope === "server" && accountId) {
260
- // IMAP server search
261
- const folders = db.getFolders(accountId);
262
- const folder = folderId ? folders.find(f => f.id === folderId) : folders.find(f => f.specialUse === "inbox");
263
- if (!folder)
264
- return res.json({ items: [], total: 0, page, pageSize });
265
- const criteria = {};
266
- // Parse qualifiers
267
- const fromMatch = q.match(/from:(\S+)/i);
268
- const toMatch = q.match(/to:(\S+)/i);
269
- const subjectMatch = q.match(/subject:(.+?)(?:\s+\w+:|$)/i);
270
- const bodyText = q.replace(/(?:from|to|subject):\S+/gi, "").trim();
271
- if (fromMatch)
272
- criteria.from = fromMatch[1];
273
- if (toMatch)
274
- criteria.to = toMatch[1];
275
- if (subjectMatch)
276
- criteria.subject = subjectMatch[1].trim();
277
- if (bodyText)
278
- criteria.body = bodyText;
279
- const uids = await imapManager.searchOnServer(accountId, folder.path, criteria);
280
- // Fetch envelopes for matching UIDs from local DB
281
- const items = uids.slice((page - 1) * pageSize, page * pageSize)
282
- .map(uid => db.getMessageByUid(accountId, uid, folderId))
283
- .filter(Boolean);
284
- res.json({ items, total: uids.length, page, pageSize });
285
- }
286
- else if (scope === "current" && accountId && folderId) {
287
- // Search within current folder only
288
- const result = db.searchMessages(q, page, pageSize, accountId, folderId);
289
- res.json(result);
290
- }
291
- else {
292
- // All folders (default)
293
- const result = db.searchMessages(q, page, pageSize);
294
- res.json(result);
295
- }
48
+ const result = await svc.search(req.query.q || "", Number(req.query.page) || 1, Number(req.query.pageSize) || 50, req.query.scope || "all", req.query.accountId, Number(req.query.folderId) || 0);
49
+ res.json(result);
296
50
  }
297
51
  catch (e) {
298
52
  res.status(500).json({ error: e.message });
299
53
  }
300
54
  });
301
55
  router.post("/search/rebuild", (req, res) => {
302
- const count = db.rebuildSearchIndex();
303
- console.log(` Rebuilt search index: ${count} messages`);
304
- res.json({ ok: true, indexed: count });
305
- });
306
- // ── Sync status ──
307
- router.get("/sync/pending", (req, res) => {
308
- const count = db.getTotalPendingSyncCount();
309
- res.json({ pending: count });
56
+ res.json({ ok: true, indexed: svc.rebuildSearchIndex() });
310
57
  });
311
58
  // ── Sync ──
59
+ router.get("/sync/pending", (req, res) => { res.json(svc.getSyncPending()); });
312
60
  router.post("/sync", async (req, res) => {
313
61
  try {
314
- await imapManager.syncAll();
62
+ await svc.syncAll();
315
63
  res.json({ ok: true });
316
64
  }
317
65
  catch (e) {
@@ -320,24 +68,7 @@ export function createApiRouter(db, imapManager) {
320
68
  });
321
69
  router.post("/sync/:accountId", async (req, res) => {
322
70
  try {
323
- const folders = await imapManager.syncFolders(req.params.accountId);
324
- // INBOX first
325
- folders.sort((a, b) => {
326
- if (a.specialUse === "inbox")
327
- return -1;
328
- if (b.specialUse === "inbox")
329
- return 1;
330
- return 0;
331
- });
332
- for (const folder of folders) {
333
- try {
334
- await imapManager.syncFolder(req.params.accountId, folder.id);
335
- }
336
- catch (e) {
337
- // Non-existent folders handled internally by syncFolder caller
338
- console.error(` Skipping folder ${folder.path}: ${e.message}`);
339
- }
340
- }
71
+ await svc.syncAccount(req.params.accountId);
341
72
  res.json({ ok: true });
342
73
  }
343
74
  catch (e) {
@@ -347,45 +78,7 @@ export function createApiRouter(db, imapManager) {
347
78
  // ── Send ──
348
79
  router.post("/send", async (req, res) => {
349
80
  try {
350
- const msg = req.body;
351
- const settings = loadSettings();
352
- const account = settings.accounts.find(a => a.id === msg.from);
353
- if (!account)
354
- return res.status(400).json({ error: `Unknown account: ${msg.from}` });
355
- // Use custom From address if provided, otherwise account default
356
- const fromHeader = msg.fromAddress || `${account.name} <${account.email}>`;
357
- // Build RFC 822 message
358
- const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
359
- const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
360
- const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
361
- const body = msg.bodyHtml || msg.bodyText || "";
362
- const bodyBase64 = Buffer.from(body, "utf-8").toString("base64").replace(/(.{76})/g, "$1\r\n");
363
- const headers = [
364
- `From: ${fromHeader}`,
365
- `To: ${to}`,
366
- cc ? `Cc: ${cc}` : null,
367
- bcc ? `Bcc: ${bcc}` : null,
368
- `Subject: ${msg.subject}`,
369
- `Date: ${new Date().toUTCString()}`,
370
- msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
371
- msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
372
- `MIME-Version: 1.0`,
373
- `Content-Type: text/html; charset=UTF-8`,
374
- `Content-Transfer-Encoding: base64`,
375
- ].filter(h => h !== null).join("\r\n");
376
- const rawMessage = `${headers}\r\n\r\n${bodyBase64}`;
377
- // Local-first: save to sync queue, worker will APPEND to Outbox and send
378
- imapManager.queueOutgoingLocal(account.id, rawMessage);
379
- console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
380
- // Record recipient addresses for autocomplete
381
- for (const addr of msg.to)
382
- db.recordSentAddress(addr.name, addr.address);
383
- if (msg.cc)
384
- for (const addr of msg.cc)
385
- db.recordSentAddress(addr.name, addr.address);
386
- if (msg.bcc)
387
- for (const addr of msg.bcc)
388
- db.recordSentAddress(addr.name, addr.address);
81
+ await svc.send(req.body);
389
82
  res.json({ ok: true, queued: true });
390
83
  }
391
84
  catch (e) {
@@ -396,62 +89,39 @@ export function createApiRouter(db, imapManager) {
396
89
  // ── Delete ──
397
90
  router.delete("/message/:accountId/:uid", async (req, res) => {
398
91
  try {
399
- const { accountId, uid } = req.params;
400
- const envelope = db.getMessageByUid(accountId, Number(uid));
401
- if (!envelope)
402
- return res.status(404).json({ error: "Message not found" });
403
- await imapManager.trashMessage(accountId, envelope.folderId, envelope.uid);
92
+ await svc.deleteMessage(req.params.accountId, Number(req.params.uid));
404
93
  res.json({ ok: true });
405
94
  }
406
95
  catch (e) {
407
- console.error(` Delete error: ${e.message}`);
408
- res.status(500).json({ error: e.message });
96
+ const status = e.message === "Message not found" ? 404 : 500;
97
+ res.status(status).json({ error: e.message });
409
98
  }
410
99
  });
411
- // ── Move message to another folder ──
100
+ // ── Move ──
412
101
  router.post("/message/:accountId/:uid/move", async (req, res) => {
413
102
  try {
414
- const { accountId, uid } = req.params;
415
- const { targetFolderId, targetAccountId } = req.body;
416
- if (targetFolderId == null)
417
- return res.status(400).json({ error: "targetFolderId required" });
418
- const envelope = db.getMessageByUid(accountId, Number(uid));
419
- if (!envelope)
420
- return res.status(404).json({ error: "Message not found" });
421
- if (targetAccountId && targetAccountId !== accountId) {
422
- // Cross-account move via iflow
423
- await imapManager.moveMessageCrossAccount(accountId, envelope.uid, envelope.folderId, targetAccountId, targetFolderId);
424
- }
425
- else {
426
- // Same-account move
427
- await imapManager.moveMessage(accountId, envelope.uid, envelope.folderId, targetFolderId);
428
- }
103
+ await svc.moveMessage(req.params.accountId, Number(req.params.uid), req.body.targetFolderId, req.body.targetAccountId);
429
104
  res.json({ ok: true });
430
105
  }
431
106
  catch (e) {
432
- console.error(` Move error: ${e.message}`);
433
- res.status(500).json({ error: e.message });
107
+ const status = e.message === "Message not found" ? 404 : 500;
108
+ res.status(status).json({ error: e.message });
434
109
  }
435
110
  });
436
- // ── Undelete (move from Trash back to original folder) ──
111
+ // ── Undelete ──
437
112
  router.post("/message/:accountId/:uid/undelete", async (req, res) => {
438
113
  try {
439
- const { accountId, uid } = req.params;
440
- const { folderId } = req.body;
441
- await imapManager.undeleteMessage(accountId, Number(uid), folderId);
114
+ await svc.undeleteMessage(req.params.accountId, Number(req.params.uid), req.body.folderId);
442
115
  res.json({ ok: true });
443
116
  }
444
117
  catch (e) {
445
- console.error(` Undelete error: ${e.message}`);
446
118
  res.status(500).json({ error: e.message });
447
119
  }
448
120
  });
449
- // ── Direct IMAP delete (for messages not in local DB) ──
121
+ // ── Direct IMAP delete ──
450
122
  router.delete("/imap/:accountId/:folderPath/:uid", async (req, res) => {
451
123
  try {
452
- const { accountId, uid } = req.params;
453
- const folderPath = decodeURIComponent(req.params.folderPath);
454
- await imapManager.deleteOnServer(accountId, folderPath, Number(uid));
124
+ await svc.deleteOnServer(req.params.accountId, decodeURIComponent(req.params.folderPath), Number(req.params.uid));
455
125
  res.json({ ok: true });
456
126
  }
457
127
  catch (e) {
@@ -459,123 +129,45 @@ export function createApiRouter(db, imapManager) {
459
129
  }
460
130
  });
461
131
  // ── Folder management ──
462
- // Create subfolder
463
132
  router.post("/folder/:accountId", async (req, res) => {
464
133
  try {
465
- const { accountId } = req.params;
466
- const { parentPath, name } = req.body;
467
- const fullPath = parentPath ? `${parentPath}.${name}` : name;
468
- const client = imapManager.createPublicClient(accountId);
469
- try {
470
- await client.createmailbox(fullPath);
471
- await imapManager.syncFolders(accountId, client);
472
- await client.logout();
473
- }
474
- finally {
475
- try {
476
- await client.logout();
477
- }
478
- catch { /* */ }
479
- }
134
+ await svc.createFolder(req.params.accountId, req.body.parentPath, req.body.name);
480
135
  res.json({ ok: true });
481
136
  }
482
137
  catch (e) {
483
138
  res.status(500).json({ error: e.message });
484
139
  }
485
140
  });
486
- // Rename folder
487
141
  router.post("/folder/:accountId/:folderId/rename", async (req, res) => {
488
142
  try {
489
- const { accountId, folderId } = req.params;
490
- const { newName } = req.body;
491
- const folder = db.getFolders(accountId).find(f => f.id === Number(folderId));
492
- if (!folder)
493
- return res.status(404).json({ error: "Folder not found" });
494
- const parts = folder.path.split(folder.delimiter || ".");
495
- parts[parts.length - 1] = newName;
496
- const newPath = parts.join(folder.delimiter || ".");
497
- const client = imapManager.createPublicClient(accountId);
498
- try {
499
- // iflow doesn't have renamemailbox yet — use raw imapflow
500
- await client.withConnection(async () => {
501
- await client.client.mailboxRename(folder.path, newPath);
502
- });
503
- await imapManager.syncFolders(accountId, client);
504
- await client.logout();
505
- }
506
- finally {
507
- try {
508
- await client.logout();
509
- }
510
- catch { /* */ }
511
- }
143
+ await svc.renameFolder(req.params.accountId, Number(req.params.folderId), req.body.newName);
512
144
  res.json({ ok: true });
513
145
  }
514
146
  catch (e) {
515
147
  res.status(500).json({ error: e.message });
516
148
  }
517
149
  });
518
- // Delete folder
519
150
  router.delete("/folder/:accountId/:folderId", async (req, res) => {
520
151
  try {
521
- const { accountId, folderId } = req.params;
522
- const folder = db.getFolders(accountId).find(f => f.id === Number(folderId));
523
- if (!folder)
524
- return res.status(404).json({ error: "Folder not found" });
525
- const client = imapManager.createPublicClient(accountId);
526
- try {
527
- await client.withConnection(async () => {
528
- await client.client.mailboxDelete(folder.path);
529
- });
530
- db.deleteFolder(Number(folderId));
531
- await client.logout();
532
- }
533
- finally {
534
- try {
535
- await client.logout();
536
- }
537
- catch { /* */ }
538
- }
152
+ await svc.deleteFolder(req.params.accountId, Number(req.params.folderId));
539
153
  res.json({ ok: true });
540
154
  }
541
155
  catch (e) {
542
156
  res.status(500).json({ error: e.message });
543
157
  }
544
158
  });
545
- // Mark all messages in folder as read
546
159
  router.post("/folder/:accountId/:folderId/mark-read", async (req, res) => {
547
160
  try {
548
- const { accountId, folderId } = req.params;
549
- db.markFolderRead(Number(folderId));
161
+ svc.markFolderRead(Number(req.params.folderId));
550
162
  res.json({ ok: true });
551
163
  }
552
164
  catch (e) {
553
165
  res.status(500).json({ error: e.message });
554
166
  }
555
167
  });
556
- // Empty folder (permanently delete all messages)
557
168
  router.post("/folder/:accountId/:folderId/empty", async (req, res) => {
558
169
  try {
559
- const { accountId, folderId } = req.params;
560
- const folder = db.getFolders(accountId).find(f => f.id === Number(folderId));
561
- if (!folder)
562
- return res.status(404).json({ error: "Folder not found" });
563
- db.deleteAllMessages(accountId, Number(folderId));
564
- // Also delete on IMAP
565
- const client = imapManager.createPublicClient(accountId);
566
- try {
567
- const uids = await client.getUids(folder.path);
568
- for (const uid of uids) {
569
- await client.deleteMessageByUid(folder.path, uid);
570
- }
571
- await client.logout();
572
- }
573
- finally {
574
- try {
575
- await client.logout();
576
- }
577
- catch { /* */ }
578
- }
170
+ await svc.emptyFolder(req.params.accountId, Number(req.params.folderId));
579
171
  res.json({ ok: true });
580
172
  }
581
173
  catch (e) {
@@ -585,58 +177,31 @@ export function createApiRouter(db, imapManager) {
585
177
  // ── Attachments ──
586
178
  router.get("/message/:accountId/:uid/attachment/:attachmentId", async (req, res) => {
587
179
  try {
588
- const { accountId, uid, attachmentId } = req.params;
589
- const folderId = req.query.folderId ? Number(req.query.folderId) : undefined;
590
- const envelope = db.getMessageByUid(accountId, Number(uid), folderId);
591
- if (!envelope)
592
- return res.status(404).json({ error: "Message not found" });
593
- const raw = await imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
594
- if (!raw)
595
- return res.status(404).json({ error: "Message body not available" });
596
- const parsed = await simpleParser(raw);
597
- const att = parsed.attachments?.[Number(attachmentId)];
598
- if (!att)
599
- return res.status(404).json({ error: "Attachment not found" });
600
- res.set("Content-Type", att.contentType || "application/octet-stream");
601
- res.set("Content-Disposition", `inline; filename="${(att.filename || "attachment").replace(/"/g, "")}""`);
180
+ const att = await svc.getAttachment(req.params.accountId, Number(req.params.uid), Number(req.params.attachmentId), req.query.folderId ? Number(req.query.folderId) : undefined);
181
+ res.set("Content-Type", att.contentType);
182
+ res.set("Content-Disposition", `inline; filename="${att.filename}"`);
602
183
  res.send(att.content);
603
184
  }
604
185
  catch (e) {
605
- res.status(500).json({ error: e.message });
186
+ const status = e.message.includes("not found") || e.message.includes("not available") ? 404 : 500;
187
+ res.status(status).json({ error: e.message });
606
188
  }
607
189
  });
608
190
  // ── Drafts ──
609
191
  router.post("/draft", async (req, res) => {
610
192
  try {
611
193
  const { accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid } = req.body;
612
- const settings = loadSettings();
613
- const account = settings.accounts.find(a => a.id === accountId);
614
- if (!account)
615
- return res.status(400).json({ error: `Unknown account: ${accountId}` });
616
- const headers = [
617
- `From: ${account.name} <${account.email}>`,
618
- to ? `To: ${to}` : null,
619
- cc ? `Cc: ${cc}` : null,
620
- `Subject: ${subject || "(no subject)"}`,
621
- `Date: ${new Date().toUTCString()}`,
622
- `MIME-Version: 1.0`,
623
- `Content-Type: text/html; charset=UTF-8`,
624
- ].filter(h => h !== null).join("\r\n");
625
- const raw = `${headers}\r\n\r\n${bodyHtml || bodyText || ""}`;
626
- const uid = await imapManager.saveDraft(accountId, raw, previousDraftUid);
194
+ const uid = await svc.saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid);
627
195
  res.json({ ok: true, draftUid: uid });
628
196
  }
629
197
  catch (e) {
630
- console.error(` Draft save error: ${e.message}`);
631
198
  res.status(500).json({ error: e.message });
632
199
  }
633
200
  });
634
201
  router.delete("/draft", async (req, res) => {
635
202
  try {
636
- const { accountId, draftUid } = req.body;
637
- if (accountId && draftUid) {
638
- await imapManager.deleteDraft(accountId, draftUid);
639
- }
203
+ if (req.body.accountId && req.body.draftUid)
204
+ await svc.deleteDraft(req.body.accountId, req.body.draftUid);
640
205
  res.json({ ok: true });
641
206
  }
642
207
  catch (e) {
@@ -644,36 +209,22 @@ export function createApiRouter(db, imapManager) {
644
209
  }
645
210
  });
646
211
  // ── Contacts ──
647
- router.get("/contacts", (req, res) => {
648
- const q = req.query.q || "";
649
- if (q.length < 1)
650
- return res.json([]);
651
- const results = db.searchContacts(q);
652
- res.json(results);
653
- });
212
+ router.get("/contacts", (req, res) => { res.json(svc.searchContacts(req.query.q || "")); });
654
213
  router.post("/contacts/sync-google", async (req, res) => {
655
214
  try {
656
- await imapManager.syncAllContacts();
215
+ await svc.syncGoogleContacts();
657
216
  res.json({ ok: true });
658
217
  }
659
218
  catch (e) {
660
219
  res.status(500).json({ error: e.message });
661
220
  }
662
221
  });
663
- router.post("/contacts/seed", (req, res) => {
664
- const added = db.seedContactsFromMessages();
665
- console.log(` Seeded ${added} contacts from message history`);
666
- res.json({ ok: true, added });
667
- });
222
+ router.post("/contacts/seed", (req, res) => { res.json({ ok: true, added: svc.seedContacts() }); });
668
223
  // ── Settings ──
669
- router.get("/settings", (req, res) => {
670
- const settings = loadSettings();
671
- res.json(settings);
672
- });
673
- router.put("/settings", (req, res) => {
674
- saveSettings(req.body);
675
- res.json({ ok: true });
676
- });
224
+ router.get("/settings", (req, res) => { res.json(svc.getSettings()); });
225
+ router.put("/settings", (req, res) => { svc.saveSettings(req.body); res.json({ ok: true }); });
677
226
  return router;
678
227
  }
228
+ // Re-export the service for direct use (Android bridge, worker thread, etc.)
229
+ export { MailxService } from "@bobfrankston/mailx-service";
679
230
  //# sourceMappingURL=index.js.map