@bobfrankston/mailx 1.0.55 → 1.0.61

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,72 +68,26 @@ 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) {
344
75
  res.status(500).json({ error: e.message });
345
76
  }
346
77
  });
78
+ router.post("/reauth/:accountId", async (req, res) => {
79
+ try {
80
+ const ok = await svc.reauthenticate(req.params.accountId);
81
+ res.json({ ok });
82
+ }
83
+ catch (e) {
84
+ res.status(500).json({ error: e.message });
85
+ }
86
+ });
347
87
  // ── Send ──
348
88
  router.post("/send", async (req, res) => {
349
89
  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);
90
+ await svc.send(req.body);
389
91
  res.json({ ok: true, queued: true });
390
92
  }
391
93
  catch (e) {
@@ -396,62 +98,39 @@ export function createApiRouter(db, imapManager) {
396
98
  // ── Delete ──
397
99
  router.delete("/message/:accountId/:uid", async (req, res) => {
398
100
  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);
101
+ await svc.deleteMessage(req.params.accountId, Number(req.params.uid));
404
102
  res.json({ ok: true });
405
103
  }
406
104
  catch (e) {
407
- console.error(` Delete error: ${e.message}`);
408
- res.status(500).json({ error: e.message });
105
+ const status = e.message === "Message not found" ? 404 : 500;
106
+ res.status(status).json({ error: e.message });
409
107
  }
410
108
  });
411
- // ── Move message to another folder ──
109
+ // ── Move ──
412
110
  router.post("/message/:accountId/:uid/move", async (req, res) => {
413
111
  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
- }
112
+ await svc.moveMessage(req.params.accountId, Number(req.params.uid), req.body.targetFolderId, req.body.targetAccountId);
429
113
  res.json({ ok: true });
430
114
  }
431
115
  catch (e) {
432
- console.error(` Move error: ${e.message}`);
433
- res.status(500).json({ error: e.message });
116
+ const status = e.message === "Message not found" ? 404 : 500;
117
+ res.status(status).json({ error: e.message });
434
118
  }
435
119
  });
436
- // ── Undelete (move from Trash back to original folder) ──
120
+ // ── Undelete ──
437
121
  router.post("/message/:accountId/:uid/undelete", async (req, res) => {
438
122
  try {
439
- const { accountId, uid } = req.params;
440
- const { folderId } = req.body;
441
- await imapManager.undeleteMessage(accountId, Number(uid), folderId);
123
+ await svc.undeleteMessage(req.params.accountId, Number(req.params.uid), req.body.folderId);
442
124
  res.json({ ok: true });
443
125
  }
444
126
  catch (e) {
445
- console.error(` Undelete error: ${e.message}`);
446
127
  res.status(500).json({ error: e.message });
447
128
  }
448
129
  });
449
- // ── Direct IMAP delete (for messages not in local DB) ──
130
+ // ── Direct IMAP delete ──
450
131
  router.delete("/imap/:accountId/:folderPath/:uid", async (req, res) => {
451
132
  try {
452
- const { accountId, uid } = req.params;
453
- const folderPath = decodeURIComponent(req.params.folderPath);
454
- await imapManager.deleteOnServer(accountId, folderPath, Number(uid));
133
+ await svc.deleteOnServer(req.params.accountId, decodeURIComponent(req.params.folderPath), Number(req.params.uid));
455
134
  res.json({ ok: true });
456
135
  }
457
136
  catch (e) {
@@ -459,123 +138,45 @@ export function createApiRouter(db, imapManager) {
459
138
  }
460
139
  });
461
140
  // ── Folder management ──
462
- // Create subfolder
463
141
  router.post("/folder/:accountId", async (req, res) => {
464
142
  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
- }
143
+ await svc.createFolder(req.params.accountId, req.body.parentPath, req.body.name);
480
144
  res.json({ ok: true });
481
145
  }
482
146
  catch (e) {
483
147
  res.status(500).json({ error: e.message });
484
148
  }
485
149
  });
486
- // Rename folder
487
150
  router.post("/folder/:accountId/:folderId/rename", async (req, res) => {
488
151
  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
- }
152
+ await svc.renameFolder(req.params.accountId, Number(req.params.folderId), req.body.newName);
512
153
  res.json({ ok: true });
513
154
  }
514
155
  catch (e) {
515
156
  res.status(500).json({ error: e.message });
516
157
  }
