@bobfrankston/mailx 1.0.50 → 1.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/app.js +103 -41
- package/client/compose/compose.css +66 -0
- package/client/compose/compose.html +1 -2
- package/client/compose/compose.js +59 -21
- package/client/compose/editor.js +160 -0
- package/client/index.html +8 -0
- package/client/styles/components.css +2 -0
- 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-imap/index.d.ts +4 -0
- package/packages/mailx-imap/index.js +164 -83
- 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/packages/mailx-settings/index.d.ts +1 -0
- package/packages/mailx-settings/index.js +1 -0
- package/packages/mailx-types/index.d.ts +1 -0
|
@@ -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
|
+
}
|
|
@@ -177,6 +177,7 @@ export interface MailxSettings {
|
|
|
177
177
|
accounts: AccountConfig[];
|
|
178
178
|
ui: {
|
|
179
179
|
theme: "system" | "dark" | "light";
|
|
180
|
+
editor: "quill" | "tiptap";
|
|
180
181
|
folderWidth: number;
|
|
181
182
|
listViewerSplit: number; /** Percentage for message list height */
|
|
182
183
|
fontSize: number;
|