nothumanallowed 13.5.113 → 13.5.115

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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * email-imap.mjs — IMAP sync service (READ-ONLY)
3
+ *
4
+ * ⛔ IMAP IS STRICTLY READ-ONLY.
5
+ * This service NEVER writes to the IMAP server:
6
+ * - No setFlags() — read/starred state is in SQLite only
7
+ * - No moveMessage() — label moves are in SQLite only
8
+ * - No DELETE — emails are never deleted from the server
9
+ *
10
+ * Handles: connection pool, incremental sync, IDLE push, folder listing.
11
+ */
12
+
13
+ import { ImapFlow } from 'imapflow';
14
+ import { simpleParser } from 'mailparser';
15
+ import { createHash } from 'crypto';
16
+ import {
17
+ getAccountCredentials, setSyncStatus, upsertFolder, getFolder,
18
+ updateFolderUid, insertMessage, insertAttachments, addMessageToLabel,
19
+ getSystemLabel, isSenderBlocked, applyArchivingRules, messageExists,
20
+ updateLabelCounts, getDb, contentHash,
21
+ } from './email-db.mjs';
22
+
23
+ // ── FOLDER TYPE MAPPING ────────────────────────────────────────────────────
24
+
25
+ const SPECIAL_USE_MAP = {
26
+ '\\Inbox': 'inbox', '\\Sent': 'sent', '\\Drafts': 'drafts',
27
+ '\\Junk': 'spam', '\\Trash': 'trash',
28
+ };
29
+
30
+ const FOLDER_NAME_MAP = {
31
+ 'inbox': 'inbox', 'sent': 'sent', 'sent messages': 'sent', 'sent items': 'sent',
32
+ 'posta inviata': 'sent', 'inviati': 'sent', 'drafts': 'drafts', 'bozze': 'drafts',
33
+ 'draft': 'drafts', 'spam': 'spam', 'junk': 'spam', 'junk e-mail': 'spam',
34
+ 'posta indesiderata': 'spam', 'trash': 'trash', 'cestino': 'trash',
35
+ 'deleted': 'trash', 'deleted messages': 'trash', 'deleted items': 'trash',
36
+ };
37
+
38
+ // ── CONNECTION POOL ────────────────────────────────────────────────────────
39
+
40
+ const syncClients = new Map(); // accountId → ImapFlow (sync)
41
+ const idleClients = new Map(); // accountId → ImapFlow (IDLE)
42
+ const idleTimers = new Map();
43
+ const heartbeatTimers = new Map();
44
+ const lastIdleEvents = new Map();
45
+ const reconnectAttempts = new Map();
46
+
47
+ const IDLE_RESTART_MS = 25 * 60 * 1000; // 25 min
48
+ const IDLE_HEARTBEAT_WATCHDOG_MS = 12 * 60 * 1000; // 12 min
49
+ const RECONNECT_DELAYS = [5000, 15000, 30000, 60000, 120000, 300000];
50
+
51
+ const idleHandlers = new Set();
52
+ export function onIdleNotification(handler) {
53
+ idleHandlers.add(handler);
54
+ return () => idleHandlers.delete(handler);
55
+ }
56
+ function notifyIdle(accountId, folder) {
57
+ for (const h of idleHandlers) { try { h(accountId, folder); } catch {} }
58
+ }
59
+
60
+ function createImapClient(label, creds, accountId) {
61
+ const isSecure = creds.imap_port === 993;
62
+ const client = new ImapFlow({
63
+ host: creds.imap_host,
64
+ port: creds.imap_port,
65
+ secure: isSecure,
66
+ auth: { user: creds.username, pass: creds.password },
67
+ logger: false,
68
+ clientInfo: { name: 'NHA-Mail', version: '1.0.0' },
69
+ emitLogs: false,
70
+ ...(!isSecure && { tls: { rejectUnauthorized: false } }),
71
+ });
72
+ client.on('error', (err) => {
73
+ console.error(`[email:imap] ${label} error:`, err.message);
74
+ if (accountId) syncClients.delete(accountId);
75
+ });
76
+ return client;
77
+ }
78
+
79
+ export async function getImapClient(accountId) {
80
+ const existing = syncClients.get(accountId);
81
+ if (existing?.usable) return existing;
82
+ if (existing) { syncClients.delete(accountId); try { await existing.logout(); } catch {} }
83
+
84
+ const creds = getAccountCredentials(accountId);
85
+ if (!creds || !creds.imap_host) throw new Error(`No IMAP credentials for account ${accountId}`);
86
+ const client = createImapClient(`sync:${accountId.slice(0, 8)}`, creds, accountId);
87
+ await client.connect();
88
+ syncClients.set(accountId, client);
89
+ return client;
90
+ }
91
+
92
+ export async function closeImapClient(accountId) {
93
+ const c = syncClients.get(accountId);
94
+ if (c) { try { await c.logout(); } catch {}; syncClients.delete(accountId); }
95
+ }
96
+
97
+ // ── FOLDER LISTING ─────────────────────────────────────────────────────────
98
+
99
+ export async function listImapFolders(accountId) {
100
+ const client = await getImapClient(accountId);
101
+ const tree = await client.listTree();
102
+ const all = [];
103
+
104
+ function walk(items) {
105
+ if (!items) return;
106
+ for (const item of items) {
107
+ const name = item.name ?? '';
108
+ const path = item.path ?? '';
109
+ let folderType = 'custom';
110
+ if (item.specialUse) folderType = SPECIAL_USE_MAP[item.specialUse] ?? 'custom';
111
+ if (folderType === 'custom') folderType = FOLDER_NAME_MAP[name.toLowerCase()] ?? 'custom';
112
+ if (path === 'INBOX') folderType = 'inbox';
113
+ if (!item.flags?.has('\\Noselect')) {
114
+ all.push({ path, name, folderType, hasSpecialUse: !!item.specialUse });
115
+ }
116
+ if (item.folders?.length) walk(item.folders);
117
+ }
118
+ }
119
+
120
+ if (tree.path === 'INBOX' || !tree.path) {
121
+ all.push({ path: 'INBOX', name: 'Inbox', folderType: 'inbox', hasSpecialUse: true });
122
+ }
123
+ if (tree.folders?.length) walk(tree.folders);
124
+
125
+ // Dedup: keep one winner per system type
126
+ const winners = new Map();
127
+ for (const f of all) {
128
+ if (f.folderType === 'custom') continue;
129
+ const ex = winners.get(f.folderType);
130
+ if (!ex || (f.hasSpecialUse && !ex.hasSpecialUse) || (!f.path.includes('.') && ex.path.includes('.'))) {
131
+ winners.set(f.folderType, f);
132
+ }
133
+ }
134
+ return all.map(f => f.folderType === 'custom' ? f : (winners.get(f.folderType) === f ? f : { ...f, folderType: 'custom' }));
135
+ }
136
+
137
+ // ── INCREMENTAL SYNC ───────────────────────────────────────────────────────
138
+
139
+ function threadId(messageId, inReplyTo, references, fromAddress, date, subject) {
140
+ const root = (references && references[0]) || inReplyTo || messageId;
141
+ if (root) return createHash('sha1').update(root).digest('hex');
142
+ return createHash('sha1').update(`${fromAddress}|${date}|${subject}`).digest('hex');
143
+ }
144
+
145
+ function stripQuotedReplies(text) {
146
+ if (!text) return text;
147
+ return text.split('\n').filter(l => !l.startsWith('>')).join('\n').trim();
148
+ }
149
+
150
+ export async function syncFolder(accountId, folderPath, fullResync) {
151
+ const client = await getImapClient(accountId);
152
+ const db = getDb();
153
+ const folderMeta = getFolder(accountId, folderPath);
154
+ const lock = await client.getMailboxLock(folderPath);
155
+
156
+ try {
157
+ const mailbox = client.mailbox;
158
+ const currentUidValidity = Number(mailbox?.uidValidity ?? 0);
159
+ const storedUidValidity = folderMeta?.uid_validity ?? null;
160
+ const lastUid = (fullResync || storedUidValidity !== currentUidValidity) ? 0 : (folderMeta?.last_uid ?? 0);
161
+ const needsResync = fullResync || (storedUidValidity !== null && storedUidValidity !== currentUidValidity);
162
+
163
+ if (needsResync && folderMeta) {
164
+ // Clear existing messages for this folder on UID validity change
165
+ db.prepare('DELETE FROM email_message_labels WHERE message_id IN (SELECT id FROM email_messages WHERE account_id = ? AND imap_folder_path = ?)').run(accountId, folderPath);
166
+ db.prepare('DELETE FROM email_messages WHERE account_id = ? AND imap_folder_path = ?').run(accountId, folderPath);
167
+ }
168
+
169
+ const inboxLabel = getSystemLabel(accountId, 'inbox');
170
+ const sentLabel = getSystemLabel(accountId, 'sent');
171
+ const blockedLabel = getSystemLabel(accountId, 'spam');
172
+
173
+ let newLastUid = lastUid;
174
+ let synced = 0;
175
+
176
+ const range = lastUid > 0 ? `${lastUid + 1}:*` : '1:*';
177
+
178
+ for await (const msg of client.fetch(range, {
179
+ uid: true, flags: true, envelope: true, internalDate: true, size: true,
180
+ }, { uid: true })) {
181
+ if (msg.uid <= lastUid) continue;
182
+ newLastUid = Math.max(newLastUid, msg.uid);
183
+
184
+ const env = msg.envelope ?? {};
185
+ const fromAddr = env.from?.[0]?.address ?? null;
186
+ const fromName = env.from?.[0]?.name ?? null;
187
+
188
+ // Skip blocked senders
189
+ if (fromAddr && isSenderBlocked(accountId, fromAddr)) {
190
+ synced++;
191
+ continue;
192
+ }
193
+
194
+ // Check if already synced
195
+ if (messageExists(accountId, folderPath, msg.uid)) { synced++; continue; }
196
+
197
+ // Fetch full body for this message
198
+ let bodyText = null, bodyHtml = null, bodyPreview = '', attachments = [];
199
+ let inReplyTo = null, references = [], msgId = null;
200
+
201
+ try {
202
+ const dl = await client.download(String(msg.uid), undefined, { uid: true });
203
+ if (dl) {
204
+ const chunks = [];
205
+ let totalSize = 0;
206
+ for await (const chunk of dl.content) {
207
+ totalSize += chunk.length;
208
+ if (totalSize > 25 * 1024 * 1024) break; // 25MB cap
209
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
210
+ }
211
+ const raw = Buffer.concat(chunks);
212
+ const parsed = await simpleParser(raw);
213
+ msgId = parsed.messageId || null;
214
+ inReplyTo = typeof parsed.inReplyTo === 'string' ? parsed.inReplyTo : (Array.isArray(parsed.inReplyTo) ? parsed.inReplyTo[0] : null);
215
+ references = Array.isArray(parsed.references) ? parsed.references.slice(0, 20) : (parsed.references ? [parsed.references] : []);
216
+ bodyText = parsed.text?.replace(/\x00/g, '') || null;
217
+ bodyHtml = parsed.html?.replace(/\x00/g, '') || null;
218
+ bodyPreview = (bodyText || '').replace(/\s+/g, ' ').trim().slice(0, 255);
219
+
220
+ if (parsed.attachments) {
221
+ for (const att of parsed.attachments) {
222
+ attachments.push({
223
+ filename: att.filename || null,
224
+ content_type: att.contentType || 'application/octet-stream',
225
+ size_bytes: att.size || 0,
226
+ part_id: att.partId || '',
227
+ content_id: att.contentId || null,
228
+ content: att.size < 5 * 1024 * 1024 ? att.content : null,
229
+ });
230
+ }
231
+ }
232
+ }
233
+ } catch (err) {
234
+ console.warn(`[email:imap] Failed to fetch body uid=${msg.uid}:`, err.message);
235
+ }
236
+
237
+ const toAddresses = (env.to || []).map(a => ({ address: a.address, name: a.name }));
238
+ const ccAddresses = (env.cc || []).map(a => ({ address: a.address, name: a.name }));
239
+ const bccAddresses = (env.bcc || []).map(a => ({ address: a.address, name: a.name }));
240
+ const subject = env.subject || '';
241
+ const internalDate = (msg.internalDate || new Date()).toISOString();
242
+ const tid = threadId(msgId, inReplyTo, references, fromAddr, internalDate, subject);
243
+ const hash = contentHash(msgId || '', fromAddr || '', internalDate);
244
+
245
+ const folderRec = upsertFolder(accountId, folderPath, folderPath, folderMeta?.folder_type || 'custom', currentUidValidity, newLastUid);
246
+
247
+ const msgDbId = insertMessage({
248
+ account_id: accountId,
249
+ folder_id: folderRec,
250
+ imap_folder_path: folderPath,
251
+ uid: msg.uid,
252
+ message_id: msgId,
253
+ in_reply_to: inReplyTo,
254
+ references_list: references,
255
+ thread_id: tid,
256
+ subject,
257
+ from_address: fromAddr,
258
+ from_name: fromName,
259
+ to_addresses: toAddresses,
260
+ cc_addresses: ccAddresses,
261
+ bcc_addresses: bccAddresses,
262
+ body_text: bodyText,
263
+ body_html: bodyHtml,
264
+ body_preview: bodyPreview,
265
+ body_reply_only: stripQuotedReplies(bodyText),
266
+ size_bytes: msg.size || 0,
267
+ has_attachments: attachments.length > 0,
268
+ content_hash: hash,
269
+ internal_date: internalDate,
270
+ source: 'imap',
271
+ });
272
+
273
+ if (attachments.length > 0) insertAttachments(msgDbId, attachments);
274
+
275
+ // Apply archiving rules (first match wins, removes from inbox)
276
+ const archived = applyArchivingRules(accountId, msgDbId, fromAddr, subject);
277
+ if (!archived) {
278
+ // Assign label based on folder type
279
+ const isSent = folderPath.toLowerCase().includes('sent') || folderPath.toLowerCase().includes('inviati');
280
+ const targetLabel = isSent ? sentLabel : inboxLabel;
281
+ if (targetLabel) addMessageToLabel(msgDbId, targetLabel.id);
282
+ }
283
+
284
+ synced++;
285
+ }
286
+
287
+ // Update folder sync state
288
+ upsertFolder(accountId, folderPath,
289
+ folderPath, folderMeta?.folder_type || 'custom',
290
+ currentUidValidity, newLastUid);
291
+
292
+ return { synced, lastUid: newLastUid };
293
+ } finally {
294
+ lock.release();
295
+ }
296
+ }
297
+
298
+ export async function syncAccount(accountId) {
299
+ setSyncStatus(accountId, 'syncing', null);
300
+ try {
301
+ // List folders from IMAP and sync INBOX + Sent
302
+ const folders = await listImapFolders(accountId);
303
+ const priority = ['inbox', 'sent'];
304
+ const toSync = [
305
+ ...folders.filter(f => priority.includes(f.folderType)),
306
+ ...folders.filter(f => !priority.includes(f.folderType) && f.folderType !== 'trash'),
307
+ ];
308
+
309
+ let totalSynced = 0;
310
+ for (const f of toSync) {
311
+ try {
312
+ const result = await syncFolder(accountId, f.path, false);
313
+ totalSynced += result.synced;
314
+ } catch (err) {
315
+ console.warn(`[email:imap] Sync folder ${f.path} failed:`, err.message);
316
+ }
317
+ }
318
+ setSyncStatus(accountId, 'idle', null);
319
+ return { synced: totalSynced };
320
+ } catch (err) {
321
+ setSyncStatus(accountId, 'error', err.message);
322
+ throw err;
323
+ }
324
+ }
325
+
326
+ // ── IDLE (push notifications for new messages) ─────────────────────────────
327
+
328
+ export async function startIdle(accountId) {
329
+ await connectIdle(accountId);
330
+ }
331
+
332
+ async function connectIdle(accountId) {
333
+ const creds = getAccountCredentials(accountId);
334
+ if (!creds?.imap_host) return;
335
+
336
+ try {
337
+ const existing = idleClients.get(accountId);
338
+ if (existing) { try { await existing.logout(); } catch {} }
339
+
340
+ const client = createImapClient(`idle:${accountId.slice(0, 8)}`, creds);
341
+ await client.connect();
342
+ idleClients.set(accountId, client);
343
+ reconnectAttempts.set(accountId, 0);
344
+ lastIdleEvents.set(accountId, Date.now());
345
+
346
+ runIdleLoop(accountId).catch(() => scheduleReconnect(accountId));
347
+ } catch (err) {
348
+ console.error(`[email:idle] Connect failed for ${accountId.slice(0, 8)}:`, err.message);
349
+ scheduleReconnect(accountId);
350
+ }
351
+ }
352
+
353
+ async function runIdleLoop(accountId) {
354
+ const client = idleClients.get(accountId);
355
+ if (!client?.usable) return;
356
+ const lock = await client.getMailboxLock('INBOX');
357
+ try {
358
+ client.on('exists', (data) => {
359
+ lastIdleEvents.set(accountId, Date.now());
360
+ if (data.count > data.prevCount) notifyIdle(accountId, data.path);
361
+ });
362
+
363
+ const restart = async () => {
364
+ clearTimeout(idleTimers.get(accountId));
365
+ clearTimeout(heartbeatTimers.get(accountId));
366
+ const c = idleClients.get(accountId);
367
+ if (!c?.usable) { scheduleReconnect(accountId); return; }
368
+ lastIdleEvents.set(accountId, Date.now());
369
+ heartbeatTimers.set(accountId, setTimeout(() => {
370
+ if (Date.now() - (lastIdleEvents.get(accountId) ?? 0) > IDLE_HEARTBEAT_WATCHDOG_MS) {
371
+ scheduleReconnect(accountId);
372
+ }
373
+ }, IDLE_HEARTBEAT_WATCHDOG_MS));
374
+ idleTimers.set(accountId, setTimeout(async () => {
375
+ try { await restart(); } catch { scheduleReconnect(accountId); }
376
+ }, IDLE_RESTART_MS));
377
+ await c.idle();
378
+ };
379
+ await restart();
380
+ } finally {
381
+ lock.release();
382
+ }
383
+ }
384
+
385
+ function scheduleReconnect(accountId) {
386
+ clearTimeout(idleTimers.get(accountId));
387
+ clearTimeout(heartbeatTimers.get(accountId));
388
+ idleTimers.delete(accountId); heartbeatTimers.delete(accountId);
389
+ const attempt = reconnectAttempts.get(accountId) ?? 0;
390
+ const delay = RECONNECT_DELAYS[Math.min(attempt, RECONNECT_DELAYS.length - 1)];
391
+ reconnectAttempts.set(accountId, attempt + 1);
392
+ setTimeout(() => connectIdle(accountId).catch(() => scheduleReconnect(accountId)), delay);
393
+ }
394
+
395
+ export async function stopIdle(accountId) {
396
+ clearTimeout(idleTimers.get(accountId));
397
+ clearTimeout(heartbeatTimers.get(accountId));
398
+ idleTimers.delete(accountId); heartbeatTimers.delete(accountId);
399
+ const c = idleClients.get(accountId);
400
+ if (c) { try { await c.logout(); } catch {}; idleClients.delete(accountId); }
401
+ }
402
+
403
+ export async function stopAllIdle() {
404
+ for (const id of [...idleClients.keys()]) await stopIdle(id);
405
+ }
406
+
407
+ // ── ATTACHMENT FETCH ───────────────────────────────────────────────────────
408
+
409
+ export async function fetchAttachmentContent(accountId, folderPath, uid, partId) {
410
+ const client = await getImapClient(accountId);
411
+ const lock = await client.getMailboxLock(folderPath);
412
+ try {
413
+ const dl = await client.download(String(uid), partId, { uid: true });
414
+ if (!dl) return null;
415
+ const chunks = [];
416
+ for await (const chunk of dl.content) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
417
+ return { buffer: Buffer.concat(chunks), contentType: dl.meta?.contentType };
418
+ } finally {
419
+ lock.release();
420
+ }
421
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * email-smtp.mjs — SMTP send service
3
+ *
4
+ * Nodemailer-based. Fresh transporter per send (avoids stale connection issues).
5
+ * Handles: plain send, reply/forward with threading headers, attachments.
6
+ */
7
+
8
+ import { createTransport } from 'nodemailer';
9
+ import { randomUUID } from 'crypto';
10
+ import {
11
+ getAccountCredentials, insertMessage, addMessageToLabel, getSystemLabel,
12
+ insertAttachments, getDb,
13
+ } from './email-db.mjs';
14
+ import { createHash } from 'crypto';
15
+
16
+ function threadId(messageId) {
17
+ if (!messageId) return createHash('sha1').update(randomUUID()).digest('hex');
18
+ return createHash('sha1').update(messageId).digest('hex');
19
+ }
20
+
21
+ function getTransporter(creds) {
22
+ const isSecure = creds.smtp_port === 465;
23
+ return createTransport({
24
+ host: creds.smtp_host,
25
+ port: creds.smtp_port,
26
+ secure: isSecure,
27
+ auth: { user: creds.username, pass: creds.password },
28
+ connectionTimeout: 15000,
29
+ greetingTimeout: 10000,
30
+ socketTimeout: 20000,
31
+ tls: { rejectUnauthorized: isSecure || creds.smtp_port === 587 },
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Send an email.
37
+ *
38
+ * @param {string} accountId
39
+ * @param {object} opts
40
+ * - to: string | string[]
41
+ * - cc: string | string[] (optional)
42
+ * - bcc: string | string[] (optional)
43
+ * - subject: string
44
+ * - bodyHtml: string
45
+ * - bodyText: string (optional, auto-generated from html if omitted)
46
+ * - inReplyTo: string (Message-ID of original, for replies)
47
+ * - references: string[] (for threading)
48
+ * - attachments: [{filename, content: Buffer|string, contentType}]
49
+ * - replyToMessageDbId: string (DB id of original message, for label assignment)
50
+ */
51
+ export async function sendEmail(accountId, opts) {
52
+ const creds = getAccountCredentials(accountId);
53
+ if (!creds?.smtp_host) throw new Error('No SMTP credentials configured for this account');
54
+
55
+ const msgId = `<${randomUUID()}@nha.local>`;
56
+ const fromLine = creds.from_name ? `${creds.from_name} <${creds.email_address}>` : creds.email_address;
57
+
58
+ const mailOpts = {
59
+ messageId: msgId,
60
+ from: fromLine,
61
+ to: Array.isArray(opts.to) ? opts.to.join(', ') : opts.to,
62
+ subject: opts.subject || '',
63
+ html: opts.bodyHtml || '',
64
+ text: opts.bodyText || stripHtml(opts.bodyHtml || ''),
65
+ envelope: { from: creds.email_address, to: toArray(opts.to) },
66
+ };
67
+
68
+ if (opts.cc?.length) mailOpts.cc = Array.isArray(opts.cc) ? opts.cc.join(', ') : opts.cc;
69
+ if (opts.bcc?.length) mailOpts.bcc = Array.isArray(opts.bcc) ? opts.bcc.join(', ') : opts.bcc;
70
+ if (opts.inReplyTo) mailOpts['In-Reply-To'] = opts.inReplyTo;
71
+ if (opts.references?.length) mailOpts['References'] = opts.references.join(' ');
72
+
73
+ if (opts.attachments?.length) {
74
+ mailOpts.attachments = opts.attachments.map(a => ({
75
+ filename: a.filename,
76
+ content: a.content,
77
+ contentType: a.contentType || 'application/octet-stream',
78
+ }));
79
+ }
80
+
81
+ const transporter = getTransporter(creds);
82
+ let lastError;
83
+ for (let attempt = 0; attempt < 3; attempt++) {
84
+ try {
85
+ await transporter.sendMail(mailOpts);
86
+ break;
87
+ } catch (err) {
88
+ lastError = err;
89
+ if (attempt < 2) await new Promise(r => setTimeout(r, 2000));
90
+ }
91
+ }
92
+ if (lastError) throw lastError;
93
+
94
+ // ── Save to local DB as "sent" ────────────────────────────────────────
95
+ const now = new Date().toISOString();
96
+ const tid = threadId(opts.inReplyTo || msgId);
97
+ const sentLabel = getSystemLabel(accountId, 'sent');
98
+
99
+ const dbId = insertMessage({
100
+ account_id: accountId,
101
+ imap_folder_path: 'Sent',
102
+ uid: Date.now(), // synthetic UID for sent messages
103
+ message_id: msgId,
104
+ in_reply_to: opts.inReplyTo || null,
105
+ references_list: opts.references || [],
106
+ thread_id: tid,
107
+ subject: opts.subject || '',
108
+ from_address: creds.email_address,
109
+ from_name: creds.from_name || null,
110
+ to_addresses: toArray(opts.to).map(a => ({ address: a, name: '' })),
111
+ cc_addresses: toArray(opts.cc).map(a => ({ address: a, name: '' })),
112
+ bcc_addresses: toArray(opts.bcc).map(a => ({ address: a, name: '' })),
113
+ body_html: opts.bodyHtml || null,
114
+ body_text: opts.bodyText || null,
115
+ body_preview: (opts.bodyText || stripHtml(opts.bodyHtml || '')).slice(0, 255),
116
+ size_bytes: (opts.bodyHtml || '').length,
117
+ has_attachments: (opts.attachments?.length || 0) > 0,
118
+ internal_date: now,
119
+ source: 'sent',
120
+ });
121
+
122
+ if (sentLabel) addMessageToLabel(dbId, sentLabel.id);
123
+
124
+ return { messageId: msgId, dbId };
125
+ }
126
+
127
+ function toArray(v) {
128
+ if (!v) return [];
129
+ if (Array.isArray(v)) return v.map(x => typeof x === 'string' ? x : x.address || x.email || '').filter(Boolean);
130
+ return v.split(',').map(s => s.trim()).filter(Boolean);
131
+ }
132
+
133
+ function stripHtml(html) {
134
+ return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
135
+ }
@@ -8,10 +8,15 @@
8
8
  // ── Providers ──────────────────────────────────────────────────────────────
9
9
 
10
10
  export async function callAnthropic(apiKey, model, systemPrompt, userMessage, stream = false, opts = {}) {
11
+ // Use Anthropic prompt caching: system prompt as array with cache_control
12
+ // so the same system prompt is served from cache on repeated calls (~90% saving on input tokens).
13
+ const systemBlocks = systemPrompt
14
+ ? [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }]
15
+ : [];
11
16
  const body = {
12
17
  model: model || 'claude-sonnet-4-20250514',
13
18
  max_tokens: opts.max_tokens || 8192,
14
- system: systemPrompt,
19
+ system: systemBlocks,
15
20
  messages: [{ role: 'user', content: userMessage }],
16
21
  stream,
17
22
  };
@@ -22,6 +27,7 @@ export async function callAnthropic(apiKey, model, systemPrompt, userMessage, st
22
27
  'Content-Type': 'application/json',
23
28
  'x-api-key': apiKey,
24
29
  'anthropic-version': '2023-06-01',
30
+ 'anthropic-beta': 'prompt-caching-2024-07-31',
25
31
  },
26
32
  body: JSON.stringify(body),
27
33
  });