517
158
  });
518
- // Delete folder
519
159
  router.delete("/folder/:accountId/:folderId", async (req, res) => {
520
160
  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
- }
161
+ await svc.deleteFolder(req.params.accountId, Number(req.params.folderId));
539
162
  res.json({ ok: true });
540
163
  }
541
164
  catch (e) {
542
165
  res.status(500).json({ error: e.message });
543
166
  }
544
167
  });
545
- // Mark all messages in folder as read
546
168
  router.post("/folder/:accountId/:folderId/mark-read", async (req, res) => {
547
169
  try {
548
- const { accountId, folderId } = req.params;
549
- db.markFolderRead(Number(folderId));
170
+ svc.markFolderRead(Number(req.params.folderId));
550
171
  res.json({ ok: true });
551
172
  }
552
173
  catch (e) {
553
174
  res.status(500).json({ error: e.message });
554
175
  }
555
176
  });
556
- // Empty folder (permanently delete all messages)
557
177
  router.post("/folder/:accountId/:folderId/empty", async (req, res) => {
558
178
  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
- }
179
+ await svc.emptyFolder(req.params.accountId, Number(req.params.folderId));
579
180
  res.json({ ok: true });
580
181
  }
581
182
  catch (e) {
@@ -585,58 +186,31 @@ export function createApiRouter(db, imapManager) {
585
186
  // ── Attachments ──
586
187
  router.get("/message/:accountId/:uid/attachment/:attachmentId", async (req, res) => {
587
188
  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, "")}""`);
189
+ const att = await svc.getAttachment(req.params.accountId, Number(req.params.uid), Number(req.params.attachmentId), req.query.folderId ? Number(req.query.folderId) : undefined);
190
+ res.set("Content-Type", att.contentType);
191
+ res.set("Content-Disposition", `inline; filename="${att.filename}"`);
602
192
  res.send(att.content);
603
193
  }
604
194
  catch (e) {
605
- res.status(500).json({ error: e.message });
195
+ const status = e.message.includes("not found") || e.message.includes("not available") ? 404 : 500;
196
+ res.status(status).json({ error: e.message });
606
197
  }
607
198
  });
608
199
  // ── Drafts ──
609
200
  router.post("/draft", async (req, res) => {
610
201
  try {
611
202
  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);
203
+ const uid = await svc.saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid);
627
204
  res.json({ ok: true, draftUid: uid });
628
205
  }
629
206
  catch (e) {
630
- console.error(` Draft save error: ${e.message}`);
631
207
  res.status(500).json({ error: e.message });
632
208
  }
633
209
  });
634
210
  router.delete("/draft", async (req, res) => {
635
211
  try {
636
- const { accountId, draftUid } = req.body;
637
- if (accountId && draftUid) {
638
- await imapManager.deleteDraft(accountId, draftUid);
639
- }
212
+ if (req.body.accountId && req.body.draftUid)
213
+ await svc.deleteDraft(req.body.accountId, req.body.draftUid);
640
214
  res.json({ ok: true });
641
215
  }
642
216
  catch (e) {
@@ -644,36 +218,22 @@ export function createApiRouter(db, imapManager) {
644
218
  }
645
219
  });
646
220
  // ── 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
- });
221
+ router.get("/contacts", (req, res) => { res.json(svc.searchContacts(req.query.q || "")); });
654
222
  router.post("/contacts/sync-google", async (req, res) => {
655
223
  try {
656
- await imapManager.syncAllContacts();
224
+ await svc.syncGoogleContacts();
657
225
  res.json({ ok: true });
658
226
  }
659
227
  catch (e) {
660
228
  res.status(500).json({ error: e.message });
661
229
  }
662
230
  });
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
- });
231
+ router.post("/contacts/seed", (req, res) => { res.json({ ok: true, added: svc.seedContacts() }); });
668
232
  // ── 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
- });
233
+ router.get("/settings", (req, res) => { res.json(svc.getSettings()); });
234
+ router.put("/settings", (req, res) => { svc.saveSettings(req.body); res.json({ ok: true }); });
677
235
  return router;
678
236
  }
237
+ // Re-export the service for direct use (Android bridge, worker thread, etc.)
238
+ export { MailxService } from "@bobfrankston/mailx-service";
679
239
  //# sourceMappingURL=index.js.map