@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.
- package/client/app.js +38 -0
- package/client/styles/components.css +12 -0
- package/package.json +1 -1
- package/packages/mailx-api/index.d.ts +2 -1
- package/packages/mailx-api/index.js +65 -505
- package/packages/mailx-api/package.json +2 -3
- package/packages/mailx-imap/index.d.ts +3 -0
- package/packages/mailx-imap/index.js +113 -36
- package/packages/mailx-server/index.js +3 -0
- package/packages/mailx-service/index.d.ts +60 -0
- package/packages/mailx-service/index.js +460 -0
- package/packages/mailx-service/package.json +22 -0
- package/packages/mailx-types/index.d.ts +5 -0
|
@@ -1,317 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @bobfrankston/mailx-api
|
|
3
|
-
* Express Router
|
|
3
|
+
* Thin Express Router — delegates all logic to mailx-service.
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from "express";
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
res.status(
|
|
105
|
+
const status = e.message === "Message not found" ? 404 : 500;
|
|
106
|
+
res.status(status).json({ error: e.message });
|
|
409
107
|
}
|
|
410
108
|
});
|
|
411
|
-
// ── Move
|
|
109
|
+
// ── Move ──
|
|
412
110
|
router.post("/message/:accountId/:uid/move", async (req, res) => {
|
|
413
111
|
try {
|
|
414
|
-
|
|
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
|
-
|
|
433
|
-
res.status(
|
|
116
|
+
const status = e.message === "Message not found" ? 404 : 500;
|
|
117
|
+
res.status(status).json({ error: e.message });
|
|
434
118
|
}
|
|
435
119
|
});
|
|
436
|
-
// ── Undelete
|
|
120
|
+
// ── Undelete ──
|
|
437
121
|
router.post("/message/:accountId/:uid/undelete", async (req, res) => {
|
|
438
122
|
try {
|
|
439
|
-
|
|
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
|
|
130
|
+
// ── Direct IMAP delete ──
|
|
450
131
|
router.delete("/imap/:accountId/:folderPath/:uid", async (req, res) => {
|
|
451
132
|
try {
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
637
|
-
|
|
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
|
|
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
|
-
|
|
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
|