@diskd-ai/email-mcp 0.3.0
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/LICENSE +155 -0
- package/README.md +820 -0
- package/dist/cli/account-commands.d.ts +10 -0
- package/dist/cli/account-commands.d.ts.map +1 -0
- package/dist/cli/account-commands.js +703 -0
- package/dist/cli/account-commands.js.map +1 -0
- package/dist/cli/config-commands.d.ts +9 -0
- package/dist/cli/config-commands.d.ts.map +1 -0
- package/dist/cli/config-commands.js +156 -0
- package/dist/cli/config-commands.js.map +1 -0
- package/dist/cli/guard.d.ts +11 -0
- package/dist/cli/guard.d.ts.map +1 -0
- package/dist/cli/guard.js +18 -0
- package/dist/cli/guard.js.map +1 -0
- package/dist/cli/install-commands.d.ts +12 -0
- package/dist/cli/install-commands.d.ts.map +1 -0
- package/dist/cli/install-commands.js +320 -0
- package/dist/cli/install-commands.js.map +1 -0
- package/dist/cli/notify.d.ts +8 -0
- package/dist/cli/notify.d.ts.map +1 -0
- package/dist/cli/notify.js +143 -0
- package/dist/cli/notify.js.map +1 -0
- package/dist/cli/providers.d.ts +13 -0
- package/dist/cli/providers.d.ts.map +1 -0
- package/dist/cli/providers.js +180 -0
- package/dist/cli/providers.js.map +1 -0
- package/dist/cli/scheduler.d.ts +8 -0
- package/dist/cli/scheduler.d.ts.map +1 -0
- package/dist/cli/scheduler.js +268 -0
- package/dist/cli/scheduler.js.map +1 -0
- package/dist/cli/setup.d.ts +12 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +15 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/test.d.ts +7 -0
- package/dist/cli/test.d.ts.map +1 -0
- package/dist/cli/test.js +67 -0
- package/dist/cli/test.js.map +1 -0
- package/dist/config/loader.d.ts +34 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +339 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/schema.d.ts +351 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +165 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/xdg.d.ts +27 -0
- package/dist/config/xdg.d.ts.map +1 -0
- package/dist/config/xdg.js +30 -0
- package/dist/config/xdg.js.map +1 -0
- package/dist/connections/manager.d.ts +42 -0
- package/dist/connections/manager.d.ts.map +1 -0
- package/dist/connections/manager.js +266 -0
- package/dist/connections/manager.js.map +1 -0
- package/dist/connections/types.d.ts +13 -0
- package/dist/connections/types.d.ts.map +1 -0
- package/dist/connections/types.js +2 -0
- package/dist/connections/types.js.map +1 -0
- package/dist/logging.d.ts +46 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +63 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.d.ts +13 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +206 -0
- package/dist/main.js.map +1 -0
- package/dist/prompts/actions.prompt.d.ts +9 -0
- package/dist/prompts/actions.prompt.d.ts.map +1 -0
- package/dist/prompts/actions.prompt.js +64 -0
- package/dist/prompts/actions.prompt.js.map +1 -0
- package/dist/prompts/calendar.prompt.d.ts +9 -0
- package/dist/prompts/calendar.prompt.d.ts.map +1 -0
- package/dist/prompts/calendar.prompt.js +55 -0
- package/dist/prompts/calendar.prompt.js.map +1 -0
- package/dist/prompts/cleanup.prompt.d.ts +9 -0
- package/dist/prompts/cleanup.prompt.d.ts.map +1 -0
- package/dist/prompts/cleanup.prompt.js +78 -0
- package/dist/prompts/cleanup.prompt.js.map +1 -0
- package/dist/prompts/compose.prompt.d.ts +8 -0
- package/dist/prompts/compose.prompt.d.ts.map +1 -0
- package/dist/prompts/compose.prompt.js +116 -0
- package/dist/prompts/compose.prompt.js.map +1 -0
- package/dist/prompts/register.d.ts +8 -0
- package/dist/prompts/register.d.ts.map +1 -0
- package/dist/prompts/register.js +20 -0
- package/dist/prompts/register.js.map +1 -0
- package/dist/prompts/thread.prompt.d.ts +9 -0
- package/dist/prompts/thread.prompt.d.ts.map +1 -0
- package/dist/prompts/thread.prompt.js +58 -0
- package/dist/prompts/thread.prompt.js.map +1 -0
- package/dist/prompts/triage.prompt.d.ts +9 -0
- package/dist/prompts/triage.prompt.d.ts.map +1 -0
- package/dist/prompts/triage.prompt.js +64 -0
- package/dist/prompts/triage.prompt.js.map +1 -0
- package/dist/resources/accounts.resource.d.ts +9 -0
- package/dist/resources/accounts.resource.d.ts.map +1 -0
- package/dist/resources/accounts.resource.js +26 -0
- package/dist/resources/accounts.resource.js.map +1 -0
- package/dist/resources/mailboxes.resource.d.ts +10 -0
- package/dist/resources/mailboxes.resource.d.ts.map +1 -0
- package/dist/resources/mailboxes.resource.js +33 -0
- package/dist/resources/mailboxes.resource.js.map +1 -0
- package/dist/resources/register.d.ts +12 -0
- package/dist/resources/register.d.ts.map +1 -0
- package/dist/resources/register.js +20 -0
- package/dist/resources/register.js.map +1 -0
- package/dist/resources/scheduled.resource.d.ts +9 -0
- package/dist/resources/scheduled.resource.d.ts.map +1 -0
- package/dist/resources/scheduled.resource.js +31 -0
- package/dist/resources/scheduled.resource.js.map +1 -0
- package/dist/resources/stats.resource.d.ts +10 -0
- package/dist/resources/stats.resource.d.ts.map +1 -0
- package/dist/resources/stats.resource.js +45 -0
- package/dist/resources/stats.resource.js.map +1 -0
- package/dist/resources/templates.resource.d.ts +9 -0
- package/dist/resources/templates.resource.d.ts.map +1 -0
- package/dist/resources/templates.resource.js +34 -0
- package/dist/resources/templates.resource.js.map +1 -0
- package/dist/resources/unread.resource.d.ts +11 -0
- package/dist/resources/unread.resource.d.ts.map +1 -0
- package/dist/resources/unread.resource.js +46 -0
- package/dist/resources/unread.resource.js.map +1 -0
- package/dist/safety/audit.d.ts +17 -0
- package/dist/safety/audit.d.ts.map +1 -0
- package/dist/safety/audit.js +50 -0
- package/dist/safety/audit.js.map +1 -0
- package/dist/safety/rate-limiter.d.ts +22 -0
- package/dist/safety/rate-limiter.d.ts.map +1 -0
- package/dist/safety/rate-limiter.js +52 -0
- package/dist/safety/rate-limiter.js.map +1 -0
- package/dist/safety/validation.d.ts +44 -0
- package/dist/safety/validation.d.ts.map +1 -0
- package/dist/safety/validation.js +113 -0
- package/dist/safety/validation.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +25 -0
- package/dist/server.js.map +1 -0
- package/dist/services/calendar.service.d.ts +22 -0
- package/dist/services/calendar.service.d.ts.map +1 -0
- package/dist/services/calendar.service.js +115 -0
- package/dist/services/calendar.service.js.map +1 -0
- package/dist/services/event-bus.d.ts +28 -0
- package/dist/services/event-bus.d.ts.map +1 -0
- package/dist/services/event-bus.js +16 -0
- package/dist/services/event-bus.js.map +1 -0
- package/dist/services/hooks.service.d.ts +77 -0
- package/dist/services/hooks.service.d.ts.map +1 -0
- package/dist/services/hooks.service.js +498 -0
- package/dist/services/hooks.service.js.map +1 -0
- package/dist/services/imap.service.d.ts +133 -0
- package/dist/services/imap.service.d.ts.map +1 -0
- package/dist/services/imap.service.js +1393 -0
- package/dist/services/imap.service.js.map +1 -0
- package/dist/services/label-strategy.d.ts +20 -0
- package/dist/services/label-strategy.d.ts.map +1 -0
- package/dist/services/label-strategy.js +237 -0
- package/dist/services/label-strategy.js.map +1 -0
- package/dist/services/local-calendar.service.d.ts +126 -0
- package/dist/services/local-calendar.service.d.ts.map +1 -0
- package/dist/services/local-calendar.service.js +428 -0
- package/dist/services/local-calendar.service.js.map +1 -0
- package/dist/services/notifier.service.d.ts +69 -0
- package/dist/services/notifier.service.d.ts.map +1 -0
- package/dist/services/notifier.service.js +319 -0
- package/dist/services/notifier.service.js.map +1 -0
- package/dist/services/oauth.service.d.ts +47 -0
- package/dist/services/oauth.service.d.ts.map +1 -0
- package/dist/services/oauth.service.js +140 -0
- package/dist/services/oauth.service.js.map +1 -0
- package/dist/services/presets.d.ts +36 -0
- package/dist/services/presets.d.ts.map +1 -0
- package/dist/services/presets.js +173 -0
- package/dist/services/presets.js.map +1 -0
- package/dist/services/reminders.service.d.ts +63 -0
- package/dist/services/reminders.service.d.ts.map +1 -0
- package/dist/services/reminders.service.js +281 -0
- package/dist/services/reminders.service.js.map +1 -0
- package/dist/services/scheduler.service.d.ts +42 -0
- package/dist/services/scheduler.service.d.ts.map +1 -0
- package/dist/services/scheduler.service.js +260 -0
- package/dist/services/scheduler.service.js.map +1 -0
- package/dist/services/smtp.service.d.ts +40 -0
- package/dist/services/smtp.service.d.ts.map +1 -0
- package/dist/services/smtp.service.js +151 -0
- package/dist/services/smtp.service.js.map +1 -0
- package/dist/services/template.service.d.ts +33 -0
- package/dist/services/template.service.d.ts.map +1 -0
- package/dist/services/template.service.js +123 -0
- package/dist/services/template.service.js.map +1 -0
- package/dist/services/watcher.service.d.ts +36 -0
- package/dist/services/watcher.service.d.ts.map +1 -0
- package/dist/services/watcher.service.js +241 -0
- package/dist/services/watcher.service.js.map +1 -0
- package/dist/tools/accounts.tool.d.ts +7 -0
- package/dist/tools/accounts.tool.d.ts.map +1 -0
- package/dist/tools/accounts.tool.js +29 -0
- package/dist/tools/accounts.tool.js.map +1 -0
- package/dist/tools/analytics.tool.d.ts +9 -0
- package/dist/tools/analytics.tool.d.ts.map +1 -0
- package/dist/tools/analytics.tool.js +27 -0
- package/dist/tools/analytics.tool.js.map +1 -0
- package/dist/tools/attachments.tool.d.ts +7 -0
- package/dist/tools/attachments.tool.d.ts.map +1 -0
- package/dist/tools/attachments.tool.js +45 -0
- package/dist/tools/attachments.tool.js.map +1 -0
- package/dist/tools/bulk.tool.d.ts +7 -0
- package/dist/tools/bulk.tool.d.ts.map +1 -0
- package/dist/tools/bulk.tool.js +75 -0
- package/dist/tools/bulk.tool.js.map +1 -0
- package/dist/tools/calendar.tool.d.ts +19 -0
- package/dist/tools/calendar.tool.d.ts.map +1 -0
- package/dist/tools/calendar.tool.js +538 -0
- package/dist/tools/calendar.tool.js.map +1 -0
- package/dist/tools/contacts.tool.d.ts +7 -0
- package/dist/tools/contacts.tool.d.ts.map +1 -0
- package/dist/tools/contacts.tool.js +44 -0
- package/dist/tools/contacts.tool.js.map +1 -0
- package/dist/tools/drafts.tool.d.ts +8 -0
- package/dist/tools/drafts.tool.d.ts.map +1 -0
- package/dist/tools/drafts.tool.js +92 -0
- package/dist/tools/drafts.tool.js.map +1 -0
- package/dist/tools/emails.tool.d.ts +7 -0
- package/dist/tools/emails.tool.d.ts.map +1 -0
- package/dist/tools/emails.tool.js +400 -0
- package/dist/tools/emails.tool.js.map +1 -0
- package/dist/tools/folders.tool.d.ts +7 -0
- package/dist/tools/folders.tool.d.ts.map +1 -0
- package/dist/tools/folders.tool.js +111 -0
- package/dist/tools/folders.tool.js.map +1 -0
- package/dist/tools/health.tool.d.ts +10 -0
- package/dist/tools/health.tool.d.ts.map +1 -0
- package/dist/tools/health.tool.js +78 -0
- package/dist/tools/health.tool.js.map +1 -0
- package/dist/tools/label.tool.d.ts +11 -0
- package/dist/tools/label.tool.d.ts.map +1 -0
- package/dist/tools/label.tool.js +165 -0
- package/dist/tools/label.tool.js.map +1 -0
- package/dist/tools/locate.tool.d.ts +11 -0
- package/dist/tools/locate.tool.d.ts.map +1 -0
- package/dist/tools/locate.tool.js +59 -0
- package/dist/tools/locate.tool.js.map +1 -0
- package/dist/tools/mailboxes.tool.d.ts +7 -0
- package/dist/tools/mailboxes.tool.d.ts.map +1 -0
- package/dist/tools/mailboxes.tool.js +38 -0
- package/dist/tools/mailboxes.tool.js.map +1 -0
- package/dist/tools/manage.tool.d.ts +7 -0
- package/dist/tools/manage.tool.d.ts.map +1 -0
- package/dist/tools/manage.tool.js +125 -0
- package/dist/tools/manage.tool.js.map +1 -0
- package/dist/tools/register.d.ts +20 -0
- package/dist/tools/register.d.ts.map +1 -0
- package/dist/tools/register.js +53 -0
- package/dist/tools/register.js.map +1 -0
- package/dist/tools/scheduler.tool.d.ts +9 -0
- package/dist/tools/scheduler.tool.d.ts.map +1 -0
- package/dist/tools/scheduler.tool.js +104 -0
- package/dist/tools/scheduler.tool.js.map +1 -0
- package/dist/tools/send.tool.d.ts +7 -0
- package/dist/tools/send.tool.d.ts.map +1 -0
- package/dist/tools/send.tool.js +123 -0
- package/dist/tools/send.tool.js.map +1 -0
- package/dist/tools/templates.tool.d.ts +12 -0
- package/dist/tools/templates.tool.d.ts.map +1 -0
- package/dist/tools/templates.tool.js +140 -0
- package/dist/tools/templates.tool.js.map +1 -0
- package/dist/tools/thread.tool.d.ts +10 -0
- package/dist/tools/thread.tool.d.ts.map +1 -0
- package/dist/tools/thread.tool.js +146 -0
- package/dist/tools/thread.tool.js.map +1 -0
- package/dist/tools/watcher.tool.d.ts +9 -0
- package/dist/tools/watcher.tool.d.ts.map +1 -0
- package/dist/tools/watcher.tool.js +282 -0
- package/dist/tools/watcher.tool.js.map +1 -0
- package/dist/types/index.d.ts +271 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/calendar-notes.d.ts +31 -0
- package/dist/utils/calendar-notes.d.ts.map +1 -0
- package/dist/utils/calendar-notes.js +99 -0
- package/dist/utils/calendar-notes.js.map +1 -0
- package/dist/utils/calendar-state.d.ts +27 -0
- package/dist/utils/calendar-state.d.ts.map +1 -0
- package/dist/utils/calendar-state.js +85 -0
- package/dist/utils/calendar-state.js.map +1 -0
- package/dist/utils/conference-details.d.ts +12 -0
- package/dist/utils/conference-details.d.ts.map +1 -0
- package/dist/utils/conference-details.js +71 -0
- package/dist/utils/conference-details.js.map +1 -0
- package/dist/utils/meeting-url.d.ts +10 -0
- package/dist/utils/meeting-url.d.ts.map +1 -0
- package/dist/utils/meeting-url.js +30 -0
- package/dist/utils/meeting-url.js.map +1 -0
- package/package.json +108 -0
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IMAP service — pure business logic for email read operations.
|
|
3
|
+
*
|
|
4
|
+
* No MCP dependency — fully unit-testable.
|
|
5
|
+
*/
|
|
6
|
+
import { sanitizeMailboxName, sanitizeSearchQuery } from '../safety/validation.js';
|
|
7
|
+
import { detectLabelStrategy } from './label-strategy.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers (must be defined before ImapService)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
function parseAddress(addr) {
|
|
12
|
+
return {
|
|
13
|
+
name: addr?.name ?? undefined,
|
|
14
|
+
address: addr?.address ?? 'unknown',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function parseAddresses(addrs) {
|
|
18
|
+
if (!addrs)
|
|
19
|
+
return [];
|
|
20
|
+
return addrs.map(parseAddress);
|
|
21
|
+
}
|
|
22
|
+
function hasAttachments(bodyStructure) {
|
|
23
|
+
if (!bodyStructure || typeof bodyStructure !== 'object')
|
|
24
|
+
return false;
|
|
25
|
+
const bs = bodyStructure;
|
|
26
|
+
if (bs.disposition === 'attachment')
|
|
27
|
+
return true;
|
|
28
|
+
if (Array.isArray(bs.childNodes)) {
|
|
29
|
+
return bs.childNodes.some((child) => hasAttachments(child));
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
function extractAttachments(bodyStructure) {
|
|
34
|
+
const attachments = [];
|
|
35
|
+
if (!bodyStructure || typeof bodyStructure !== 'object')
|
|
36
|
+
return attachments;
|
|
37
|
+
const bs = bodyStructure;
|
|
38
|
+
if (bs.disposition === 'attachment') {
|
|
39
|
+
const params = (bs.dispositionParameters ?? bs.parameters ?? {});
|
|
40
|
+
attachments.push({
|
|
41
|
+
filename: params.filename ?? params.name ?? 'unnamed',
|
|
42
|
+
mimeType: `${bs.type ?? 'application'}/${bs.subtype ?? 'octet-stream'}`,
|
|
43
|
+
size: bs.size ?? 0,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(bs.childNodes)) {
|
|
47
|
+
bs.childNodes.forEach((child) => {
|
|
48
|
+
attachments.push(...extractAttachments(child));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return attachments;
|
|
52
|
+
}
|
|
53
|
+
/** Find the MIME part number for an attachment by filename. */
|
|
54
|
+
function findMimePartByFilename(bodyStructure, targetFilename, partPath = '') {
|
|
55
|
+
if (!bodyStructure || typeof bodyStructure !== 'object')
|
|
56
|
+
return undefined;
|
|
57
|
+
const bs = bodyStructure;
|
|
58
|
+
const currentPart = bs.part;
|
|
59
|
+
const effectivePath = currentPart ?? partPath;
|
|
60
|
+
if (bs.disposition === 'attachment') {
|
|
61
|
+
const params = (bs.dispositionParameters ?? bs.parameters ?? {});
|
|
62
|
+
const filename = params.filename ?? params.name ?? 'unnamed';
|
|
63
|
+
if (filename === targetFilename)
|
|
64
|
+
return effectivePath;
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(bs.childNodes)) {
|
|
67
|
+
// eslint-disable-next-line no-plusplus
|
|
68
|
+
for (let i = 0; i < bs.childNodes.length; i++) {
|
|
69
|
+
const childPart = effectivePath ? `${effectivePath}.${i + 1}` : String(i + 1);
|
|
70
|
+
const found = findMimePartByFilename(bs.childNodes[i], targetFilename, childPart);
|
|
71
|
+
if (found)
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
function messageToEmailMeta(msg) {
|
|
78
|
+
const envelope = (msg.envelope ?? {});
|
|
79
|
+
const flags = new Set((msg.flags ?? []));
|
|
80
|
+
// Extract non-system flags as labels (IMAP keywords)
|
|
81
|
+
const labels = [...flags].filter((f) => !f.startsWith('\\'));
|
|
82
|
+
// Extract preview from source buffer
|
|
83
|
+
let preview;
|
|
84
|
+
if (msg.source && Buffer.isBuffer(msg.source)) {
|
|
85
|
+
const rawText = msg.source.toString('utf-8');
|
|
86
|
+
// Try to extract body text after the header blank line
|
|
87
|
+
const bodyStart = rawText.indexOf('\r\n\r\n');
|
|
88
|
+
if (bodyStart >= 0) {
|
|
89
|
+
preview = rawText
|
|
90
|
+
.slice(bodyStart + 4, bodyStart + 204)
|
|
91
|
+
.replace(/\s+/g, ' ')
|
|
92
|
+
.trim();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
id: String(msg.uid ?? msg.seq),
|
|
97
|
+
subject: envelope.subject ?? '(no subject)',
|
|
98
|
+
from: parseAddress(envelope.from?.[0]),
|
|
99
|
+
to: parseAddresses(envelope.to),
|
|
100
|
+
date: envelope.date
|
|
101
|
+
? new Date(envelope.date).toISOString()
|
|
102
|
+
: new Date().toISOString(),
|
|
103
|
+
seen: flags.has('\\Seen'),
|
|
104
|
+
flagged: flags.has('\\Flagged'),
|
|
105
|
+
answered: flags.has('\\Answered'),
|
|
106
|
+
hasAttachments: hasAttachments(msg.bodyStructure),
|
|
107
|
+
labels,
|
|
108
|
+
preview,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async function messageToEmail(msg, client, uid) {
|
|
112
|
+
const meta = messageToEmailMeta(msg);
|
|
113
|
+
const envelope = (msg.envelope ?? {});
|
|
114
|
+
// Parse full source for body content
|
|
115
|
+
let bodyText;
|
|
116
|
+
let bodyHtml;
|
|
117
|
+
const headers = {};
|
|
118
|
+
if (msg.source && Buffer.isBuffer(msg.source)) {
|
|
119
|
+
const raw = msg.source.toString('utf-8');
|
|
120
|
+
const headerEnd = raw.indexOf('\r\n\r\n');
|
|
121
|
+
if (headerEnd >= 0) {
|
|
122
|
+
// Parse headers
|
|
123
|
+
const headerSection = raw.slice(0, headerEnd);
|
|
124
|
+
headerSection.split('\r\n').forEach((line) => {
|
|
125
|
+
const colonIdx = line.indexOf(':');
|
|
126
|
+
if (colonIdx > 0 && !line.startsWith(' ') && !line.startsWith('\t')) {
|
|
127
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
128
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
129
|
+
headers[key] = value;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
const body = raw.slice(headerEnd + 4);
|
|
133
|
+
// Simple content type detection
|
|
134
|
+
const contentType = headers['content-type'] ?? '';
|
|
135
|
+
if (contentType.includes('text/html')) {
|
|
136
|
+
bodyHtml = body;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
bodyText = body;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Try to get text/html parts via download if body parsing was simple
|
|
144
|
+
try {
|
|
145
|
+
const textPart = await client.download(String(uid), '1', { uid: true });
|
|
146
|
+
if (textPart?.content) {
|
|
147
|
+
const chunks = [];
|
|
148
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
149
|
+
for await (const chunk of textPart.content) {
|
|
150
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
151
|
+
}
|
|
152
|
+
bodyText = Buffer.concat(chunks).toString('utf-8');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Part may not exist
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
...meta,
|
|
160
|
+
cc: parseAddresses(envelope.cc),
|
|
161
|
+
bcc: parseAddresses(envelope.bcc),
|
|
162
|
+
bodyText,
|
|
163
|
+
bodyHtml,
|
|
164
|
+
messageId: envelope.messageId ?? '',
|
|
165
|
+
inReplyTo: envelope.inReplyTo ?? undefined,
|
|
166
|
+
references: headers.references?.split(/\s+/).filter(Boolean),
|
|
167
|
+
attachments: extractAttachments(msg.bodyStructure),
|
|
168
|
+
headers,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Service
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
export default class ImapService {
|
|
175
|
+
connections;
|
|
176
|
+
labelStrategies = new Map();
|
|
177
|
+
labelStrategyPending = new Map();
|
|
178
|
+
constructor(connections) {
|
|
179
|
+
this.connections = connections;
|
|
180
|
+
}
|
|
181
|
+
async getLabelStrategy(accountName) {
|
|
182
|
+
const cached = this.labelStrategies.get(accountName);
|
|
183
|
+
if (cached)
|
|
184
|
+
return cached;
|
|
185
|
+
// Deduplicate concurrent detection for the same account
|
|
186
|
+
const pending = this.labelStrategyPending.get(accountName);
|
|
187
|
+
if (pending)
|
|
188
|
+
return pending;
|
|
189
|
+
const promise = (async () => {
|
|
190
|
+
const client = await this.connections.getImapClient(accountName);
|
|
191
|
+
const strategy = await detectLabelStrategy(client);
|
|
192
|
+
this.labelStrategies.set(accountName, strategy);
|
|
193
|
+
this.labelStrategyPending.delete(accountName);
|
|
194
|
+
return strategy;
|
|
195
|
+
})();
|
|
196
|
+
this.labelStrategyPending.set(accountName, promise);
|
|
197
|
+
return promise;
|
|
198
|
+
}
|
|
199
|
+
// -------------------------------------------------------------------------
|
|
200
|
+
// Mailboxes
|
|
201
|
+
// -------------------------------------------------------------------------
|
|
202
|
+
async listMailboxes(accountName) {
|
|
203
|
+
const client = await this.connections.getImapClient(accountName);
|
|
204
|
+
const mailboxes = await client.list();
|
|
205
|
+
const statusResults = await Promise.allSettled(mailboxes.map(async (mb) => {
|
|
206
|
+
const status = await client.status(mb.path, {
|
|
207
|
+
messages: true,
|
|
208
|
+
unseen: true,
|
|
209
|
+
});
|
|
210
|
+
return {
|
|
211
|
+
name: mb.name,
|
|
212
|
+
path: mb.path,
|
|
213
|
+
specialUse: mb.specialUse ?? undefined,
|
|
214
|
+
totalMessages: status.messages ?? 0,
|
|
215
|
+
unseenMessages: status.unseen ?? 0,
|
|
216
|
+
};
|
|
217
|
+
}));
|
|
218
|
+
return statusResults.map((result, idx) => {
|
|
219
|
+
if (result.status === 'fulfilled') {
|
|
220
|
+
return result.value;
|
|
221
|
+
}
|
|
222
|
+
// Fallback for folders that don't support STATUS (e.g. \Noselect)
|
|
223
|
+
const mb = mailboxes[idx];
|
|
224
|
+
return {
|
|
225
|
+
name: mb.name,
|
|
226
|
+
path: mb.path,
|
|
227
|
+
specialUse: mb.specialUse ?? undefined,
|
|
228
|
+
totalMessages: 0,
|
|
229
|
+
unseenMessages: 0,
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// -------------------------------------------------------------------------
|
|
234
|
+
// List emails
|
|
235
|
+
// -------------------------------------------------------------------------
|
|
236
|
+
async listEmails(accountName, options = {}) {
|
|
237
|
+
const client = await this.connections.getImapClient(accountName);
|
|
238
|
+
const mailbox = sanitizeMailboxName(options.mailbox ?? 'INBOX');
|
|
239
|
+
const page = options.page ?? 1;
|
|
240
|
+
const pageSize = options.pageSize ?? 20;
|
|
241
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
242
|
+
try {
|
|
243
|
+
// Build search criteria
|
|
244
|
+
const search = {};
|
|
245
|
+
if (options.since)
|
|
246
|
+
search.since = new Date(options.since);
|
|
247
|
+
if (options.before)
|
|
248
|
+
search.before = new Date(options.before);
|
|
249
|
+
if (options.from)
|
|
250
|
+
search.from = options.from;
|
|
251
|
+
if (options.subject)
|
|
252
|
+
search.subject = options.subject;
|
|
253
|
+
if (options.seen !== undefined)
|
|
254
|
+
search.seen = options.seen;
|
|
255
|
+
if (options.flagged !== undefined)
|
|
256
|
+
search.flagged = options.flagged;
|
|
257
|
+
if (options.answered !== undefined)
|
|
258
|
+
search.answered = options.answered;
|
|
259
|
+
// Search for matching UIDs
|
|
260
|
+
const searchResult = await client.search(search, { uid: true });
|
|
261
|
+
let uids = Array.isArray(searchResult) ? searchResult : [];
|
|
262
|
+
// Post-filter for hasAttachment (IMAP has no native attachment search)
|
|
263
|
+
if (options.hasAttachment !== undefined && uids.length > 0) {
|
|
264
|
+
const filteredUids = [];
|
|
265
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
266
|
+
for await (const msg of client.fetch(uids.join(','), { uid: true, bodyStructure: true }, { uid: true })) {
|
|
267
|
+
const raw = msg;
|
|
268
|
+
if (options.hasAttachment === hasAttachments(raw.bodyStructure)) {
|
|
269
|
+
filteredUids.push(raw.uid);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
uids = filteredUids;
|
|
273
|
+
}
|
|
274
|
+
if (uids.length === 0) {
|
|
275
|
+
return {
|
|
276
|
+
items: [],
|
|
277
|
+
total: 0,
|
|
278
|
+
page,
|
|
279
|
+
pageSize,
|
|
280
|
+
hasMore: false,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// Sort descending (newest first) and paginate
|
|
284
|
+
uids.sort((a, b) => b - a);
|
|
285
|
+
const total = uids.length;
|
|
286
|
+
const start = (page - 1) * pageSize;
|
|
287
|
+
const pageUids = uids.slice(start, start + pageSize);
|
|
288
|
+
if (pageUids.length === 0) {
|
|
289
|
+
return {
|
|
290
|
+
items: [],
|
|
291
|
+
total,
|
|
292
|
+
page,
|
|
293
|
+
pageSize,
|
|
294
|
+
hasMore: false,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const items = [];
|
|
298
|
+
const range = pageUids.join(',');
|
|
299
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
300
|
+
for await (const msg of client.fetch(range, {
|
|
301
|
+
uid: true,
|
|
302
|
+
envelope: true,
|
|
303
|
+
flags: true,
|
|
304
|
+
bodyStructure: true,
|
|
305
|
+
source: { start: 0, maxLength: 256 },
|
|
306
|
+
}, { uid: true })) {
|
|
307
|
+
items.push(messageToEmailMeta(msg));
|
|
308
|
+
}
|
|
309
|
+
// Sort by date descending
|
|
310
|
+
items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
311
|
+
return {
|
|
312
|
+
items,
|
|
313
|
+
total,
|
|
314
|
+
page,
|
|
315
|
+
pageSize,
|
|
316
|
+
hasMore: start + pageSize < total,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
lock.release();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// -------------------------------------------------------------------------
|
|
324
|
+
// Get single email
|
|
325
|
+
// -------------------------------------------------------------------------
|
|
326
|
+
async getEmail(accountName, emailId, mailbox = 'INBOX') {
|
|
327
|
+
const client = await this.connections.getImapClient(accountName);
|
|
328
|
+
const uid = parseInt(emailId, 10);
|
|
329
|
+
const safeMailbox = sanitizeMailboxName(mailbox);
|
|
330
|
+
const lock = await client.getMailboxLock(safeMailbox);
|
|
331
|
+
try {
|
|
332
|
+
const msg = await client.fetchOne(String(uid), {
|
|
333
|
+
uid: true,
|
|
334
|
+
envelope: true,
|
|
335
|
+
flags: true,
|
|
336
|
+
bodyStructure: true,
|
|
337
|
+
source: true,
|
|
338
|
+
}, { uid: true });
|
|
339
|
+
if (!msg) {
|
|
340
|
+
throw new Error(`Email ${emailId} not found in ${mailbox}`);
|
|
341
|
+
}
|
|
342
|
+
return await messageToEmail(msg, client, uid);
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
lock.release();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// -------------------------------------------------------------------------
|
|
349
|
+
// Get email flags (lightweight — no body fetch, no \Seen change)
|
|
350
|
+
// -------------------------------------------------------------------------
|
|
351
|
+
async getEmailFlags(accountName, emailId, mailbox = 'INBOX') {
|
|
352
|
+
const client = await this.connections.getImapClient(accountName);
|
|
353
|
+
const uid = parseInt(emailId, 10);
|
|
354
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
355
|
+
try {
|
|
356
|
+
const msg = await client.fetchOne(String(uid), { uid: true, envelope: true, flags: true }, { uid: true });
|
|
357
|
+
if (!msg) {
|
|
358
|
+
throw new Error(`Email ${emailId} not found in ${mailbox}`);
|
|
359
|
+
}
|
|
360
|
+
const raw = msg;
|
|
361
|
+
const flags = new Set((raw.flags ?? []));
|
|
362
|
+
const labels = [...flags].filter((f) => !f.startsWith('\\'));
|
|
363
|
+
const envelope = (raw.envelope ?? {});
|
|
364
|
+
const fromEntry = envelope.from?.[0];
|
|
365
|
+
let from = '';
|
|
366
|
+
if (fromEntry) {
|
|
367
|
+
from = fromEntry.name
|
|
368
|
+
? `${fromEntry.name} <${fromEntry.address}>`
|
|
369
|
+
: (fromEntry.address ?? '');
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
seen: flags.has('\\Seen'),
|
|
373
|
+
flagged: flags.has('\\Flagged'),
|
|
374
|
+
answered: flags.has('\\Answered'),
|
|
375
|
+
labels,
|
|
376
|
+
subject: envelope.subject ?? '(no subject)',
|
|
377
|
+
from,
|
|
378
|
+
date: envelope.date ? new Date(envelope.date).toISOString() : '',
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
lock.release();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// -------------------------------------------------------------------------
|
|
386
|
+
// Search emails
|
|
387
|
+
// -------------------------------------------------------------------------
|
|
388
|
+
async searchEmails(accountName, query, options = {}) {
|
|
389
|
+
const client = await this.connections.getImapClient(accountName);
|
|
390
|
+
const mailbox = sanitizeMailboxName(options.mailbox ?? 'INBOX');
|
|
391
|
+
const page = options.page ?? 1;
|
|
392
|
+
const pageSize = options.pageSize ?? 20;
|
|
393
|
+
const sanitizedQuery = query ? sanitizeSearchQuery(query) : '';
|
|
394
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
395
|
+
try {
|
|
396
|
+
// Build search criteria — base query OR across subject/from/body
|
|
397
|
+
const baseCriteria = sanitizedQuery
|
|
398
|
+
? { or: [{ subject: sanitizedQuery }, { from: sanitizedQuery }, { body: sanitizedQuery }] }
|
|
399
|
+
: {};
|
|
400
|
+
// Build additional filters as AND conditions
|
|
401
|
+
const andConditions = [baseCriteria];
|
|
402
|
+
if (options.to) {
|
|
403
|
+
andConditions.push({ to: options.to });
|
|
404
|
+
}
|
|
405
|
+
if (options.largerThan !== undefined) {
|
|
406
|
+
andConditions.push({ larger: options.largerThan * 1024 });
|
|
407
|
+
}
|
|
408
|
+
if (options.smallerThan !== undefined) {
|
|
409
|
+
andConditions.push({ smaller: options.smallerThan * 1024 });
|
|
410
|
+
}
|
|
411
|
+
if (options.answered === true) {
|
|
412
|
+
andConditions.push({ answered: true });
|
|
413
|
+
}
|
|
414
|
+
else if (options.answered === false) {
|
|
415
|
+
andConditions.push({ answered: false });
|
|
416
|
+
}
|
|
417
|
+
// Use the combined criteria or just the base
|
|
418
|
+
const searchCriteria = andConditions.length === 1 ? baseCriteria : Object.assign({}, ...andConditions);
|
|
419
|
+
const searchResult = await client.search(searchCriteria, { uid: true });
|
|
420
|
+
let uids = Array.isArray(searchResult) ? searchResult : [];
|
|
421
|
+
// Post-filter for has_attachment if requested (IMAP doesn't have native support)
|
|
422
|
+
if (options.hasAttachment !== undefined && uids.length > 0) {
|
|
423
|
+
const filteredUids = [];
|
|
424
|
+
const checkRange = uids.join(',');
|
|
425
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
426
|
+
for await (const msg of client.fetch(checkRange, { uid: true, bodyStructure: true }, { uid: true })) {
|
|
427
|
+
const raw = msg;
|
|
428
|
+
const msgHasAtt = hasAttachments(raw.bodyStructure);
|
|
429
|
+
if (options.hasAttachment === msgHasAtt) {
|
|
430
|
+
filteredUids.push(raw.uid);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
uids = filteredUids;
|
|
434
|
+
}
|
|
435
|
+
if (uids.length === 0) {
|
|
436
|
+
return {
|
|
437
|
+
items: [],
|
|
438
|
+
total: 0,
|
|
439
|
+
page,
|
|
440
|
+
pageSize,
|
|
441
|
+
hasMore: false,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
uids.sort((a, b) => b - a);
|
|
445
|
+
const total = uids.length;
|
|
446
|
+
const start = (page - 1) * pageSize;
|
|
447
|
+
const pageUids = uids.slice(start, start + pageSize);
|
|
448
|
+
if (pageUids.length === 0) {
|
|
449
|
+
return {
|
|
450
|
+
items: [],
|
|
451
|
+
total,
|
|
452
|
+
page,
|
|
453
|
+
pageSize,
|
|
454
|
+
hasMore: false,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
const items = [];
|
|
458
|
+
const range = pageUids.join(',');
|
|
459
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
460
|
+
for await (const msg of client.fetch(range, {
|
|
461
|
+
uid: true,
|
|
462
|
+
envelope: true,
|
|
463
|
+
flags: true,
|
|
464
|
+
bodyStructure: true,
|
|
465
|
+
source: { start: 0, maxLength: 256 },
|
|
466
|
+
}, { uid: true })) {
|
|
467
|
+
items.push(messageToEmailMeta(msg));
|
|
468
|
+
}
|
|
469
|
+
items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
470
|
+
return {
|
|
471
|
+
items,
|
|
472
|
+
total,
|
|
473
|
+
page,
|
|
474
|
+
pageSize,
|
|
475
|
+
hasMore: start + pageSize < total,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
lock.release();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// -------------------------------------------------------------------------
|
|
483
|
+
// Labels
|
|
484
|
+
// -------------------------------------------------------------------------
|
|
485
|
+
async listLabels(accountName) {
|
|
486
|
+
const strategy = await this.getLabelStrategy(accountName);
|
|
487
|
+
const client = await this.connections.getImapClient(accountName);
|
|
488
|
+
return strategy.listLabels(client);
|
|
489
|
+
}
|
|
490
|
+
async addLabel(accountName, emailId, mailbox, label) {
|
|
491
|
+
const strategy = await this.getLabelStrategy(accountName);
|
|
492
|
+
const client = await this.connections.getImapClient(accountName);
|
|
493
|
+
await strategy.addLabel(client, emailId, mailbox, label);
|
|
494
|
+
}
|
|
495
|
+
async removeLabel(accountName, emailId, mailbox, label) {
|
|
496
|
+
const strategy = await this.getLabelStrategy(accountName);
|
|
497
|
+
const client = await this.connections.getImapClient(accountName);
|
|
498
|
+
await strategy.removeLabel(client, emailId, mailbox, label);
|
|
499
|
+
}
|
|
500
|
+
async createLabel(accountName, name) {
|
|
501
|
+
const strategy = await this.getLabelStrategy(accountName);
|
|
502
|
+
const client = await this.connections.getImapClient(accountName);
|
|
503
|
+
await strategy.createLabel(client, name);
|
|
504
|
+
}
|
|
505
|
+
async deleteLabel(accountName, name) {
|
|
506
|
+
const strategy = await this.getLabelStrategy(accountName);
|
|
507
|
+
const client = await this.connections.getImapClient(accountName);
|
|
508
|
+
await strategy.deleteLabel(client, name);
|
|
509
|
+
}
|
|
510
|
+
// -------------------------------------------------------------------------
|
|
511
|
+
// Virtual-folder detection
|
|
512
|
+
// -------------------------------------------------------------------------
|
|
513
|
+
static VIRTUAL_SPECIAL_USE = new Set(['\\All', '\\Flagged']);
|
|
514
|
+
static async assertRealMailbox(client, mailboxPath) {
|
|
515
|
+
const mailboxes = await client.list();
|
|
516
|
+
const mb = mailboxes.find((m) => m.path === mailboxPath);
|
|
517
|
+
if (!mb)
|
|
518
|
+
return; // unknown — let the server reject if invalid
|
|
519
|
+
const virtualFlag = [...ImapService.VIRTUAL_SPECIAL_USE].find((f) => mb.specialUse === f || mb.flags?.has(f));
|
|
520
|
+
if (virtualFlag) {
|
|
521
|
+
throw new Error(`"${mailboxPath}" is a virtual folder (${virtualFlag}). ` +
|
|
522
|
+
'Use find_email_folder to locate the real folder first.');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// -------------------------------------------------------------------------
|
|
526
|
+
// Find real folder for an email
|
|
527
|
+
// -------------------------------------------------------------------------
|
|
528
|
+
async findEmailFolder(accountName, emailId, sourceMailbox) {
|
|
529
|
+
const client = await this.connections.getImapClient(accountName);
|
|
530
|
+
// 1. Fetch Message-ID from the source mailbox
|
|
531
|
+
let messageId;
|
|
532
|
+
const srcLock = await client.getMailboxLock(sourceMailbox);
|
|
533
|
+
try {
|
|
534
|
+
const msg = await client.fetchOne(emailId, { headers: true }, { uid: true });
|
|
535
|
+
// biome-ignore lint/complexity/useOptionalChain: optional chain breaks TS type narrowing for union with false
|
|
536
|
+
if (msg && msg.headers && Buffer.isBuffer(msg.headers)) {
|
|
537
|
+
const headerText = msg.headers.toString('utf-8');
|
|
538
|
+
const match = /^message-id:\s*(.+)$/im.exec(headerText);
|
|
539
|
+
if (match) {
|
|
540
|
+
messageId = match[1].trim();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
srcLock.release();
|
|
546
|
+
}
|
|
547
|
+
if (!messageId) {
|
|
548
|
+
throw new Error('Could not retrieve Message-ID for this email.');
|
|
549
|
+
}
|
|
550
|
+
// 2. List all real mailboxes (exclude virtual and non-selectable)
|
|
551
|
+
const allMailboxes = await client.list();
|
|
552
|
+
const realMailboxes = allMailboxes.filter((mb) => {
|
|
553
|
+
if (!mb.listed)
|
|
554
|
+
return false;
|
|
555
|
+
if (mb.flags?.has('\\Noselect'))
|
|
556
|
+
return false;
|
|
557
|
+
const isVirtual = [...ImapService.VIRTUAL_SPECIAL_USE].some((f) => mb.specialUse === f || mb.flags?.has(f));
|
|
558
|
+
if (isVirtual)
|
|
559
|
+
return false;
|
|
560
|
+
return true;
|
|
561
|
+
});
|
|
562
|
+
// 3. Search each real mailbox for the Message-ID (sequential — each needs its own lock)
|
|
563
|
+
const folders = [];
|
|
564
|
+
const searchMailbox = async (mbPath) => {
|
|
565
|
+
try {
|
|
566
|
+
const lock = await client.getMailboxLock(mbPath);
|
|
567
|
+
try {
|
|
568
|
+
const results = await client.search({ header: { 'message-id': messageId } }, { uid: true });
|
|
569
|
+
if (results && Array.isArray(results) && results.length > 0) {
|
|
570
|
+
folders.push(mbPath);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
lock.release();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// Skip folders that can't be selected or searched (e.g. \Noselect, INBOX on some providers)
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
582
|
+
for (const mb of realMailboxes) {
|
|
583
|
+
// eslint-disable-next-line no-await-in-loop
|
|
584
|
+
await searchMailbox(mb.path);
|
|
585
|
+
}
|
|
586
|
+
return { folders, messageId };
|
|
587
|
+
}
|
|
588
|
+
// -------------------------------------------------------------------------
|
|
589
|
+
// Move / Delete
|
|
590
|
+
// -------------------------------------------------------------------------
|
|
591
|
+
async moveEmail(accountName, emailId, sourceMailbox, destinationMailbox) {
|
|
592
|
+
const client = await this.connections.getImapClient(accountName);
|
|
593
|
+
const safeSource = sanitizeMailboxName(sourceMailbox);
|
|
594
|
+
const safeDest = sanitizeMailboxName(destinationMailbox);
|
|
595
|
+
await ImapService.assertRealMailbox(client, safeSource);
|
|
596
|
+
const lock = await client.getMailboxLock(safeSource);
|
|
597
|
+
try {
|
|
598
|
+
const ok = await client.messageMove(emailId, safeDest, { uid: true });
|
|
599
|
+
if (!ok) {
|
|
600
|
+
throw new Error(`IMAP server rejected the move from "${safeSource}" to "${safeDest}".`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
lock.release();
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async deleteEmail(accountName, emailId, mailbox = 'INBOX', permanent = false) {
|
|
608
|
+
const client = await this.connections.getImapClient(accountName);
|
|
609
|
+
const safeMailbox = sanitizeMailboxName(mailbox);
|
|
610
|
+
if (permanent) {
|
|
611
|
+
const lock = await client.getMailboxLock(safeMailbox);
|
|
612
|
+
try {
|
|
613
|
+
const ok = await client.messageDelete(emailId, { uid: true });
|
|
614
|
+
if (!ok) {
|
|
615
|
+
throw new Error('IMAP server rejected the delete operation.');
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
lock.release();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
await ImapService.assertRealMailbox(client, safeMailbox);
|
|
624
|
+
const mailboxes = await client.list();
|
|
625
|
+
const trash = mailboxes.find((mb) => mb.specialUse === '\\Trash');
|
|
626
|
+
const trashPath = trash?.path ?? 'Trash';
|
|
627
|
+
const lock = await client.getMailboxLock(safeMailbox);
|
|
628
|
+
try {
|
|
629
|
+
const ok = await client.messageMove(emailId, trashPath, { uid: true });
|
|
630
|
+
if (!ok) {
|
|
631
|
+
throw new Error('IMAP server rejected the move to Trash.');
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
lock.release();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// -------------------------------------------------------------------------
|
|
640
|
+
// Flag management
|
|
641
|
+
// -------------------------------------------------------------------------
|
|
642
|
+
async setFlags(accountName, emailId, mailbox, action) {
|
|
643
|
+
const client = await this.connections.getImapClient(accountName);
|
|
644
|
+
const safeMailbox = sanitizeMailboxName(mailbox);
|
|
645
|
+
const lock = await client.getMailboxLock(safeMailbox);
|
|
646
|
+
try {
|
|
647
|
+
const flagMap = {
|
|
648
|
+
read: { flags: ['\\Seen'], add: true },
|
|
649
|
+
unread: { flags: ['\\Seen'], add: false },
|
|
650
|
+
flag: { flags: ['\\Flagged'], add: true },
|
|
651
|
+
unflag: { flags: ['\\Flagged'], add: false },
|
|
652
|
+
};
|
|
653
|
+
const { flags, add } = flagMap[action];
|
|
654
|
+
let ok;
|
|
655
|
+
if (add) {
|
|
656
|
+
ok = await client.messageFlagsAdd(emailId, flags, { uid: true });
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
ok = await client.messageFlagsRemove(emailId, flags, { uid: true });
|
|
660
|
+
}
|
|
661
|
+
if (!ok) {
|
|
662
|
+
throw new Error(`IMAP server rejected the ${action} flag operation.`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
finally {
|
|
666
|
+
lock.release();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// -------------------------------------------------------------------------
|
|
670
|
+
// Bulk operations
|
|
671
|
+
// -------------------------------------------------------------------------
|
|
672
|
+
async bulkSetFlags(accountName, ids, mailbox, action) {
|
|
673
|
+
const client = await this.connections.getImapClient(accountName);
|
|
674
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
675
|
+
const result = {
|
|
676
|
+
total: ids.length,
|
|
677
|
+
succeeded: 0,
|
|
678
|
+
failed: 0,
|
|
679
|
+
errors: [],
|
|
680
|
+
};
|
|
681
|
+
try {
|
|
682
|
+
const flagMap = {
|
|
683
|
+
mark_read: { flags: ['\\Seen'], add: true },
|
|
684
|
+
mark_unread: { flags: ['\\Seen'], add: false },
|
|
685
|
+
flag: { flags: ['\\Flagged'], add: true },
|
|
686
|
+
unflag: { flags: ['\\Flagged'], add: false },
|
|
687
|
+
};
|
|
688
|
+
const { flags, add } = flagMap[action];
|
|
689
|
+
const range = ids.join(',');
|
|
690
|
+
let ok;
|
|
691
|
+
if (add) {
|
|
692
|
+
ok = await client.messageFlagsAdd(range, flags, { uid: true });
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
ok = await client.messageFlagsRemove(range, flags, { uid: true });
|
|
696
|
+
}
|
|
697
|
+
if (ok) {
|
|
698
|
+
result.succeeded = ids.length;
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
result.failed = ids.length;
|
|
702
|
+
result.errors = ['IMAP server rejected the flag operation.'];
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
result.failed = ids.length;
|
|
707
|
+
result.errors = [err instanceof Error ? err.message : String(err)];
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
lock.release();
|
|
711
|
+
}
|
|
712
|
+
if (result.errors?.length === 0)
|
|
713
|
+
delete result.errors;
|
|
714
|
+
return result;
|
|
715
|
+
}
|
|
716
|
+
async bulkMove(accountName, ids, mailbox, destination) {
|
|
717
|
+
const client = await this.connections.getImapClient(accountName);
|
|
718
|
+
await ImapService.assertRealMailbox(client, mailbox);
|
|
719
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
720
|
+
const result = {
|
|
721
|
+
total: ids.length,
|
|
722
|
+
succeeded: 0,
|
|
723
|
+
failed: 0,
|
|
724
|
+
errors: [],
|
|
725
|
+
};
|
|
726
|
+
try {
|
|
727
|
+
const range = ids.join(',');
|
|
728
|
+
const ok = await client.messageMove(range, destination, { uid: true });
|
|
729
|
+
if (ok) {
|
|
730
|
+
result.succeeded = ids.length;
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
result.failed = ids.length;
|
|
734
|
+
result.errors = ['IMAP server rejected the move operation.'];
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
catch (err) {
|
|
738
|
+
result.failed = ids.length;
|
|
739
|
+
result.errors = [err instanceof Error ? err.message : String(err)];
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
lock.release();
|
|
743
|
+
}
|
|
744
|
+
if (result.errors?.length === 0)
|
|
745
|
+
delete result.errors;
|
|
746
|
+
return result;
|
|
747
|
+
}
|
|
748
|
+
async bulkDelete(accountName, ids, mailbox, permanent = false) {
|
|
749
|
+
const client = await this.connections.getImapClient(accountName);
|
|
750
|
+
const result = {
|
|
751
|
+
total: ids.length,
|
|
752
|
+
succeeded: 0,
|
|
753
|
+
failed: 0,
|
|
754
|
+
errors: [],
|
|
755
|
+
};
|
|
756
|
+
if (permanent) {
|
|
757
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
758
|
+
try {
|
|
759
|
+
const range = ids.join(',');
|
|
760
|
+
const ok = await client.messageDelete(range, { uid: true });
|
|
761
|
+
if (ok) {
|
|
762
|
+
result.succeeded = ids.length;
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
result.failed = ids.length;
|
|
766
|
+
result.errors = ['IMAP server rejected the delete operation.'];
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
catch (err) {
|
|
770
|
+
result.failed = ids.length;
|
|
771
|
+
result.errors = [err instanceof Error ? err.message : String(err)];
|
|
772
|
+
}
|
|
773
|
+
finally {
|
|
774
|
+
lock.release();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
await ImapService.assertRealMailbox(client, mailbox);
|
|
779
|
+
const mailboxes = await client.list();
|
|
780
|
+
const trash = mailboxes.find((mb) => mb.specialUse === '\\Trash');
|
|
781
|
+
const trashPath = trash?.path ?? 'Trash';
|
|
782
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
783
|
+
try {
|
|
784
|
+
const range = ids.join(',');
|
|
785
|
+
const ok = await client.messageMove(range, trashPath, { uid: true });
|
|
786
|
+
if (ok) {
|
|
787
|
+
result.succeeded = ids.length;
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
result.failed = ids.length;
|
|
791
|
+
result.errors = ['IMAP server rejected the move to Trash.'];
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
catch (err) {
|
|
795
|
+
result.failed = ids.length;
|
|
796
|
+
result.errors = [err instanceof Error ? err.message : String(err)];
|
|
797
|
+
}
|
|
798
|
+
finally {
|
|
799
|
+
lock.release();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (result.errors?.length === 0)
|
|
803
|
+
delete result.errors;
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
// -------------------------------------------------------------------------
|
|
807
|
+
// Draft management
|
|
808
|
+
// -------------------------------------------------------------------------
|
|
809
|
+
async saveDraft(accountName, options) {
|
|
810
|
+
const client = await this.connections.getImapClient(accountName);
|
|
811
|
+
const account = this.connections.getAccount(accountName);
|
|
812
|
+
// Find the Drafts folder
|
|
813
|
+
const mailboxes = await client.list();
|
|
814
|
+
const drafts = mailboxes.find((mb) => mb.specialUse === '\\Drafts');
|
|
815
|
+
const draftsPath = drafts?.path ?? 'Drafts';
|
|
816
|
+
// Construct RFC 822 message
|
|
817
|
+
const headers = [
|
|
818
|
+
`From: ${account.fullName ? `"${account.fullName}" <${account.email}>` : account.email}`,
|
|
819
|
+
`To: ${options.to.join(', ')}`,
|
|
820
|
+
`Subject: ${options.subject}`,
|
|
821
|
+
`Date: ${new Date().toUTCString()}`,
|
|
822
|
+
`MIME-Version: 1.0`,
|
|
823
|
+
];
|
|
824
|
+
if (options.cc?.length)
|
|
825
|
+
headers.push(`Cc: ${options.cc.join(', ')}`);
|
|
826
|
+
if (options.bcc?.length)
|
|
827
|
+
headers.push(`Bcc: ${options.bcc.join(', ')}`);
|
|
828
|
+
if (options.inReplyTo)
|
|
829
|
+
headers.push(`In-Reply-To: ${options.inReplyTo}`);
|
|
830
|
+
const contentType = options.html ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8';
|
|
831
|
+
headers.push(`Content-Type: ${contentType}`);
|
|
832
|
+
const rawMessage = `${headers.join('\r\n')}\r\n\r\n${options.body}`;
|
|
833
|
+
const appendResult = await client.append(draftsPath, Buffer.from(rawMessage), [
|
|
834
|
+
'\\Draft',
|
|
835
|
+
'\\Seen',
|
|
836
|
+
]);
|
|
837
|
+
return {
|
|
838
|
+
id: appendResult.uid ?? 0,
|
|
839
|
+
mailbox: draftsPath,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Fetch a draft message for sending.
|
|
844
|
+
* Returns the parsed draft with recipients and content.
|
|
845
|
+
*/
|
|
846
|
+
async fetchDraft(accountName, emailId, mailbox) {
|
|
847
|
+
const client = await this.connections.getImapClient(accountName);
|
|
848
|
+
// Find drafts folder if not specified
|
|
849
|
+
let draftsPath = mailbox;
|
|
850
|
+
if (!draftsPath) {
|
|
851
|
+
const mailboxes = await client.list();
|
|
852
|
+
const draftsFolder = mailboxes.find((mb) => mb.specialUse === '\\Drafts');
|
|
853
|
+
draftsPath = draftsFolder?.path ?? 'Drafts';
|
|
854
|
+
}
|
|
855
|
+
const email = await this.getEmail(accountName, String(emailId), draftsPath);
|
|
856
|
+
return { email, mailbox: draftsPath };
|
|
857
|
+
}
|
|
858
|
+
/** Delete a draft after it has been sent. */
|
|
859
|
+
async deleteDraft(accountName, emailId, mailbox) {
|
|
860
|
+
const client = await this.connections.getImapClient(accountName);
|
|
861
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
862
|
+
try {
|
|
863
|
+
await client.messageDelete(String(emailId), { uid: true });
|
|
864
|
+
}
|
|
865
|
+
finally {
|
|
866
|
+
lock.release();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// -------------------------------------------------------------------------
|
|
870
|
+
// Mailbox (folder) CRUD
|
|
871
|
+
// -------------------------------------------------------------------------
|
|
872
|
+
async createMailbox(accountName, folderPath) {
|
|
873
|
+
const client = await this.connections.getImapClient(accountName);
|
|
874
|
+
await client.mailboxCreate(folderPath);
|
|
875
|
+
}
|
|
876
|
+
async renameMailbox(accountName, folderPath, newPath) {
|
|
877
|
+
const client = await this.connections.getImapClient(accountName);
|
|
878
|
+
await client.mailboxRename(folderPath, newPath);
|
|
879
|
+
}
|
|
880
|
+
async deleteMailbox(accountName, folderPath) {
|
|
881
|
+
const client = await this.connections.getImapClient(accountName);
|
|
882
|
+
await client.mailboxDelete(folderPath);
|
|
883
|
+
}
|
|
884
|
+
// -------------------------------------------------------------------------
|
|
885
|
+
// Attachment download
|
|
886
|
+
// -------------------------------------------------------------------------
|
|
887
|
+
async downloadAttachment(accountName, emailId, mailbox, filename, maxSizeBytes = 5 * 1024 * 1024) {
|
|
888
|
+
const client = await this.connections.getImapClient(accountName);
|
|
889
|
+
const uid = parseInt(emailId, 10);
|
|
890
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
891
|
+
try {
|
|
892
|
+
// Fetch bodyStructure to find the MIME part
|
|
893
|
+
const msg = await client.fetchOne(String(uid), { uid: true, bodyStructure: true }, { uid: true });
|
|
894
|
+
if (!msg) {
|
|
895
|
+
throw new Error(`Email ${emailId} not found in ${mailbox}`);
|
|
896
|
+
}
|
|
897
|
+
const attachments = extractAttachments(msg.bodyStructure);
|
|
898
|
+
const attachment = attachments.find((a) => a.filename === filename);
|
|
899
|
+
if (!attachment) {
|
|
900
|
+
throw new Error(`Attachment "${filename}" not found. Available: ${attachments.map((a) => a.filename).join(', ') || 'none'}`);
|
|
901
|
+
}
|
|
902
|
+
if (attachment.size > maxSizeBytes) {
|
|
903
|
+
throw new Error(`Attachment "${filename}" is ${Math.round(attachment.size / 1024 / 1024)}MB, exceeds ${Math.round(maxSizeBytes / 1024 / 1024)}MB limit`);
|
|
904
|
+
}
|
|
905
|
+
// Find the MIME part number
|
|
906
|
+
const partNumber = findMimePartByFilename(msg.bodyStructure, filename);
|
|
907
|
+
if (!partNumber) {
|
|
908
|
+
throw new Error(`Could not locate MIME part for "${filename}"`);
|
|
909
|
+
}
|
|
910
|
+
// Download the part
|
|
911
|
+
const downloadResult = await client.download(String(uid), partNumber, {
|
|
912
|
+
uid: true,
|
|
913
|
+
});
|
|
914
|
+
if (!downloadResult?.content) {
|
|
915
|
+
throw new Error(`Failed to download attachment "${filename}"`);
|
|
916
|
+
}
|
|
917
|
+
const chunks = [];
|
|
918
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
919
|
+
for await (const chunk of downloadResult.content) {
|
|
920
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
921
|
+
}
|
|
922
|
+
const content = Buffer.concat(chunks);
|
|
923
|
+
return {
|
|
924
|
+
filename: attachment.filename,
|
|
925
|
+
mimeType: attachment.mimeType,
|
|
926
|
+
size: content.length,
|
|
927
|
+
contentBase64: content.toString('base64'),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
finally {
|
|
931
|
+
lock.release();
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// -------------------------------------------------------------------------
|
|
935
|
+
// Save all email attachments to a local directory
|
|
936
|
+
// -------------------------------------------------------------------------
|
|
937
|
+
/**
|
|
938
|
+
* Download and save all non-ICS attachments from an email to a local directory.
|
|
939
|
+
* Returns metadata including the saved file paths and file:// URLs.
|
|
940
|
+
*
|
|
941
|
+
* Attachments larger than maxSizeBytes (default 25 MB) are skipped.
|
|
942
|
+
*/
|
|
943
|
+
async saveEmailAttachments(accountName, emailId, mailbox, destDir, maxSizeBytes = 25 * 1024 * 1024) {
|
|
944
|
+
const client = await this.connections.getImapClient(accountName);
|
|
945
|
+
const uid = parseInt(emailId, 10);
|
|
946
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
947
|
+
let attachmentMetas = [];
|
|
948
|
+
try {
|
|
949
|
+
const msg = await client.fetchOne(String(uid), { uid: true, bodyStructure: true }, { uid: true });
|
|
950
|
+
if (!msg)
|
|
951
|
+
return [];
|
|
952
|
+
// biome-ignore format: line too long; eslint implicit-arrow-linebreak prevents multi-line implicit return
|
|
953
|
+
attachmentMetas = extractAttachments(msg.bodyStructure).filter((a) => a.size <= maxSizeBytes && !a.mimeType.includes('calendar') && !a.filename.toLowerCase().endsWith('.ics'));
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
lock.release();
|
|
957
|
+
}
|
|
958
|
+
if (attachmentMetas.length === 0)
|
|
959
|
+
return [];
|
|
960
|
+
const { mkdir } = await import('node:fs/promises');
|
|
961
|
+
await mkdir(destDir, { recursive: true });
|
|
962
|
+
const results = await Promise.allSettled(attachmentMetas.map(async (meta) => {
|
|
963
|
+
const downloaded = await this.downloadAttachment(accountName, emailId, mailbox, meta.filename, maxSizeBytes);
|
|
964
|
+
const safe = meta.filename.replace(/[/\\?%*:|"<>]/g, '_');
|
|
965
|
+
const localPath = `${destDir}/${safe}`;
|
|
966
|
+
const { writeFile } = await import('node:fs/promises');
|
|
967
|
+
await writeFile(localPath, Buffer.from(downloaded.contentBase64, 'base64'));
|
|
968
|
+
return {
|
|
969
|
+
filename: meta.filename,
|
|
970
|
+
localPath,
|
|
971
|
+
fileUrl: `file://${localPath}`,
|
|
972
|
+
mimeType: meta.mimeType,
|
|
973
|
+
size: downloaded.size,
|
|
974
|
+
};
|
|
975
|
+
}));
|
|
976
|
+
return results
|
|
977
|
+
.filter((r) => r.status === 'fulfilled')
|
|
978
|
+
.map((r) => r.value);
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Reconstruct an email thread by following References / In-Reply-To chains.
|
|
982
|
+
* Searches by Message-ID header for each reference and returns messages in
|
|
983
|
+
* chronological order. Caps at MAX_THREAD_MESSAGES to prevent runaway chains.
|
|
984
|
+
*/
|
|
985
|
+
async getThread(accountName, messageId, mailbox = 'INBOX') {
|
|
986
|
+
const MAX_THREAD_MESSAGES = 50;
|
|
987
|
+
const client = await this.connections.getImapClient(accountName);
|
|
988
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
989
|
+
try {
|
|
990
|
+
// Collect all Message-IDs in the thread
|
|
991
|
+
const targetMsgIds = new Set([messageId]);
|
|
992
|
+
// First, find the root message to get its References chain
|
|
993
|
+
const rootSearch = await client.search({ header: { 'Message-ID': messageId } }, { uid: true });
|
|
994
|
+
const rootUids = Array.isArray(rootSearch) ? rootSearch : [];
|
|
995
|
+
if (rootUids.length > 0) {
|
|
996
|
+
const rootMsg = await client.fetchOne(String(rootUids[0]), { uid: true, envelope: true, source: true }, { uid: true });
|
|
997
|
+
if (rootMsg) {
|
|
998
|
+
const raw = rootMsg;
|
|
999
|
+
const envelope = (raw.envelope ?? {});
|
|
1000
|
+
const inReplyTo = envelope.inReplyTo;
|
|
1001
|
+
if (inReplyTo)
|
|
1002
|
+
targetMsgIds.add(inReplyTo);
|
|
1003
|
+
// Parse References header from source
|
|
1004
|
+
if (raw.source && Buffer.isBuffer(raw.source)) {
|
|
1005
|
+
const src = raw.source.toString('utf-8');
|
|
1006
|
+
const refMatch = /^References:\s*(.+?)(?:\r?\n(?!\s))/ms.exec(src);
|
|
1007
|
+
if (refMatch) {
|
|
1008
|
+
refMatch[1]
|
|
1009
|
+
.split(/\s+/)
|
|
1010
|
+
.filter(Boolean)
|
|
1011
|
+
.forEach((ref) => {
|
|
1012
|
+
targetMsgIds.add(ref);
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Search for all related messages by Message-ID
|
|
1019
|
+
const foundUids = new Set();
|
|
1020
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1021
|
+
for (const msgId of targetMsgIds) {
|
|
1022
|
+
if (foundUids.size >= MAX_THREAD_MESSAGES)
|
|
1023
|
+
break;
|
|
1024
|
+
try {
|
|
1025
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1026
|
+
const searchResult = await client.search({ header: { 'Message-ID': msgId } }, { uid: true });
|
|
1027
|
+
if (Array.isArray(searchResult)) {
|
|
1028
|
+
searchResult.forEach((uid) => {
|
|
1029
|
+
foundUids.add(uid);
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
// Header search may not be supported for all messages
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
// Also search for messages that reference any of our Message-IDs
|
|
1038
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1039
|
+
for (const msgId of targetMsgIds) {
|
|
1040
|
+
if (foundUids.size >= MAX_THREAD_MESSAGES)
|
|
1041
|
+
break;
|
|
1042
|
+
try {
|
|
1043
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1044
|
+
const refSearch = await client.search({ header: { References: msgId } }, { uid: true });
|
|
1045
|
+
if (Array.isArray(refSearch)) {
|
|
1046
|
+
refSearch.forEach((uid) => {
|
|
1047
|
+
foundUids.add(uid);
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1051
|
+
const replySearch = await client.search({ header: { 'In-Reply-To': msgId } }, { uid: true });
|
|
1052
|
+
if (Array.isArray(replySearch)) {
|
|
1053
|
+
replySearch.forEach((uid) => {
|
|
1054
|
+
foundUids.add(uid);
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
catch {
|
|
1059
|
+
// Header search may fail on some servers
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (foundUids.size === 0) {
|
|
1063
|
+
return {
|
|
1064
|
+
threadId: messageId,
|
|
1065
|
+
messages: [],
|
|
1066
|
+
participants: [],
|
|
1067
|
+
messageCount: 0,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
// Fetch full content for all thread messages
|
|
1071
|
+
const uidList = Array.from(foundUids).slice(0, MAX_THREAD_MESSAGES);
|
|
1072
|
+
const range = uidList.join(',');
|
|
1073
|
+
const messages = [];
|
|
1074
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1075
|
+
for await (const msg of client.fetch(range, {
|
|
1076
|
+
uid: true,
|
|
1077
|
+
envelope: true,
|
|
1078
|
+
flags: true,
|
|
1079
|
+
bodyStructure: true,
|
|
1080
|
+
source: true,
|
|
1081
|
+
}, { uid: true })) {
|
|
1082
|
+
const raw = msg;
|
|
1083
|
+
const uid = raw.uid;
|
|
1084
|
+
messages.push(await messageToEmail(raw, client, uid));
|
|
1085
|
+
}
|
|
1086
|
+
// Sort chronologically
|
|
1087
|
+
messages.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
1088
|
+
// Extract unique participants
|
|
1089
|
+
const participantMap = new Map();
|
|
1090
|
+
messages.forEach((email) => {
|
|
1091
|
+
const addParticipant = (addr) => {
|
|
1092
|
+
const key = addr.address.toLowerCase();
|
|
1093
|
+
if (!participantMap.has(key)) {
|
|
1094
|
+
participantMap.set(key, addr);
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
addParticipant(email.from);
|
|
1098
|
+
email.to.forEach(addParticipant);
|
|
1099
|
+
email.cc?.forEach(addParticipant);
|
|
1100
|
+
});
|
|
1101
|
+
return {
|
|
1102
|
+
threadId: messageId,
|
|
1103
|
+
messages,
|
|
1104
|
+
participants: Array.from(participantMap.values()),
|
|
1105
|
+
messageCount: messages.length,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
finally {
|
|
1109
|
+
lock.release();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
// -------------------------------------------------------------------------
|
|
1113
|
+
// Contact extraction
|
|
1114
|
+
// -------------------------------------------------------------------------
|
|
1115
|
+
async extractContacts(accountName, options = {}) {
|
|
1116
|
+
const client = await this.connections.getImapClient(accountName);
|
|
1117
|
+
const mailbox = options.mailbox ?? 'INBOX';
|
|
1118
|
+
const limit = Math.min(options.limit ?? 100, 500);
|
|
1119
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
1120
|
+
try {
|
|
1121
|
+
// Search for all messages, take the latest N
|
|
1122
|
+
const searchResult = await client.search({ all: true }, { uid: true });
|
|
1123
|
+
const uids = Array.isArray(searchResult) ? searchResult : [];
|
|
1124
|
+
if (uids.length === 0)
|
|
1125
|
+
return [];
|
|
1126
|
+
uids.sort((a, b) => b - a);
|
|
1127
|
+
const targetUids = uids.slice(0, limit);
|
|
1128
|
+
const range = targetUids.join(',');
|
|
1129
|
+
const contactMap = new Map();
|
|
1130
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1131
|
+
for await (const msg of client.fetch(range, { uid: true, envelope: true }, { uid: true })) {
|
|
1132
|
+
const envelope = (msg.envelope ?? {});
|
|
1133
|
+
const date = envelope.date ? new Date(envelope.date) : new Date();
|
|
1134
|
+
const addressLists = [
|
|
1135
|
+
envelope.from,
|
|
1136
|
+
envelope.to,
|
|
1137
|
+
envelope.cc,
|
|
1138
|
+
];
|
|
1139
|
+
addressLists.forEach((addrs) => {
|
|
1140
|
+
(addrs ?? []).forEach((addr) => {
|
|
1141
|
+
if (!addr.address)
|
|
1142
|
+
return;
|
|
1143
|
+
const key = addr.address.toLowerCase();
|
|
1144
|
+
const existing = contactMap.get(key);
|
|
1145
|
+
if (existing) {
|
|
1146
|
+
existing.frequency += 1;
|
|
1147
|
+
if (date > existing.lastSeen) {
|
|
1148
|
+
existing.lastSeen = date;
|
|
1149
|
+
if (addr.name)
|
|
1150
|
+
existing.name = addr.name;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
contactMap.set(key, {
|
|
1155
|
+
name: addr.name ?? undefined,
|
|
1156
|
+
email: addr.address,
|
|
1157
|
+
frequency: 1,
|
|
1158
|
+
lastSeen: date,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
// Sort by frequency descending
|
|
1165
|
+
const contacts = Array.from(contactMap.values())
|
|
1166
|
+
.sort((a, b) => b.frequency - a.frequency)
|
|
1167
|
+
.map((c) => ({
|
|
1168
|
+
name: c.name,
|
|
1169
|
+
email: c.email,
|
|
1170
|
+
frequency: c.frequency,
|
|
1171
|
+
lastSeen: c.lastSeen.toISOString(),
|
|
1172
|
+
}));
|
|
1173
|
+
return contacts;
|
|
1174
|
+
}
|
|
1175
|
+
finally {
|
|
1176
|
+
lock.release();
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
// -------------------------------------------------------------------------
|
|
1180
|
+
// Email analytics
|
|
1181
|
+
// -------------------------------------------------------------------------
|
|
1182
|
+
async getEmailStats(accountName, mailbox, period) {
|
|
1183
|
+
const client = await this.connections.getImapClient(accountName);
|
|
1184
|
+
const now = new Date();
|
|
1185
|
+
const since = new Date(now);
|
|
1186
|
+
if (period === 'day')
|
|
1187
|
+
since.setDate(since.getDate() - 1);
|
|
1188
|
+
else if (period === 'week')
|
|
1189
|
+
since.setDate(since.getDate() - 7);
|
|
1190
|
+
else
|
|
1191
|
+
since.setMonth(since.getMonth() - 1);
|
|
1192
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
1193
|
+
try {
|
|
1194
|
+
// Date-range search
|
|
1195
|
+
const uids = await client
|
|
1196
|
+
.search({ since }, { uid: true })
|
|
1197
|
+
.then((r) => (Array.isArray(r) ? r : []));
|
|
1198
|
+
if (uids.length === 0) {
|
|
1199
|
+
return {
|
|
1200
|
+
period,
|
|
1201
|
+
dateRange: {
|
|
1202
|
+
from: since.toISOString().split('T')[0],
|
|
1203
|
+
to: now.toISOString().split('T')[0],
|
|
1204
|
+
},
|
|
1205
|
+
totalReceived: 0,
|
|
1206
|
+
unreadCount: 0,
|
|
1207
|
+
flaggedCount: 0,
|
|
1208
|
+
topSenders: [],
|
|
1209
|
+
dailyVolume: [],
|
|
1210
|
+
hasAttachmentsCount: 0,
|
|
1211
|
+
avgPerDay: 0,
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
const range = uids.join(',');
|
|
1215
|
+
const senderMap = new Map();
|
|
1216
|
+
const dailyMap = new Map();
|
|
1217
|
+
let unread = 0;
|
|
1218
|
+
let flagged = 0;
|
|
1219
|
+
let withAttachments = 0;
|
|
1220
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1221
|
+
for await (const msg of client.fetch(range, {
|
|
1222
|
+
uid: true,
|
|
1223
|
+
envelope: true,
|
|
1224
|
+
flags: true,
|
|
1225
|
+
bodyStructure: true,
|
|
1226
|
+
}, { uid: true })) {
|
|
1227
|
+
const envelope = (msg.envelope ?? {});
|
|
1228
|
+
const flags = (msg.flags ??
|
|
1229
|
+
new Set());
|
|
1230
|
+
const { bodyStructure } = msg;
|
|
1231
|
+
// Count flags
|
|
1232
|
+
if (!flags.has('\\Seen'))
|
|
1233
|
+
unread += 1;
|
|
1234
|
+
if (flags.has('\\Flagged'))
|
|
1235
|
+
flagged += 1;
|
|
1236
|
+
if (hasAttachments(bodyStructure))
|
|
1237
|
+
withAttachments += 1;
|
|
1238
|
+
// Track sender
|
|
1239
|
+
const fromList = (envelope.from ?? []);
|
|
1240
|
+
if (fromList.length > 0 && fromList[0].address) {
|
|
1241
|
+
const key = fromList[0].address.toLowerCase();
|
|
1242
|
+
const existing = senderMap.get(key);
|
|
1243
|
+
if (existing) {
|
|
1244
|
+
existing.count += 1;
|
|
1245
|
+
}
|
|
1246
|
+
else {
|
|
1247
|
+
senderMap.set(key, {
|
|
1248
|
+
email: fromList[0].address,
|
|
1249
|
+
name: fromList[0].name,
|
|
1250
|
+
count: 1,
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
// Track daily volume
|
|
1255
|
+
const date = envelope.date ? new Date(envelope.date) : new Date();
|
|
1256
|
+
const dayKey = date.toISOString().split('T')[0];
|
|
1257
|
+
dailyMap.set(dayKey, (dailyMap.get(dayKey) ?? 0) + 1);
|
|
1258
|
+
}
|
|
1259
|
+
const topSenders = Array.from(senderMap.values())
|
|
1260
|
+
.sort((a, b) => b.count - a.count)
|
|
1261
|
+
.slice(0, 10);
|
|
1262
|
+
const dailyVolume = Array.from(dailyMap.entries())
|
|
1263
|
+
.map(([date, count]) => ({ date, count }))
|
|
1264
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
1265
|
+
const days = Math.max(1, dailyVolume.length);
|
|
1266
|
+
return {
|
|
1267
|
+
period,
|
|
1268
|
+
dateRange: {
|
|
1269
|
+
from: since.toISOString().split('T')[0],
|
|
1270
|
+
to: now.toISOString().split('T')[0],
|
|
1271
|
+
},
|
|
1272
|
+
totalReceived: uids.length,
|
|
1273
|
+
unreadCount: unread,
|
|
1274
|
+
flaggedCount: flagged,
|
|
1275
|
+
topSenders,
|
|
1276
|
+
dailyVolume,
|
|
1277
|
+
hasAttachmentsCount: withAttachments,
|
|
1278
|
+
avgPerDay: Math.round((uids.length / days) * 10) / 10,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
finally {
|
|
1282
|
+
lock.release();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
// -------------------------------------------------------------------------
|
|
1286
|
+
// Quota
|
|
1287
|
+
// -------------------------------------------------------------------------
|
|
1288
|
+
async getQuota(accountName) {
|
|
1289
|
+
const client = await this.connections.getImapClient(accountName);
|
|
1290
|
+
try {
|
|
1291
|
+
const quota = await client.getQuotaForMailbox('INBOX');
|
|
1292
|
+
if (!quota?.storage?.limit)
|
|
1293
|
+
return null;
|
|
1294
|
+
const usedMb = Math.round((quota.storage.usage ?? 0) / 1024);
|
|
1295
|
+
const totalMb = Math.round(quota.storage.limit / 1024);
|
|
1296
|
+
return {
|
|
1297
|
+
usedMb,
|
|
1298
|
+
totalMb,
|
|
1299
|
+
percentage: totalMb > 0 ? Math.round((usedMb / totalMb) * 100) : 0,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
// -------------------------------------------------------------------------
|
|
1307
|
+
// Capabilities
|
|
1308
|
+
// -------------------------------------------------------------------------
|
|
1309
|
+
async getCapabilities(accountName) {
|
|
1310
|
+
const client = await this.connections.getImapClient(accountName);
|
|
1311
|
+
try {
|
|
1312
|
+
// ImapFlow exposes capabilities as a Set on the client
|
|
1313
|
+
const caps = client.capabilities;
|
|
1314
|
+
return caps ? Array.from(caps) : [];
|
|
1315
|
+
}
|
|
1316
|
+
catch {
|
|
1317
|
+
return [];
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// -------------------------------------------------------------------------
|
|
1321
|
+
// Calendar part extraction
|
|
1322
|
+
// -------------------------------------------------------------------------
|
|
1323
|
+
/* eslint-disable no-await-in-loop, no-restricted-syntax -- Sequential IMAP fetch required */
|
|
1324
|
+
async getCalendarParts(accountName, mailbox, emailId) {
|
|
1325
|
+
const client = await this.connections.getImapClient(accountName);
|
|
1326
|
+
const lock = await client.getMailboxLock(mailbox);
|
|
1327
|
+
try {
|
|
1328
|
+
const icsContents = [];
|
|
1329
|
+
// Fetch body structure
|
|
1330
|
+
for await (const msg of client.fetch(emailId, { uid: true, bodyStructure: true }, { uid: true })) {
|
|
1331
|
+
const structure = msg.bodyStructure;
|
|
1332
|
+
const parts = this.findCalendarParts(structure);
|
|
1333
|
+
// Fetch each calendar part
|
|
1334
|
+
for (const partId of parts) {
|
|
1335
|
+
for await (const partMsg of client.fetch(emailId, { uid: true, bodyParts: [partId] }, { uid: true })) {
|
|
1336
|
+
const bodyParts = partMsg.bodyParts;
|
|
1337
|
+
if (bodyParts) {
|
|
1338
|
+
bodyParts.forEach((buf) => {
|
|
1339
|
+
icsContents.push(buf.toString('utf-8'));
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return icsContents;
|
|
1346
|
+
}
|
|
1347
|
+
finally {
|
|
1348
|
+
lock.release();
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
|
1352
|
+
/**
|
|
1353
|
+
* Recursively find body parts with text/calendar content type.
|
|
1354
|
+
*/
|
|
1355
|
+
findCalendarParts(structure, prefix = '') {
|
|
1356
|
+
if (!structure || typeof structure !== 'object')
|
|
1357
|
+
return [];
|
|
1358
|
+
const s = structure;
|
|
1359
|
+
const parts = [];
|
|
1360
|
+
const type = s.type?.toLowerCase() ?? '';
|
|
1361
|
+
const subtype = s.subtype?.toLowerCase() ?? '';
|
|
1362
|
+
const disposition = s.disposition?.toLowerCase() ?? '';
|
|
1363
|
+
// Check for text/calendar part
|
|
1364
|
+
if (type === 'text' && subtype === 'calendar') {
|
|
1365
|
+
const partId = s.part;
|
|
1366
|
+
if (partId)
|
|
1367
|
+
parts.push(partId);
|
|
1368
|
+
else if (prefix)
|
|
1369
|
+
parts.push(prefix);
|
|
1370
|
+
}
|
|
1371
|
+
// Check for .ics attachment
|
|
1372
|
+
if (disposition === 'attachment' && typeof s.dispositionParameters === 'object') {
|
|
1373
|
+
const params = s.dispositionParameters;
|
|
1374
|
+
const filename = params.filename ?? '';
|
|
1375
|
+
if (filename.toLowerCase().endsWith('.ics')) {
|
|
1376
|
+
const partId = s.part;
|
|
1377
|
+
if (partId)
|
|
1378
|
+
parts.push(partId);
|
|
1379
|
+
else if (prefix)
|
|
1380
|
+
parts.push(prefix);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
// Recurse into child nodes
|
|
1384
|
+
if (Array.isArray(s.childNodes)) {
|
|
1385
|
+
s.childNodes.forEach((child, i) => {
|
|
1386
|
+
const childPrefix = prefix ? `${prefix}.${i + 1}` : `${i + 1}`;
|
|
1387
|
+
parts.push(...this.findCalendarParts(child, childPrefix));
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
return parts;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
//# sourceMappingURL=imap.service.js.map
|