@fluxmail/provider-imap 0.2.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.
@@ -0,0 +1,783 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { ImapFlow } from 'imapflow';
3
+ import nodemailer from 'nodemailer';
4
+ import PostalMime from 'postal-mime';
5
+ import { EmailError, replySubject, } from '@fluxmail/core';
6
+ import { downloadBody, inspectStructure } from './body.js';
7
+ import { mapImapError } from './errors.js';
8
+ import { resolveFolders } from './folders.js';
9
+ import { composeMessage, stripBccHeader } from './mime.js';
10
+ import { toImapSearch } from './query.js';
11
+ const DEFAULT_PAGE_SIZE = 25;
12
+ const MAX_PAGE_SIZE = 100;
13
+ export const POSTAL_OPTIONS = { maxNestingDepth: 100, maxHeadersSize: 2 * 1024 * 1024 };
14
+ const HEADER_NAMES = ['Message-ID', 'References', 'In-Reply-To'];
15
+ export const IMAP_CAPABILITIES = {
16
+ labels: false,
17
+ serverThreads: false,
18
+ serverSearch: 'basic',
19
+ snippets: false,
20
+ };
21
+ function parseHeaders(raw, includeSelected = false) {
22
+ const unfolded = (raw?.toString('utf8') ?? '').replace(/\r?\n[ \t]+/g, ' ');
23
+ const selected = {};
24
+ for (const line of unfolded.split(/\r?\n/)) {
25
+ const separator = line.indexOf(':');
26
+ if (separator < 1)
27
+ continue;
28
+ const name = line.slice(0, separator).trim();
29
+ const value = line.slice(separator + 1).trim();
30
+ if (includeSelected)
31
+ selected[name] = value;
32
+ }
33
+ const get = (name) => {
34
+ const match = Object.entries(selected).find(([key]) => key.toLowerCase() === name.toLowerCase());
35
+ if (match)
36
+ return match[1];
37
+ const regex = new RegExp(`^${name}:\\s*(.*)$`, 'im');
38
+ return unfolded.match(regex)?.[1]?.trim();
39
+ };
40
+ return {
41
+ ...(get('Message-ID') ? { messageId: get('Message-ID') } : {}),
42
+ ...(get('In-Reply-To') ? { inReplyTo: get('In-Reply-To') } : {}),
43
+ references: get('References')?.match(/<[^>]+>/g) ?? [],
44
+ ...(includeSelected ? { selected } : {}),
45
+ };
46
+ }
47
+ function threadId(headers, fallback) {
48
+ const root = headers.references[0] ?? headers.inReplyTo ?? headers.messageId ?? fallback;
49
+ return `ith_${createHash('sha256').update(root.toLowerCase()).digest('hex').slice(0, 24)}`;
50
+ }
51
+ function locationId(prefix, accountId, path, uidValidity, uid) {
52
+ const value = `${accountId}\0${path}\0${uidValidity}\0${uid}`;
53
+ return `${prefix}_${createHash('sha256').update(value).digest('base64url').slice(0, 18)}`;
54
+ }
55
+ function addresses(values) {
56
+ return (values ?? []).flatMap((value) => value.address ? [{ ...(value.name ? { name: value.name } : {}), email: value.address }] : []);
57
+ }
58
+ function postalAddresses(values) {
59
+ return (Array.isArray(values) ? values : values ? [values] : []).flatMap((value) => {
60
+ if ('group' in value && value.group) {
61
+ return value.group.map((entry) => ({ ...(entry.name ? { name: entry.name } : {}), email: entry.address }));
62
+ }
63
+ return value.address ? [{ ...(value.name ? { name: value.name } : {}), email: value.address }] : [];
64
+ });
65
+ }
66
+ function cursorHash(value) {
67
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
68
+ }
69
+ function encodeCursor(cursor) {
70
+ return Buffer.from(JSON.stringify(cursor)).toString('base64url');
71
+ }
72
+ function decodeCursor(value, hash) {
73
+ if (!value)
74
+ return { v: 2, hash, folder: 0 };
75
+ try {
76
+ const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
77
+ if (parsed.v !== 2 ||
78
+ parsed.hash !== hash ||
79
+ !Number.isInteger(parsed.folder) ||
80
+ parsed.folder < 0 ||
81
+ (parsed.upperUid !== undefined && (!Number.isInteger(parsed.upperUid) || parsed.upperUid < 1))) {
82
+ throw new Error();
83
+ }
84
+ return parsed;
85
+ }
86
+ catch {
87
+ throw new EmailError('invalid_request', 'Invalid or expired IMAP page token');
88
+ }
89
+ }
90
+ function recipients(input) {
91
+ return [...(input.to ?? []), ...(input.cc ?? []), ...(input.bcc ?? [])].map((value) => value.email);
92
+ }
93
+ function isoDate(value) {
94
+ const parsed = new Date(value ?? Date.now());
95
+ return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
96
+ }
97
+ export function smtpTransportOptions(config) {
98
+ return {
99
+ host: config.host,
100
+ port: config.port,
101
+ secure: config.security === 'tls',
102
+ requireTLS: config.security === 'starttls',
103
+ auth: { user: config.user, pass: config.password },
104
+ };
105
+ }
106
+ export class ImapProvider {
107
+ options;
108
+ capabilities = IMAP_CAPABILITIES;
109
+ imap;
110
+ imapConnection;
111
+ smtp;
112
+ resolved;
113
+ constructor(options) {
114
+ this.options = options;
115
+ }
116
+ async close() {
117
+ const imap = this.imap ?? (await this.imapConnection?.catch(() => undefined));
118
+ this.imap = undefined;
119
+ this.imapConnection = undefined;
120
+ if (imap?.usable) {
121
+ try {
122
+ await imap.logout();
123
+ }
124
+ catch {
125
+ imap.close();
126
+ }
127
+ }
128
+ this.smtp?.close();
129
+ this.smtp = undefined;
130
+ }
131
+ imapOptions() {
132
+ const config = this.options.credentials.imap;
133
+ return {
134
+ host: config.host,
135
+ port: config.port,
136
+ secure: config.security === 'tls',
137
+ ...(config.security === 'starttls' ? { doSTARTTLS: true } : {}),
138
+ auth: { user: config.user, pass: config.password },
139
+ logger: false,
140
+ clientInfo: { name: 'Fluxmail' },
141
+ };
142
+ }
143
+ buildSmtp() {
144
+ if (this.options.smtpFactory)
145
+ return this.options.smtpFactory(this.options.credentials.smtp);
146
+ return nodemailer.createTransport(smtpTransportOptions(this.options.credentials.smtp));
147
+ }
148
+ async client() {
149
+ if (this.imap?.usable)
150
+ return this.imap;
151
+ if (!this.imapConnection) {
152
+ const client = this.options.imapFactory?.(this.imapOptions()) ?? new ImapFlow(this.imapOptions());
153
+ client.on('error', () => { });
154
+ this.imapConnection = (async () => {
155
+ try {
156
+ await client.connect();
157
+ this.imap = client;
158
+ return client;
159
+ }
160
+ catch (error) {
161
+ client.close();
162
+ throw mapImapError(error);
163
+ }
164
+ finally {
165
+ this.imapConnection = undefined;
166
+ }
167
+ })();
168
+ }
169
+ return this.imapConnection;
170
+ }
171
+ async refreshFolders() {
172
+ try {
173
+ const client = await this.client();
174
+ const listing = await client.list({ statusQuery: { unseen: true } });
175
+ this.resolved = resolveFolders(listing, this.options.credentials.folderOverrides);
176
+ return this.resolved;
177
+ }
178
+ catch (error) {
179
+ throw mapImapError(error);
180
+ }
181
+ }
182
+ async getFolderWarnings() {
183
+ return (await this.refreshFolders()).warnings;
184
+ }
185
+ async testConnection() {
186
+ await this.refreshFolders();
187
+ try {
188
+ this.smtp ??= this.buildSmtp();
189
+ await this.smtp.verify();
190
+ }
191
+ catch (error) {
192
+ throw mapImapError(error);
193
+ }
194
+ }
195
+ async listFolders() {
196
+ return (await this.refreshFolders()).folders;
197
+ }
198
+ rolePath(role, folders) {
199
+ return folders.paths[role];
200
+ }
201
+ requireRole(role) {
202
+ const path = this.resolved?.paths[role];
203
+ if (path)
204
+ return path;
205
+ throw new EmailError('unsupported_capability', `This account has no resolved ${role} folder. Run "fluxmail accounts configure ${this.options.accountId} --${role}-folder <path>".`);
206
+ }
207
+ async withMailbox(path, fn) {
208
+ const client = await this.client();
209
+ let lock;
210
+ try {
211
+ lock = await client.getMailboxLock(path);
212
+ const uidValidity = client.mailbox && client.mailbox.uidValidity.toString();
213
+ if (!uidValidity)
214
+ throw new EmailError('provider_unavailable', `Mailbox ${path} has no UIDVALIDITY`);
215
+ await this.options.store.invalidateMailbox(path, uidValidity);
216
+ return await fn(client, uidValidity);
217
+ }
218
+ catch (error) {
219
+ throw mapImapError(error);
220
+ }
221
+ finally {
222
+ lock?.release();
223
+ }
224
+ }
225
+ async toMessage(client, path, uidValidity, fetched, options = {}) {
226
+ const envelope = fetched.envelope ?? {};
227
+ const parsed = parseHeaders(fetched.headers, options.includeHeaders);
228
+ const existing = await this.options.store.findByLocation(path, uidValidity, fetched.uid);
229
+ const role = this.resolved?.folders.find((folder) => folder.id === path)?.role;
230
+ const resolvedDraftId = options.draftId ??
231
+ existing?.draftId ??
232
+ (role === 'drafts' ? locationId('draft', this.options.accountId, path, uidValidity, fetched.uid) : undefined);
233
+ const fallback = `${path}:${uidValidity}:${fetched.uid}`;
234
+ const location = {
235
+ id: existing?.id ?? locationId('im', this.options.accountId, path, uidValidity, fetched.uid),
236
+ accountId: this.options.accountId,
237
+ mailboxPath: path,
238
+ uidValidity,
239
+ uid: fetched.uid,
240
+ ...((parsed.messageId ?? envelope.messageId) ? { messageId: parsed.messageId ?? envelope.messageId } : {}),
241
+ ...((parsed.inReplyTo ?? envelope.inReplyTo) ? { inReplyTo: parsed.inReplyTo ?? envelope.inReplyTo } : {}),
242
+ ...(parsed.references.length ? { references: parsed.references } : {}),
243
+ threadId: options.threadId ?? threadId(parsed, fallback),
244
+ ...(resolvedDraftId ? { draftId: resolvedDraftId } : {}),
245
+ subject: envelope.subject ?? '',
246
+ date: isoDate(envelope.date ?? fetched.internalDate),
247
+ };
248
+ await this.options.store.save(location);
249
+ const flags = fetched.flags ?? new Set();
250
+ const parts = inspectStructure(fetched.bodyStructure);
251
+ const message = {
252
+ id: location.id,
253
+ threadId: location.threadId,
254
+ accountId: this.options.accountId,
255
+ ...(location.draftId ? { draftId: location.draftId } : {}),
256
+ folder: { id: path, name: path, ...(role ? { role } : {}) },
257
+ ...(addresses(envelope.from)[0] ? { from: addresses(envelope.from)[0] } : {}),
258
+ to: addresses(envelope.to),
259
+ ...(addresses(envelope.cc).length ? { cc: addresses(envelope.cc) } : {}),
260
+ ...(addresses(envelope.bcc).length ? { bcc: addresses(envelope.bcc) } : {}),
261
+ ...(addresses(envelope.replyTo).length ? { replyTo: addresses(envelope.replyTo) } : {}),
262
+ subject: envelope.subject ?? '',
263
+ date: location.date ?? new Date().toISOString(),
264
+ flags: { read: flags.has('\\Seen'), starred: flags.has('\\Flagged'), draft: flags.has('\\Draft') },
265
+ ...(fetched.bodyStructure ? { attachments: parts.attachments } : {}),
266
+ ...(options.includeHeaders && parsed.selected ? { headers: parsed.selected } : {}),
267
+ };
268
+ if (options.body) {
269
+ if (fetched.bodyStructure) {
270
+ message.body = await downloadBody(client, fetched.uid, parts);
271
+ }
272
+ else if (fetched.source) {
273
+ const parsedSource = await PostalMime.parse(fetched.source, POSTAL_OPTIONS);
274
+ message.body = {
275
+ ...(parsedSource.text !== undefined ? { text: parsedSource.text } : {}),
276
+ ...(parsedSource.html !== undefined ? { html: parsedSource.html } : {}),
277
+ };
278
+ message.attachments = parsedSource.attachments.map((attachment, index) => ({
279
+ id: `raw-${index}`,
280
+ filename: attachment.filename ?? 'attachment',
281
+ mimeType: attachment.mimeType,
282
+ sizeBytes: typeof attachment.content === 'string'
283
+ ? Buffer.byteLength(attachment.content)
284
+ : attachment.content.byteLength,
285
+ ...(attachment.contentId ? { contentId: attachment.contentId.replace(/^<|>$/g, '') } : {}),
286
+ ...(attachment.disposition ? { disposition: attachment.disposition } : {}),
287
+ }));
288
+ }
289
+ else {
290
+ message.body = {};
291
+ }
292
+ }
293
+ return message;
294
+ }
295
+ async listMessages(query, page) {
296
+ const folders = await this.refreshFolders();
297
+ const explicit = query.folder;
298
+ let paths;
299
+ if (explicit) {
300
+ const rolePath = this.rolePath(explicit, folders);
301
+ const exact = folders.selectablePaths.find((path) => path === explicit);
302
+ if (!rolePath && !exact && (explicit === 'starred' || explicit === 'all'))
303
+ paths = folders.selectablePaths.filter((p) => p !== folders.paths.all);
304
+ else if (!rolePath && !exact)
305
+ throw new EmailError('not_found', `No folder named "${explicit}"`);
306
+ else
307
+ paths = [rolePath ?? exact];
308
+ }
309
+ else {
310
+ paths = folders.selectablePaths.filter((path) => path !== folders.paths.all);
311
+ }
312
+ const normalizedQuery = { ...query, ...(explicit === 'starred' ? { starredOnly: true } : {}), folder: undefined };
313
+ const hash = cursorHash({ query: { ...normalizedQuery, ...(explicit ? { folder: explicit } : {}) }, paths });
314
+ const cursor = decodeCursor(page?.pageToken, hash);
315
+ const pageSize = Math.min(Math.max(page?.pageSize ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
316
+ const items = [];
317
+ let folderIndex = cursor.folder;
318
+ let upperUid = cursor.upperUid;
319
+ let nextCursor;
320
+ searchFolders: for (; folderIndex < paths.length; folderIndex++, upperUid = undefined) {
321
+ const path = paths[folderIndex];
322
+ await this.withMailbox(path, async (client, uidValidity) => {
323
+ const found = await client.search(toImapSearch(normalizedQuery, client.capabilities.has('X-GM-EXT-1')), {
324
+ uid: true,
325
+ });
326
+ const uids = (found || []).sort((a, b) => b - a).filter((uid) => upperUid === undefined || uid <= upperUid);
327
+ for (const uid of uids) {
328
+ const fetched = await client.fetchOne(uid, { envelope: true, flags: true, internalDate: true, bodyStructure: true, headers: HEADER_NAMES }, { uid: true });
329
+ if (!fetched)
330
+ continue;
331
+ if (query.hasAttachment && !inspectStructure(fetched.bodyStructure).attachments.length)
332
+ continue;
333
+ if (items.length === pageSize) {
334
+ nextCursor = { v: 2, hash, folder: folderIndex, upperUid: uid };
335
+ return;
336
+ }
337
+ items.push(await this.toMessage(client, path, uidValidity, fetched));
338
+ }
339
+ });
340
+ if (nextCursor)
341
+ break searchFolders;
342
+ }
343
+ const out = { items };
344
+ if (nextCursor)
345
+ out.nextPageToken = encodeCursor(nextCursor);
346
+ return out;
347
+ }
348
+ async fetchLocation(location, body, includeHeaders = false) {
349
+ return this.withMailbox(location.mailboxPath, async (client, uidValidity) => {
350
+ if (uidValidity !== location.uidValidity)
351
+ throw new EmailError('not_found', `Message ${location.id} is no longer available`);
352
+ const fetched = await client.fetchOne(location.uid, {
353
+ envelope: true,
354
+ flags: true,
355
+ internalDate: true,
356
+ bodyStructure: true,
357
+ headers: includeHeaders ? true : HEADER_NAMES,
358
+ }, { uid: true });
359
+ if (!fetched)
360
+ throw new EmailError('not_found', `No message with id "${location.id}"`);
361
+ if (body && !fetched.bodyStructure) {
362
+ const source = await client.fetchOne(location.uid, { source: true }, { uid: true });
363
+ if (source && source.source)
364
+ fetched.source = source.source;
365
+ }
366
+ return this.toMessage(client, location.mailboxPath, uidValidity, fetched, {
367
+ body,
368
+ includeHeaders,
369
+ threadId: location.threadId,
370
+ ...(location.draftId ? { draftId: location.draftId } : {}),
371
+ });
372
+ });
373
+ }
374
+ async getMessage(id, options) {
375
+ const location = await this.options.store.findById(id);
376
+ if (!location)
377
+ throw new EmailError('not_found', `No message with id "${id}"`);
378
+ return this.fetchLocation(location, true, options?.includeHeaders ?? false);
379
+ }
380
+ async getThread(id) {
381
+ let locations = await this.options.store.listByThreadId(id);
382
+ if (!locations.length)
383
+ throw new EmailError('not_found', `No thread with id "${id}"`);
384
+ const folders = await this.refreshFolders();
385
+ const searched = new Set();
386
+ while (true) {
387
+ const identifiers = new Set(locations
388
+ .flatMap((location) => [location.messageId, location.inReplyTo, ...(location.references ?? [])])
389
+ .filter(Boolean));
390
+ const pending = [...identifiers].filter((identifier) => !searched.has(identifier));
391
+ if (!pending.length)
392
+ break;
393
+ pending.forEach((identifier) => searched.add(identifier));
394
+ for (const path of folders.selectablePaths.filter((value) => value !== folders.paths.all)) {
395
+ await this.withMailbox(path, async (client, uidValidity) => {
396
+ for (const identifier of pending) {
397
+ const found = await client.search({
398
+ or: [
399
+ { header: { 'Message-ID': identifier } },
400
+ { header: { References: identifier } },
401
+ { header: { 'In-Reply-To': identifier } },
402
+ ],
403
+ }, { uid: true });
404
+ for (const uid of found || []) {
405
+ const existing = await this.options.store.findByLocation(path, uidValidity, uid);
406
+ if (existing) {
407
+ if (existing.threadId !== id)
408
+ await this.options.store.save({ ...existing, threadId: id });
409
+ continue;
410
+ }
411
+ const fetched = await client.fetchOne(uid, { envelope: true, flags: true, internalDate: true, bodyStructure: true, headers: HEADER_NAMES }, { uid: true });
412
+ if (fetched)
413
+ await this.toMessage(client, path, uidValidity, fetched, { threadId: id });
414
+ }
415
+ }
416
+ });
417
+ }
418
+ const expanded = await this.options.store.listByThreadId(id);
419
+ if (expanded.length === locations.length)
420
+ break;
421
+ locations = expanded;
422
+ }
423
+ const messages = await Promise.all(locations.map((location) => this.fetchLocation(location, true)));
424
+ messages.sort((a, b) => a.date.localeCompare(b.date));
425
+ return { id, subject: messages[0]?.subject ?? '', messages };
426
+ }
427
+ async threadingFor(input) {
428
+ if (!input.replyToMessageId)
429
+ return { input };
430
+ const original = await this.getMessage(input.replyToMessageId, { includeHeaders: true });
431
+ const header = (name) => Object.entries(original.headers ?? {}).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
432
+ const messageId = header('Message-ID');
433
+ const oldReferences = header('References');
434
+ const references = [oldReferences, messageId].filter(Boolean).join(' ');
435
+ return {
436
+ input: { ...input, subject: input.subject ?? replySubject(original.subject), replyToMessageId: undefined },
437
+ headers: {
438
+ ...(messageId ? { inReplyTo: messageId } : {}),
439
+ ...(references ? { references } : {}),
440
+ },
441
+ };
442
+ }
443
+ async compose(input, existingThreading) {
444
+ const resolved = input.replyToMessageId ? await this.threadingFor(input) : { input, headers: existingThreading };
445
+ return composeMessage(resolved.input, { ...(this.options.displayName ? { name: this.options.displayName } : {}), email: this.options.email }, resolved.headers);
446
+ }
447
+ async appendedLocation(path, result, messageId, kind) {
448
+ if (result && result.uid && result.uidValidity) {
449
+ return { uid: result.uid, uidValidity: result.uidValidity.toString() };
450
+ }
451
+ if (messageId) {
452
+ const found = await this.withMailbox(path, async (client, uidValidity) => {
453
+ const matches = await client.search({ header: { 'Message-ID': messageId } }, { uid: true });
454
+ const uid = Math.max(...(matches || []));
455
+ return Number.isFinite(uid) ? { uid, uidValidity } : undefined;
456
+ });
457
+ if (found)
458
+ return found;
459
+ }
460
+ throw new EmailError('provider_unavailable', `The IMAP server did not return or expose an ID for the ${kind}`);
461
+ }
462
+ async appendDraft(raw, draftId, existingId) {
463
+ await this.refreshFolders();
464
+ const path = this.requireRole('drafts');
465
+ const parsed = await PostalMime.parse(raw, POSTAL_OPTIONS);
466
+ const client = await this.client();
467
+ const result = await client.append(path, raw, ['\\Draft']);
468
+ const appended = await this.appendedLocation(path, result, parsed.messageId, 'saved draft');
469
+ const location = {
470
+ id: existingId ?? locationId('im', this.options.accountId, path, appended.uidValidity, appended.uid),
471
+ accountId: this.options.accountId,
472
+ mailboxPath: path,
473
+ uidValidity: appended.uidValidity,
474
+ uid: appended.uid,
475
+ ...(parsed.messageId ? { messageId: parsed.messageId } : {}),
476
+ ...(parsed.inReplyTo ? { inReplyTo: parsed.inReplyTo } : {}),
477
+ ...(parsed.references ? { references: parsed.references.match(/<[^>]+>/g) ?? [] } : {}),
478
+ threadId: threadId({
479
+ messageId: parsed.messageId,
480
+ inReplyTo: parsed.inReplyTo,
481
+ references: parsed.references?.match(/<[^>]+>/g) ?? [],
482
+ }, draftId),
483
+ draftId,
484
+ subject: parsed.subject ?? '',
485
+ date: parsed.date ?? new Date().toISOString(),
486
+ };
487
+ await this.options.store.save(location);
488
+ return this.fetchLocation(location, true);
489
+ }
490
+ async createDraft(input) {
491
+ return this.appendDraft(await this.compose(input), `draft_${randomBytes(9).toString('base64url')}`);
492
+ }
493
+ async getDraft(draftId) {
494
+ const location = await this.options.store.findByDraftId(draftId);
495
+ if (!location)
496
+ throw new EmailError('not_found', `No draft with id "${draftId}"`);
497
+ return this.fetchLocation(location, true);
498
+ }
499
+ async updateDraft(draftId, input) {
500
+ const location = await this.options.store.findByDraftId(draftId);
501
+ if (!location)
502
+ throw new EmailError('not_found', `No draft with id "${draftId}"`);
503
+ const replacement = await this.appendDraft(await this.compose(input, {
504
+ ...(location.inReplyTo ? { inReplyTo: location.inReplyTo } : {}),
505
+ ...(location.references?.length ? { references: location.references.join(' ') } : {}),
506
+ }), draftId, location.id);
507
+ await this.withMailbox(location.mailboxPath, async (client) => {
508
+ await client.messageDelete(location.uid, { uid: true });
509
+ });
510
+ return replacement;
511
+ }
512
+ async deleteDraft(draftId) {
513
+ const location = await this.options.store.findByDraftId(draftId);
514
+ if (!location)
515
+ throw new EmailError('not_found', `No draft with id "${draftId}"`);
516
+ await this.withMailbox(location.mailboxPath, async (client) => {
517
+ await client.messageDelete(location.uid, { uid: true });
518
+ });
519
+ await this.options.store.remove(location.id);
520
+ }
521
+ async deliver(raw, envelopeRecipients) {
522
+ if (!envelopeRecipients.length)
523
+ throw new EmailError('invalid_request', 'Cannot send a message with no recipients');
524
+ try {
525
+ this.smtp ??= this.buildSmtp();
526
+ const info = await this.smtp.sendMail({
527
+ envelope: { from: this.options.email, to: envelopeRecipients },
528
+ raw: stripBccHeader(raw),
529
+ });
530
+ return info.messageId || `smtp_${randomBytes(9).toString('base64url')}`;
531
+ }
532
+ catch (error) {
533
+ throw mapImapError(error);
534
+ }
535
+ }
536
+ async appendSent(raw) {
537
+ if (!this.options.credentials.saveSent)
538
+ return undefined;
539
+ await this.refreshFolders();
540
+ const path = this.resolved?.paths.sent;
541
+ if (!path)
542
+ return undefined;
543
+ const parsed = await PostalMime.parse(raw, POSTAL_OPTIONS);
544
+ const result = await (await this.client()).append(path, raw, ['\\Seen']);
545
+ const appended = await this.appendedLocation(path, result, parsed.messageId, 'Sent copy');
546
+ const headers = {
547
+ ...(parsed.messageId ? { messageId: parsed.messageId } : {}),
548
+ ...(parsed.inReplyTo ? { inReplyTo: parsed.inReplyTo } : {}),
549
+ references: parsed.references?.match(/<[^>]+>/g) ?? [],
550
+ };
551
+ const location = {
552
+ id: locationId('im', this.options.accountId, path, appended.uidValidity, appended.uid),
553
+ accountId: this.options.accountId,
554
+ mailboxPath: path,
555
+ uidValidity: appended.uidValidity,
556
+ uid: appended.uid,
557
+ ...(parsed.messageId ? { messageId: parsed.messageId } : {}),
558
+ ...(parsed.inReplyTo ? { inReplyTo: parsed.inReplyTo } : {}),
559
+ ...(headers.references.length ? { references: headers.references } : {}),
560
+ threadId: threadId(headers, parsed.messageId ?? String(appended.uid)),
561
+ subject: parsed.subject ?? '',
562
+ date: parsed.date ?? new Date().toISOString(),
563
+ };
564
+ await this.options.store.save(location);
565
+ return location;
566
+ }
567
+ async moveDraftToSent(location) {
568
+ await this.refreshFolders();
569
+ const destination = this.resolved?.paths.sent;
570
+ if (!destination)
571
+ return undefined;
572
+ const moved = await this.withMailbox(location.mailboxPath, (client) => client.messageMove(location.uid, destination, { uid: true }));
573
+ const mappedUid = moved && moved.uidMap?.get(location.uid);
574
+ const target = moved && mappedUid && moved.uidValidity
575
+ ? { uid: mappedUid, uidValidity: moved.uidValidity.toString() }
576
+ : await this.appendedLocation(destination, false, location.messageId, 'moved Sent copy');
577
+ await this.withMailbox(destination, async (client, uidValidity) => {
578
+ if (uidValidity !== target.uidValidity) {
579
+ throw new EmailError('provider_unavailable', 'The Sent folder changed while the draft was being moved');
580
+ }
581
+ await client.messageFlagsRemove(target.uid, ['\\Draft'], { uid: true });
582
+ await client.messageFlagsAdd(target.uid, ['\\Seen'], { uid: true });
583
+ });
584
+ const { draftId: _draftId, ...withoutDraft } = location;
585
+ const sent = {
586
+ ...withoutDraft,
587
+ mailboxPath: destination,
588
+ uidValidity: target.uidValidity,
589
+ uid: target.uid,
590
+ };
591
+ await this.options.store.save(sent);
592
+ return sent;
593
+ }
594
+ async send(input) {
595
+ let raw;
596
+ let draftLocation;
597
+ let envelopeRecipients;
598
+ if ('draftId' in input) {
599
+ draftLocation = await this.options.store.findByDraftId(input.draftId);
600
+ if (!draftLocation)
601
+ throw new EmailError('not_found', `No draft with id "${input.draftId}"`);
602
+ raw = await this.withMailbox(draftLocation.mailboxPath, async (client) => {
603
+ const fetched = await client.fetchOne(draftLocation.uid, { source: true }, { uid: true });
604
+ if (!fetched || !fetched.source)
605
+ throw new EmailError('not_found', `No draft with id "${input.draftId}"`);
606
+ return fetched.source;
607
+ });
608
+ const parsed = await PostalMime.parse(raw, POSTAL_OPTIONS);
609
+ envelopeRecipients = [
610
+ ...postalAddresses(parsed.to),
611
+ ...postalAddresses(parsed.cc),
612
+ ...postalAddresses(parsed.bcc),
613
+ ].map((value) => value.email);
614
+ }
615
+ else {
616
+ raw = await this.compose(input);
617
+ envelopeRecipients = recipients(input);
618
+ }
619
+ const deliveryId = await this.deliver(raw, envelopeRecipients);
620
+ const warnings = [];
621
+ let sent;
622
+ if (draftLocation && this.options.credentials.saveSent) {
623
+ try {
624
+ sent = await this.moveDraftToSent(draftLocation);
625
+ if (!sent) {
626
+ warnings.push('Message delivered, but no Sent folder is configured, so Fluxmail could not save a copy.');
627
+ await this.withMailbox(draftLocation.mailboxPath, async (client) => {
628
+ await client.messageDelete(draftLocation.uid, { uid: true });
629
+ });
630
+ await this.options.store.remove(draftLocation.id);
631
+ }
632
+ }
633
+ catch (error) {
634
+ warnings.push(`Message delivered, but Fluxmail could not move the draft to Sent: ${mapImapError(error).message}`);
635
+ }
636
+ }
637
+ else {
638
+ try {
639
+ sent = await this.appendSent(raw);
640
+ if (this.options.credentials.saveSent && !sent) {
641
+ warnings.push('Message delivered, but no Sent folder is configured, so Fluxmail could not save a copy.');
642
+ }
643
+ }
644
+ catch (error) {
645
+ warnings.push(`Message delivered, but Fluxmail could not save the Sent copy: ${mapImapError(error).message}`);
646
+ }
647
+ if (draftLocation) {
648
+ try {
649
+ await this.withMailbox(draftLocation.mailboxPath, async (client) => {
650
+ await client.messageDelete(draftLocation.uid, { uid: true });
651
+ });
652
+ await this.options.store.remove(draftLocation.id);
653
+ }
654
+ catch (error) {
655
+ warnings.push(`Message delivered, but Fluxmail could not remove the draft: ${mapImapError(error).message}`);
656
+ }
657
+ }
658
+ }
659
+ return {
660
+ id: sent?.id ?? deliveryId,
661
+ threadId: sent?.threadId ?? `ith_${createHash('sha256').update(deliveryId).digest('hex').slice(0, 24)}`,
662
+ ...(warnings.length ? { warnings } : {}),
663
+ };
664
+ }
665
+ async modify(ids, action) {
666
+ if (typeof action === 'object' && ('addLabels' in action || 'removeLabels' in action)) {
667
+ throw new EmailError('unsupported_capability', 'IMAP accounts do not support labels');
668
+ }
669
+ const locations = await Promise.all(ids.map((id) => this.options.store.findById(id)));
670
+ if (locations.some((location) => !location))
671
+ throw new EmailError('not_found', 'One or more messages no longer exist');
672
+ await this.refreshFolders();
673
+ const groups = new Map();
674
+ for (const location of locations) {
675
+ const group = groups.get(location.mailboxPath) ?? [];
676
+ group.push(location);
677
+ groups.set(location.mailboxPath, group);
678
+ }
679
+ for (const [path, group] of groups) {
680
+ await this.withMailbox(path, async (client) => {
681
+ const uids = group.map((location) => location.uid);
682
+ if (action === 'markRead')
683
+ await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
684
+ else if (action === 'markUnread')
685
+ await client.messageFlagsRemove(uids, ['\\Seen'], { uid: true });
686
+ else if (action === 'star')
687
+ await client.messageFlagsAdd(uids, ['\\Flagged'], { uid: true });
688
+ else if (action === 'unstar')
689
+ await client.messageFlagsRemove(uids, ['\\Flagged'], { uid: true });
690
+ else if (action === 'delete') {
691
+ await client.messageDelete(uids, { uid: true });
692
+ await Promise.all(group.map((location) => this.options.store.remove(location.id)));
693
+ }
694
+ else {
695
+ let destination;
696
+ if (action === 'archive')
697
+ destination = this.requireRole('archive');
698
+ else if (action === 'trash')
699
+ destination = this.requireRole('trash');
700
+ else if (action === 'untrash') {
701
+ this.requireRole('trash');
702
+ destination = this.resolved?.paths.inbox ?? 'INBOX';
703
+ }
704
+ else if (typeof action === 'object' && 'move' in action) {
705
+ const roleDestination = this.resolved?.paths[action.move];
706
+ destination = roleDestination ?? action.move;
707
+ if (!this.resolved?.selectablePaths.includes(destination)) {
708
+ throw new EmailError('not_found', `No folder named "${action.move}"`);
709
+ }
710
+ }
711
+ else
712
+ throw new EmailError('invalid_request', `Unsupported IMAP modify action: ${String(action)}`);
713
+ if (destination === path)
714
+ return;
715
+ const moved = await client.messageMove(uids, destination, { uid: true });
716
+ if (moved && moved.uidMap && moved.uidValidity) {
717
+ for (const location of group) {
718
+ const uid = moved.uidMap.get(location.uid);
719
+ if (!uid)
720
+ continue;
721
+ await this.options.store.save({
722
+ ...location,
723
+ mailboxPath: destination,
724
+ uidValidity: moved.uidValidity.toString(),
725
+ uid,
726
+ });
727
+ }
728
+ }
729
+ else {
730
+ await Promise.all(group.map((location) => this.options.store.remove(location.id)));
731
+ }
732
+ }
733
+ });
734
+ }
735
+ }
736
+ async getAttachment(messageId, attachmentId) {
737
+ const location = await this.options.store.findById(messageId);
738
+ if (!location)
739
+ throw new EmailError('not_found', `No message with id "${messageId}"`);
740
+ return this.withMailbox(location.mailboxPath, async (client, uidValidity) => {
741
+ if (uidValidity !== location.uidValidity)
742
+ throw new EmailError('not_found', 'The message location is stale');
743
+ const fetched = await client.fetchOne(location.uid, { bodyStructure: true }, { uid: true });
744
+ if (!fetched)
745
+ throw new EmailError('not_found', `No message with id "${messageId}"`);
746
+ const meta = inspectStructure(fetched.bodyStructure).attachments.find((item) => item.id === attachmentId);
747
+ if (!meta && attachmentId.startsWith('raw-')) {
748
+ const index = Number(attachmentId.slice(4));
749
+ const raw = await client.fetchOne(location.uid, { source: true }, { uid: true });
750
+ if (!raw || !raw.source)
751
+ throw new EmailError('not_found', `No attachment with id "${attachmentId}"`);
752
+ const parsed = await PostalMime.parse(raw.source, POSTAL_OPTIONS);
753
+ const attachment = parsed.attachments[index];
754
+ if (!attachment)
755
+ throw new EmailError('not_found', `No attachment with id "${attachmentId}"`);
756
+ const content = typeof attachment.content === 'string'
757
+ ? Buffer.from(attachment.content, attachment.encoding === 'base64' ? 'base64' : 'utf8')
758
+ : attachment.content instanceof ArrayBuffer
759
+ ? Buffer.from(attachment.content)
760
+ : Buffer.from(attachment.content.buffer, attachment.content.byteOffset, attachment.content.byteLength);
761
+ return {
762
+ meta: {
763
+ id: attachmentId,
764
+ filename: attachment.filename ?? 'attachment',
765
+ mimeType: attachment.mimeType,
766
+ sizeBytes: content.length,
767
+ ...(attachment.contentId ? { contentId: attachment.contentId.replace(/^<|>$/g, '') } : {}),
768
+ ...(attachment.disposition ? { disposition: attachment.disposition } : {}),
769
+ },
770
+ content,
771
+ };
772
+ }
773
+ if (!meta)
774
+ throw new EmailError('not_found', `No attachment with id "${attachmentId}"`);
775
+ const downloaded = await client.download(location.uid, attachmentId, { uid: true });
776
+ const chunks = [];
777
+ for await (const chunk of downloaded.content)
778
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
779
+ return { meta, content: Buffer.concat(chunks) };
780
+ });
781
+ }
782
+ }
783
+ //# sourceMappingURL=imapProvider.js.map