@bobfrankston/mailx 1.0.55 → 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.
- package/package.json +1 -1
- package/packages/mailx-api/index.d.ts +2 -1
- package/packages/mailx-api/index.js +56 -505
- package/packages/mailx-api/package.json +2 -3
- package/packages/mailx-service/index.d.ts +58 -0
- package/packages/mailx-service/index.js +456 -0
- package/packages/mailx-service/package.json +22 -0
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
6
|
import { MailxDB } from "@bobfrankston/mailx-store";
|
|
7
7
|
import { ImapManager } from "@bobfrankston/mailx-imap";
|
|
8
8
|
export declare function createApiRouter(db: MailxDB, imapManager: ImapManager): Router;
|
|
9
|
+
export { MailxService } from "@bobfrankston/mailx-service";
|
|
9
10
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -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,24 +68,7 @@ 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) {
|
|
@@ -347,45 +78,7 @@ export function createApiRouter(db, imapManager) {
|
|
|
347
78
|
// ── Send ──
|
|
348
79
|
router.post("/send", async (req, res) => {
|
|
349
80
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
res.status(
|
|
96
|
+
const status = e.message === "Message not found" ? 404 : 500;
|
|
97
|
+
res.status(status).json({ error: e.message });
|
|
409
98
|
}
|
|
410
99
|
});
|
|
411
|
-
// ── Move
|
|
100
|
+
// ── Move ──
|
|
412
101
|
router.post("/message/:accountId/:uid/move", async (req, res) => {
|
|
413
102
|
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
|
-
}
|
|
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
|
-
|
|
433
|
-
res.status(
|
|
107
|
+
const status = e.message === "Message not found" ? 404 : 500;
|
|
108
|
+
res.status(status).json({ error: e.message });
|
|
434
109
|
}
|
|
435
110
|
});
|
|
436
|
-
// ── Undelete
|
|
111
|
+
// ── Undelete ──
|
|
437
112
|
router.post("/message/:accountId/:uid/undelete", async (req, res) => {
|
|
438
113
|
try {
|
|
439
|
-
|
|
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
|
|
121
|
+
// ── Direct IMAP delete ──
|
|
450
122
|
router.delete("/imap/:accountId/:folderPath/:uid", async (req, res) => {
|
|
451
123
|
try {
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, "")}""`);
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
637
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
"@bobfrankston/mailx-store": "file:../mailx-store",
|
|
14
14
|
"@bobfrankston/mailx-imap": "file:../mailx-imap",
|
|
15
15
|
"@bobfrankston/mailx-settings": "file:../mailx-settings",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"nodemailer": "^7.0.0"
|
|
16
|
+
"@bobfrankston/mailx-service": "file:../mailx-service",
|
|
17
|
+
"express": "^4.21.0"
|
|
19
18
|
},
|
|
20
19
|
"devDependencies": {
|
|
21
20
|
"@types/express": "^5.0.0",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bobfrankston/mailx-service
|
|
3
|
+
* Pure business logic — no HTTP, no Express.
|
|
4
|
+
* Both the Express API (mailx-api) and the Android bridge call these functions.
|
|
5
|
+
*/
|
|
6
|
+
import { MailxDB } from "@bobfrankston/mailx-store";
|
|
7
|
+
import { ImapManager } from "@bobfrankston/mailx-imap";
|
|
8
|
+
import type { Folder } from "@bobfrankston/mailx-types";
|
|
9
|
+
export declare function sanitizeHtml(html: string): {
|
|
10
|
+
html: string;
|
|
11
|
+
hasRemoteContent: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare class MailxService {
|
|
14
|
+
private db;
|
|
15
|
+
private imapManager;
|
|
16
|
+
constructor(db: MailxDB, imapManager: ImapManager);
|
|
17
|
+
getAccounts(): any[];
|
|
18
|
+
getFolders(accountId: string): Folder[];
|
|
19
|
+
getUnifiedInbox(page?: number, pageSize?: number): any;
|
|
20
|
+
getMessages(accountId: string, folderId: number, page?: number, pageSize?: number, sort?: string, sortDir?: string, search?: string): any;
|
|
21
|
+
getMessage(accountId: string, uid: number, allowRemote?: boolean, folderId?: number): Promise<any>;
|
|
22
|
+
updateFlags(accountId: string, uid: number, flags: string[]): Promise<void>;
|
|
23
|
+
allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): void;
|
|
24
|
+
search(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number): Promise<any>;
|
|
25
|
+
rebuildSearchIndex(): number;
|
|
26
|
+
getSyncPending(): {
|
|
27
|
+
pending: number;
|
|
28
|
+
};
|
|
29
|
+
syncAll(): Promise<void>;
|
|
30
|
+
syncAccount(accountId: string): Promise<void>;
|
|
31
|
+
send(msg: any): Promise<void>;
|
|
32
|
+
deleteMessage(accountId: string, uid: number): Promise<void>;
|
|
33
|
+
moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void>;
|
|
34
|
+
undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
|
|
35
|
+
deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
|
|
36
|
+
createFolder(accountId: string, parentPath: string, name: string): Promise<void>;
|
|
37
|
+
renameFolder(accountId: string, folderId: number, newName: string): Promise<void>;
|
|
38
|
+
deleteFolder(accountId: string, folderId: number): Promise<void>;
|
|
39
|
+
markFolderRead(folderId: number): void;
|
|
40
|
+
emptyFolder(accountId: string, folderId: number): Promise<void>;
|
|
41
|
+
getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{
|
|
42
|
+
content: Buffer;
|
|
43
|
+
contentType: string;
|
|
44
|
+
filename: string;
|
|
45
|
+
}>;
|
|
46
|
+
saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number): Promise<number | null>;
|
|
47
|
+
deleteDraft(accountId: string, draftUid: number): Promise<void>;
|
|
48
|
+
searchContacts(query: string): any[];
|
|
49
|
+
syncGoogleContacts(): Promise<void>;
|
|
50
|
+
seedContacts(): number;
|
|
51
|
+
getSettings(): any;
|
|
52
|
+
saveSettings(settings: any): void;
|
|
53
|
+
getStorageInfo(): {
|
|
54
|
+
provider: string;
|
|
55
|
+
mode: string;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bobfrankston/mailx-service
|
|
3
|
+
* Pure business logic — no HTTP, no Express.
|
|
4
|
+
* Both the Express API (mailx-api) and the Android bridge call these functions.
|
|
5
|
+
*/
|
|
6
|
+
import { loadSettings, saveSettings, loadAllowlist, saveAllowlist, getStorePath, getStorageInfo } from "@bobfrankston/mailx-settings";
|
|
7
|
+
import { simpleParser } from "mailparser";
|
|
8
|
+
// ── Sanitize ──
|
|
9
|
+
export function sanitizeHtml(html) {
|
|
10
|
+
let hasRemoteContent = false;
|
|
11
|
+
let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
|
|
12
|
+
clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
13
|
+
clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
|
|
14
|
+
const url = src.slice(1, -1);
|
|
15
|
+
if (url.startsWith("data:") || url.startsWith("cid:"))
|
|
16
|
+
return match;
|
|
17
|
+
hasRemoteContent = true;
|
|
18
|
+
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"`;
|
|
19
|
+
});
|
|
20
|
+
clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
|
|
21
|
+
hasRemoteContent = true;
|
|
22
|
+
return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
|
|
23
|
+
});
|
|
24
|
+
clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (_match, _q, url) => {
|
|
25
|
+
hasRemoteContent = true;
|
|
26
|
+
return `url("") /* blocked: ${url} */`;
|
|
27
|
+
});
|
|
28
|
+
clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
|
|
29
|
+
clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
|
|
30
|
+
return { html: clean, hasRemoteContent };
|
|
31
|
+
}
|
|
32
|
+
// ── Service ──
|
|
33
|
+
export class MailxService {
|
|
34
|
+
db;
|
|
35
|
+
imapManager;
|
|
36
|
+
constructor(db, imapManager) {
|
|
37
|
+
this.db = db;
|
|
38
|
+
this.imapManager = imapManager;
|
|
39
|
+
}
|
|
40
|
+
// ── Accounts ──
|
|
41
|
+
getAccounts() {
|
|
42
|
+
const accounts = this.db.getAccounts();
|
|
43
|
+
const settings = loadSettings();
|
|
44
|
+
return accounts.map(a => {
|
|
45
|
+
const cfg = settings.accounts.find(s => s.id === a.id);
|
|
46
|
+
return { ...a, label: cfg?.label, defaultSend: cfg?.defaultSend || false };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// ── Folders ──
|
|
50
|
+
getFolders(accountId) {
|
|
51
|
+
return this.db.getFolders(accountId);
|
|
52
|
+
}
|
|
53
|
+
// ── Messages ──
|
|
54
|
+
getUnifiedInbox(page = 1, pageSize = 50) {
|
|
55
|
+
return this.db.getUnifiedInbox(page, pageSize);
|
|
56
|
+
}
|
|
57
|
+
getMessages(accountId, folderId, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search) {
|
|
58
|
+
return this.db.getMessages({ accountId, folderId, page, pageSize, sort: sort, sortDir: sortDir, search });
|
|
59
|
+
}
|
|
60
|
+
async getMessage(accountId, uid, allowRemote = false, folderId) {
|
|
61
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
62
|
+
if (!envelope)
|
|
63
|
+
throw new Error("Message not found");
|
|
64
|
+
let bodyHtml = "";
|
|
65
|
+
let bodyText = "";
|
|
66
|
+
let hasRemoteContent = false;
|
|
67
|
+
let attachments = [];
|
|
68
|
+
let raw = null;
|
|
69
|
+
try {
|
|
70
|
+
raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
|
|
71
|
+
}
|
|
72
|
+
catch (fetchErr) {
|
|
73
|
+
return {
|
|
74
|
+
...envelope, bodyHtml: "", bodyText: `[Message body unavailable: ${fetchErr.message || "IMAP connection failed"}]`,
|
|
75
|
+
hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (raw) {
|
|
79
|
+
const parsed = await simpleParser(raw);
|
|
80
|
+
bodyHtml = parsed.html || "";
|
|
81
|
+
bodyText = parsed.text || "";
|
|
82
|
+
attachments = (parsed.attachments || []).map((a, i) => ({
|
|
83
|
+
id: i,
|
|
84
|
+
filename: a.filename || `attachment-${i}`,
|
|
85
|
+
mimeType: a.contentType || "application/octet-stream",
|
|
86
|
+
size: a.size || 0,
|
|
87
|
+
contentId: a.contentId || ""
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
// Sanitize HTML
|
|
91
|
+
if (bodyHtml && !allowRemote) {
|
|
92
|
+
const allowList = loadAllowlist();
|
|
93
|
+
const senderAddr = envelope.from?.address || "";
|
|
94
|
+
const senderDomain = senderAddr.split("@")[1] || "";
|
|
95
|
+
const toAddrs = (envelope.to || []).map((a) => a.address);
|
|
96
|
+
const isAllowed = allowList.senders.includes(senderAddr) ||
|
|
97
|
+
allowList.domains.includes(senderDomain) ||
|
|
98
|
+
toAddrs.some((a) => allowList.recipients?.includes(a));
|
|
99
|
+
if (isAllowed) {
|
|
100
|
+
allowRemote = true;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const result = sanitizeHtml(bodyHtml);
|
|
104
|
+
bodyHtml = result.html;
|
|
105
|
+
hasRemoteContent = result.hasRemoteContent;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Extract headers
|
|
109
|
+
let deliveredTo = "";
|
|
110
|
+
let returnPath = "";
|
|
111
|
+
let listUnsubscribe = "";
|
|
112
|
+
if (raw) {
|
|
113
|
+
const parsed2 = await simpleParser(raw);
|
|
114
|
+
const hdr = (key) => {
|
|
115
|
+
let v = parsed2.headers.get(key);
|
|
116
|
+
if (!v)
|
|
117
|
+
return "";
|
|
118
|
+
if (Array.isArray(v))
|
|
119
|
+
v = v[0];
|
|
120
|
+
if (typeof v === "string")
|
|
121
|
+
return v;
|
|
122
|
+
if (typeof v === "object" && v !== null) {
|
|
123
|
+
if ("text" in v)
|
|
124
|
+
return v.text || "";
|
|
125
|
+
if ("value" in v)
|
|
126
|
+
return String(v.value);
|
|
127
|
+
if ("address" in v)
|
|
128
|
+
return v.address || "";
|
|
129
|
+
}
|
|
130
|
+
return String(v);
|
|
131
|
+
};
|
|
132
|
+
const msgSettings = loadSettings();
|
|
133
|
+
const acctConfig = msgSettings.accounts.find((a) => a.id === accountId);
|
|
134
|
+
const relayDomains = acctConfig?.relayDomains || [];
|
|
135
|
+
const prefixes = acctConfig?.deliveredToPrefix || [];
|
|
136
|
+
const rawDelivered = parsed2.headers.get("delivered-to");
|
|
137
|
+
if (rawDelivered) {
|
|
138
|
+
const deliveredList = Array.isArray(rawDelivered) ? rawDelivered : [rawDelivered];
|
|
139
|
+
for (let i = deliveredList.length - 1; i >= 0; i--) {
|
|
140
|
+
const d = deliveredList[i];
|
|
141
|
+
const addr = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
142
|
+
if (!relayDomains.some(rd => addr.includes(`@${rd}`))) {
|
|
143
|
+
deliveredTo = addr;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!deliveredTo && deliveredList.length > 0) {
|
|
148
|
+
const d = deliveredList[deliveredList.length - 1];
|
|
149
|
+
deliveredTo = typeof d === "string" ? d : d?.text || d?.address || String(d);
|
|
150
|
+
}
|
|
151
|
+
if (deliveredTo && prefixes.length > 0) {
|
|
152
|
+
const [local, domain] = deliveredTo.split("@");
|
|
153
|
+
for (const prefix of prefixes) {
|
|
154
|
+
if (local.startsWith(prefix)) {
|
|
155
|
+
deliveredTo = `${local.slice(prefix.length)}@${domain}`;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
returnPath = hdr("return-path").replace(/[<>]/g, "");
|
|
162
|
+
const listHeaders = parsed2.headers.get("list");
|
|
163
|
+
if (listHeaders?.unsubscribe) {
|
|
164
|
+
const unsub = listHeaders.unsubscribe;
|
|
165
|
+
listUnsubscribe = unsub.url || (unsub.mail ? `mailto:${unsub.mail}` : "");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const storePath = getStorePath();
|
|
169
|
+
const emlPath = `${storePath}/${accountId}/${envelope.folderId}/${envelope.uid}.eml`;
|
|
170
|
+
return {
|
|
171
|
+
...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
|
|
172
|
+
attachments, emlPath, deliveredTo, returnPath, listUnsubscribe,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async updateFlags(accountId, uid, flags) {
|
|
176
|
+
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
177
|
+
await this.imapManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
|
|
178
|
+
}
|
|
179
|
+
// ── Remote content allow-list ──
|
|
180
|
+
allowRemoteContent(type, value) {
|
|
181
|
+
const list = loadAllowlist();
|
|
182
|
+
if (type === "sender" && !list.senders.includes(value))
|
|
183
|
+
list.senders.push(value);
|
|
184
|
+
else if (type === "domain" && !list.domains.includes(value))
|
|
185
|
+
list.domains.push(value);
|
|
186
|
+
else if (type === "recipient") {
|
|
187
|
+
if (!list.recipients)
|
|
188
|
+
list.recipients = [];
|
|
189
|
+
if (!list.recipients.includes(value))
|
|
190
|
+
list.recipients.push(value);
|
|
191
|
+
}
|
|
192
|
+
saveAllowlist(list);
|
|
193
|
+
console.log(` [allow] Added ${type}: ${value}`);
|
|
194
|
+
}
|
|
195
|
+
// ── Search ──
|
|
196
|
+
async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
|
|
197
|
+
if (!q.trim())
|
|
198
|
+
return { items: [], total: 0, page, pageSize };
|
|
199
|
+
if (scope === "server" && accountId) {
|
|
200
|
+
const folders = this.db.getFolders(accountId);
|
|
201
|
+
const folder = folderId ? folders.find(f => f.id === folderId) : folders.find(f => f.specialUse === "inbox");
|
|
202
|
+
if (!folder)
|
|
203
|
+
return { items: [], total: 0, page, pageSize };
|
|
204
|
+
const criteria = {};
|
|
205
|
+
const fromMatch = q.match(/from:(\S+)/i);
|
|
206
|
+
const toMatch = q.match(/to:(\S+)/i);
|
|
207
|
+
const subjectMatch = q.match(/subject:(.+?)(?:\s+\w+:|$)/i);
|
|
208
|
+
const bodyText = q.replace(/(?:from|to|subject):\S+/gi, "").trim();
|
|
209
|
+
if (fromMatch)
|
|
210
|
+
criteria.from = fromMatch[1];
|
|
211
|
+
if (toMatch)
|
|
212
|
+
criteria.to = toMatch[1];
|
|
213
|
+
if (subjectMatch)
|
|
214
|
+
criteria.subject = subjectMatch[1].trim();
|
|
215
|
+
if (bodyText)
|
|
216
|
+
criteria.body = bodyText;
|
|
217
|
+
const uids = await this.imapManager.searchOnServer(accountId, folder.path, criteria);
|
|
218
|
+
const items = uids.slice((page - 1) * pageSize, page * pageSize)
|
|
219
|
+
.map(uid => this.db.getMessageByUid(accountId, uid, folderId))
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
return { items, total: uids.length, page, pageSize };
|
|
222
|
+
}
|
|
223
|
+
else if (scope === "current" && accountId && folderId) {
|
|
224
|
+
return this.db.searchMessages(q, page, pageSize, accountId, folderId);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
return this.db.searchMessages(q, page, pageSize);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
rebuildSearchIndex() {
|
|
231
|
+
const count = this.db.rebuildSearchIndex();
|
|
232
|
+
console.log(` Rebuilt search index: ${count} messages`);
|
|
233
|
+
return count;
|
|
234
|
+
}
|
|
235
|
+
// ── Sync ──
|
|
236
|
+
getSyncPending() {
|
|
237
|
+
return { pending: this.db.getTotalPendingSyncCount() };
|
|
238
|
+
}
|
|
239
|
+
async syncAll() {
|
|
240
|
+
await this.imapManager.syncAll();
|
|
241
|
+
}
|
|
242
|
+
async syncAccount(accountId) {
|
|
243
|
+
const folders = await this.imapManager.syncFolders(accountId);
|
|
244
|
+
folders.sort((a, b) => {
|
|
245
|
+
if (a.specialUse === "inbox")
|
|
246
|
+
return -1;
|
|
247
|
+
if (b.specialUse === "inbox")
|
|
248
|
+
return 1;
|
|
249
|
+
return 0;
|
|
250
|
+
});
|
|
251
|
+
for (const folder of folders) {
|
|
252
|
+
try {
|
|
253
|
+
await this.imapManager.syncFolder(accountId, folder.id);
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
console.error(` Skipping folder ${folder.path}: ${e.message}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// ── Send ──
|
|
261
|
+
async send(msg) {
|
|
262
|
+
const settings = loadSettings();
|
|
263
|
+
const account = settings.accounts.find(a => a.id === msg.from);
|
|
264
|
+
if (!account)
|
|
265
|
+
throw new Error(`Unknown account: ${msg.from}`);
|
|
266
|
+
const fromHeader = msg.fromAddress || `${account.name} <${account.email}>`;
|
|
267
|
+
const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
|
|
268
|
+
const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
|
|
269
|
+
const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
|
|
270
|
+
const body = msg.bodyHtml || msg.bodyText || "";
|
|
271
|
+
const bodyBase64 = Buffer.from(body, "utf-8").toString("base64").replace(/(.{76})/g, "$1\r\n");
|
|
272
|
+
const headers = [
|
|
273
|
+
`From: ${fromHeader}`, `To: ${to}`,
|
|
274
|
+
cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
|
|
275
|
+
`Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
|
|
276
|
+
msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
|
|
277
|
+
msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
|
|
278
|
+
`MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: base64`,
|
|
279
|
+
].filter(h => h !== null).join("\r\n");
|
|
280
|
+
const rawMessage = `${headers}\r\n\r\n${bodyBase64}`;
|
|
281
|
+
this.imapManager.queueOutgoingLocal(account.id, rawMessage);
|
|
282
|
+
console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
|
|
283
|
+
for (const addr of msg.to)
|
|
284
|
+
this.db.recordSentAddress(addr.name, addr.address);
|
|
285
|
+
if (msg.cc)
|
|
286
|
+
for (const addr of msg.cc)
|
|
287
|
+
this.db.recordSentAddress(addr.name, addr.address);
|
|
288
|
+
if (msg.bcc)
|
|
289
|
+
for (const addr of msg.bcc)
|
|
290
|
+
this.db.recordSentAddress(addr.name, addr.address);
|
|
291
|
+
}
|
|
292
|
+
// ── Delete / Move / Undelete ──
|
|
293
|
+
async deleteMessage(accountId, uid) {
|
|
294
|
+
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
295
|
+
if (!envelope)
|
|
296
|
+
throw new Error("Message not found");
|
|
297
|
+
await this.imapManager.trashMessage(accountId, envelope.folderId, envelope.uid);
|
|
298
|
+
}
|
|
299
|
+
async moveMessage(accountId, uid, targetFolderId, targetAccountId) {
|
|
300
|
+
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
301
|
+
if (!envelope)
|
|
302
|
+
throw new Error("Message not found");
|
|
303
|
+
if (targetAccountId && targetAccountId !== accountId) {
|
|
304
|
+
await this.imapManager.moveMessageCrossAccount(accountId, envelope.uid, envelope.folderId, targetAccountId, targetFolderId);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
await this.imapManager.moveMessage(accountId, envelope.uid, envelope.folderId, targetFolderId);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async undeleteMessage(accountId, uid, folderId) {
|
|
311
|
+
await this.imapManager.undeleteMessage(accountId, uid, folderId);
|
|
312
|
+
}
|
|
313
|
+
async deleteOnServer(accountId, folderPath, uid) {
|
|
314
|
+
await this.imapManager.deleteOnServer(accountId, folderPath, uid);
|
|
315
|
+
}
|
|
316
|
+
// ── Folder management ──
|
|
317
|
+
async createFolder(accountId, parentPath, name) {
|
|
318
|
+
const fullPath = parentPath ? `${parentPath}.${name}` : name;
|
|
319
|
+
const client = this.imapManager.createPublicClient(accountId);
|
|
320
|
+
try {
|
|
321
|
+
await client.createmailbox(fullPath);
|
|
322
|
+
await this.imapManager.syncFolders(accountId, client);
|
|
323
|
+
await client.logout();
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
try {
|
|
327
|
+
await client.logout();
|
|
328
|
+
}
|
|
329
|
+
catch { /* */ }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async renameFolder(accountId, folderId, newName) {
|
|
333
|
+
const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
334
|
+
if (!folder)
|
|
335
|
+
throw new Error("Folder not found");
|
|
336
|
+
const parts = folder.path.split(folder.delimiter || ".");
|
|
337
|
+
parts[parts.length - 1] = newName;
|
|
338
|
+
const newPath = parts.join(folder.delimiter || ".");
|
|
339
|
+
const client = this.imapManager.createPublicClient(accountId);
|
|
340
|
+
try {
|
|
341
|
+
await client.withConnection(async () => {
|
|
342
|
+
await client.client.mailboxRename(folder.path, newPath);
|
|
343
|
+
});
|
|
344
|
+
await this.imapManager.syncFolders(accountId, client);
|
|
345
|
+
await client.logout();
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
try {
|
|
349
|
+
await client.logout();
|
|
350
|
+
}
|
|
351
|
+
catch { /* */ }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async deleteFolder(accountId, folderId) {
|
|
355
|
+
const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
356
|
+
if (!folder)
|
|
357
|
+
throw new Error("Folder not found");
|
|
358
|
+
const client = this.imapManager.createPublicClient(accountId);
|
|
359
|
+
try {
|
|
360
|
+
await client.withConnection(async () => {
|
|
361
|
+
await client.client.mailboxDelete(folder.path);
|
|
362
|
+
});
|
|
363
|
+
this.db.deleteFolder(folderId);
|
|
364
|
+
await client.logout();
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
try {
|
|
368
|
+
await client.logout();
|
|
369
|
+
}
|
|
370
|
+
catch { /* */ }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
markFolderRead(folderId) {
|
|
374
|
+
this.db.markFolderRead(folderId);
|
|
375
|
+
}
|
|
376
|
+
async emptyFolder(accountId, folderId) {
|
|
377
|
+
const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
378
|
+
if (!folder)
|
|
379
|
+
throw new Error("Folder not found");
|
|
380
|
+
this.db.deleteAllMessages(accountId, folderId);
|
|
381
|
+
const client = this.imapManager.createPublicClient(accountId);
|
|
382
|
+
try {
|
|
383
|
+
const uids = await client.getUids(folder.path);
|
|
384
|
+
for (const uid of uids)
|
|
385
|
+
await client.deleteMessageByUid(folder.path, uid);
|
|
386
|
+
await client.logout();
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
try {
|
|
390
|
+
await client.logout();
|
|
391
|
+
}
|
|
392
|
+
catch { /* */ }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ── Attachments ──
|
|
396
|
+
async getAttachment(accountId, uid, attachmentId, folderId) {
|
|
397
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
398
|
+
if (!envelope)
|
|
399
|
+
throw new Error("Message not found");
|
|
400
|
+
const raw = await this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
|
|
401
|
+
if (!raw)
|
|
402
|
+
throw new Error("Message body not available");
|
|
403
|
+
const parsed = await simpleParser(raw);
|
|
404
|
+
const att = parsed.attachments?.[attachmentId];
|
|
405
|
+
if (!att)
|
|
406
|
+
throw new Error("Attachment not found");
|
|
407
|
+
return {
|
|
408
|
+
content: att.content,
|
|
409
|
+
contentType: att.contentType || "application/octet-stream",
|
|
410
|
+
filename: (att.filename || "attachment").replace(/"/g, ""),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
// ── Drafts ──
|
|
414
|
+
async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid) {
|
|
415
|
+
const settings = loadSettings();
|
|
416
|
+
const account = settings.accounts.find(a => a.id === accountId);
|
|
417
|
+
if (!account)
|
|
418
|
+
throw new Error(`Unknown account: ${accountId}`);
|
|
419
|
+
const headers = [
|
|
420
|
+
`From: ${account.name} <${account.email}>`,
|
|
421
|
+
to ? `To: ${to}` : null, cc ? `Cc: ${cc}` : null,
|
|
422
|
+
`Subject: ${subject || "(no subject)"}`, `Date: ${new Date().toUTCString()}`,
|
|
423
|
+
`MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`,
|
|
424
|
+
].filter(h => h !== null).join("\r\n");
|
|
425
|
+
const raw = `${headers}\r\n\r\n${bodyHtml || bodyText || ""}`;
|
|
426
|
+
return this.imapManager.saveDraft(accountId, raw, previousDraftUid);
|
|
427
|
+
}
|
|
428
|
+
async deleteDraft(accountId, draftUid) {
|
|
429
|
+
await this.imapManager.deleteDraft(accountId, draftUid);
|
|
430
|
+
}
|
|
431
|
+
// ── Contacts ──
|
|
432
|
+
searchContacts(query) {
|
|
433
|
+
if (query.length < 1)
|
|
434
|
+
return [];
|
|
435
|
+
return this.db.searchContacts(query);
|
|
436
|
+
}
|
|
437
|
+
async syncGoogleContacts() {
|
|
438
|
+
await this.imapManager.syncAllContacts();
|
|
439
|
+
}
|
|
440
|
+
seedContacts() {
|
|
441
|
+
const added = this.db.seedContactsFromMessages();
|
|
442
|
+
console.log(` Seeded ${added} contacts from message history`);
|
|
443
|
+
return added;
|
|
444
|
+
}
|
|
445
|
+
// ── Settings ──
|
|
446
|
+
getSettings() {
|
|
447
|
+
return loadSettings();
|
|
448
|
+
}
|
|
449
|
+
saveSettings(settings) {
|
|
450
|
+
saveSettings(settings);
|
|
451
|
+
}
|
|
452
|
+
getStorageInfo() {
|
|
453
|
+
return getStorageInfo();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bobfrankston/mailx-service",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc"
|
|
9
|
+
},
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@bobfrankston/mailx-types": "file:../mailx-types",
|
|
13
|
+
"@bobfrankston/mailx-store": "file:../mailx-store",
|
|
14
|
+
"@bobfrankston/mailx-imap": "file:../mailx-imap",
|
|
15
|
+
"@bobfrankston/mailx-settings": "file:../mailx-settings",
|
|
16
|
+
"mailparser": "^3.7.2"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/BobFrankston/mailx.git"
|
|
21
|
+
}
|
|
22
|
+
}
|