zele 0.3.16 → 0.3.20

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.
Files changed (90) hide show
  1. package/README.md +155 -36
  2. package/dist/api-utils.d.ts +14 -0
  3. package/dist/api-utils.js +20 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/auth.d.ts +71 -9
  6. package/dist/auth.js +186 -10
  7. package/dist/auth.js.map +1 -1
  8. package/dist/cli-types.d.ts +4 -0
  9. package/dist/cli-types.js +6 -0
  10. package/dist/cli-types.js.map +1 -0
  11. package/dist/cli.js +1 -5
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands/attachment.d.ts +2 -2
  14. package/dist/commands/attachment.js +2 -0
  15. package/dist/commands/attachment.js.map +1 -1
  16. package/dist/commands/auth-cmd.d.ts +2 -2
  17. package/dist/commands/auth-cmd.js +104 -6
  18. package/dist/commands/auth-cmd.js.map +1 -1
  19. package/dist/commands/calendar.d.ts +2 -2
  20. package/dist/commands/calendar.js.map +1 -1
  21. package/dist/commands/draft.d.ts +2 -2
  22. package/dist/commands/draft.js +58 -4
  23. package/dist/commands/draft.js.map +1 -1
  24. package/dist/commands/filter.d.ts +2 -2
  25. package/dist/commands/filter.js +7 -2
  26. package/dist/commands/filter.js.map +1 -1
  27. package/dist/commands/label.d.ts +2 -2
  28. package/dist/commands/label.js +19 -9
  29. package/dist/commands/label.js.map +1 -1
  30. package/dist/commands/mail-actions.d.ts +2 -2
  31. package/dist/commands/mail-actions.js +290 -1
  32. package/dist/commands/mail-actions.js.map +1 -1
  33. package/dist/commands/mail.d.ts +2 -2
  34. package/dist/commands/mail.js +90 -23
  35. package/dist/commands/mail.js.map +1 -1
  36. package/dist/commands/profile.d.ts +2 -2
  37. package/dist/commands/profile.js +25 -18
  38. package/dist/commands/profile.js.map +1 -1
  39. package/dist/commands/watch.d.ts +2 -2
  40. package/dist/commands/watch.js.map +1 -1
  41. package/dist/db.js +24 -0
  42. package/dist/db.js.map +1 -1
  43. package/dist/generated/internal/class.js +2 -2
  44. package/dist/generated/internal/class.js.map +1 -1
  45. package/dist/generated/internal/prismaNamespace.d.ts +2 -0
  46. package/dist/generated/internal/prismaNamespace.js +2 -0
  47. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  48. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +2 -0
  49. package/dist/generated/internal/prismaNamespaceBrowser.js +2 -0
  50. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  51. package/dist/generated/models/Account.d.ts +97 -1
  52. package/dist/gmail-client.d.ts +73 -3
  53. package/dist/gmail-client.js +165 -5
  54. package/dist/gmail-client.js.map +1 -1
  55. package/dist/imap-smtp-client.d.ts +306 -0
  56. package/dist/imap-smtp-client.js +1349 -0
  57. package/dist/imap-smtp-client.js.map +1 -0
  58. package/dist/mail-tui.js.map +1 -1
  59. package/dist/unsubscribe.d.ts +76 -0
  60. package/dist/unsubscribe.js +224 -0
  61. package/dist/unsubscribe.js.map +1 -0
  62. package/package.json +6 -3
  63. package/schema.prisma +7 -5
  64. package/skills/zele/SKILL.md +26 -96
  65. package/src/api-utils.ts +20 -0
  66. package/src/auth.ts +282 -14
  67. package/src/cli-types.ts +8 -0
  68. package/src/cli.ts +2 -7
  69. package/src/commands/attachment.ts +3 -2
  70. package/src/commands/auth-cmd.ts +114 -8
  71. package/src/commands/calendar.ts +2 -2
  72. package/src/commands/draft.ts +65 -6
  73. package/src/commands/filter.ts +11 -5
  74. package/src/commands/label.ts +24 -13
  75. package/src/commands/mail-actions.ts +317 -5
  76. package/src/commands/mail.ts +97 -25
  77. package/src/commands/profile.ts +29 -19
  78. package/src/commands/watch.ts +2 -2
  79. package/src/db.ts +28 -0
  80. package/src/generated/internal/class.ts +2 -2
  81. package/src/generated/internal/prismaNamespace.ts +2 -0
  82. package/src/generated/internal/prismaNamespaceBrowser.ts +2 -0
  83. package/src/generated/models/Account.ts +97 -1
  84. package/src/gmail-client.test.ts +155 -2
  85. package/src/gmail-client.ts +258 -6
  86. package/src/imap-smtp-client.ts +1560 -0
  87. package/src/mail-tui.tsx +2 -1
  88. package/src/schema.sql +2 -0
  89. package/src/unsubscribe.test.ts +487 -0
  90. package/src/unsubscribe.ts +255 -0
@@ -0,0 +1,1349 @@
1
+ // IMAP/SMTP email client for non-Google accounts.
2
+ // Mirrors the GmailClient method signatures and return types so commands
3
+ // can work with both client types without major rewrites.
4
+ // Each IMAP operation opens a fresh connection (connect → operate → logout)
5
+ // to avoid stale connection issues. SMTP uses nodemailer transporter.
6
+ // Threading: each IMAP message is treated as a single-message "thread"
7
+ // with threadId = "folder:uid" (e.g. "INBOX:12345").
8
+ import { ImapFlow } from 'imapflow';
9
+ import * as errore from 'errore';
10
+ import { AuthError, ApiError, UnsupportedError, EmptyThreadError, NotFoundError } from './api-utils.js';
11
+ import { renderEmailBody } from './output.js';
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ /** Parse a threadId in the format "FOLDER:UID" back to folder + uid. */
16
+ function parseThreadId(threadId) {
17
+ const idx = threadId.lastIndexOf(':');
18
+ if (idx === -1)
19
+ return { folder: 'INBOX', uid: Number(threadId) };
20
+ return { folder: threadId.slice(0, idx), uid: Number(threadId.slice(idx + 1)) };
21
+ }
22
+ /** Build a threadId from folder + uid. */
23
+ function makeThreadId(folder, uid) {
24
+ return `${folder}:${uid}`;
25
+ }
26
+ /** Static fallback map from zele folder names to IMAP folder paths.
27
+ * Used only when specialUse discovery fails. */
28
+ const FOLDER_FALLBACKS = {
29
+ sent: ['Sent', 'Sent Items', 'Sent Messages', 'INBOX.Sent'],
30
+ trash: ['Trash', 'Deleted Items', 'Deleted Messages', 'INBOX.Trash'],
31
+ spam: ['Junk', 'Junk Email', 'Spam', 'INBOX.Junk'],
32
+ drafts: ['Drafts', 'Draft', 'INBOX.Drafts'],
33
+ archive: ['Archive', 'Archives', 'All Mail', '[Gmail]/All Mail', 'INBOX.Archive'],
34
+ };
35
+ /** RFC 6154 specialUse attributes mapped to zele folder names. */
36
+ const SPECIAL_USE_MAP = {
37
+ sent: '\\Sent',
38
+ trash: '\\Trash',
39
+ bin: '\\Trash',
40
+ spam: '\\Junk',
41
+ drafts: '\\Drafts',
42
+ draft: '\\Drafts',
43
+ archive: '\\Archive',
44
+ };
45
+ /** Convert imapflow address objects to our Sender type. */
46
+ function toSender(addr) {
47
+ if (!addr)
48
+ return { email: 'unknown' };
49
+ return { name: addr.name || undefined, email: addr.address ?? 'unknown' };
50
+ }
51
+ function toSenders(addrs) {
52
+ if (!addrs || addrs.length === 0)
53
+ return [];
54
+ return addrs.map(toSender);
55
+ }
56
+ /** Basic client-side query filter for watch events.
57
+ * Supports: from:, to:, subject:, is:unread, is:starred, has:attachment, and plain text search. */
58
+ function matchesQuery(msg, query) {
59
+ const lower = query.toLowerCase();
60
+ // Handle specific operators
61
+ const fromMatch = lower.match(/from:(\S+)/);
62
+ if (fromMatch && !msg.from.email.toLowerCase().includes(fromMatch[1]))
63
+ return false;
64
+ const toMatch = lower.match(/to:(\S+)/);
65
+ if (toMatch && !msg.to.some((t) => t.email.toLowerCase().includes(toMatch[1])))
66
+ return false;
67
+ const subjectMatch = lower.match(/subject:(?:"([^"]+)"|(\S+))/);
68
+ if (subjectMatch) {
69
+ const term = (subjectMatch[1] ?? subjectMatch[2]).toLowerCase();
70
+ if (!msg.subject.toLowerCase().includes(term))
71
+ return false;
72
+ }
73
+ if (lower.includes('is:unread') && !msg.unread)
74
+ return false;
75
+ if (lower.includes('is:starred') && !msg.starred)
76
+ return false;
77
+ if (lower.includes('has:attachment') && msg.attachments.length === 0)
78
+ return false;
79
+ // Plain text: strip operators and check remaining text against subject/from
80
+ const plainText = lower
81
+ .replace(/from:\S+/g, '')
82
+ .replace(/to:\S+/g, '')
83
+ .replace(/subject:(?:"[^"]+"|[^\s]+)/g, '')
84
+ .replace(/is:\S+/g, '')
85
+ .replace(/has:\S+/g, '')
86
+ .trim();
87
+ if (plainText && !msg.subject.toLowerCase().includes(plainText) && !msg.from.email.toLowerCase().includes(plainText)) {
88
+ return false;
89
+ }
90
+ return true;
91
+ }
92
+ /** Boundary helper for imapflow calls — converts auth errors to typed values. */
93
+ function imapBoundary(email, fn) {
94
+ return errore.tryAsync({
95
+ try: fn,
96
+ catch: (err) => {
97
+ const msg = String(err);
98
+ if (msg.includes('Authentication') || msg.includes('AUTHENTICATIONFAILED') || msg.includes('LOGIN') || msg.includes('Invalid credentials')) {
99
+ return new AuthError({ email, reason: msg });
100
+ }
101
+ return new ApiError({ reason: msg, cause: err });
102
+ },
103
+ });
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // ImapSmtpClient
107
+ // ---------------------------------------------------------------------------
108
+ export class ImapSmtpClient {
109
+ imapCreds;
110
+ smtpCreds;
111
+ account;
112
+ smtpTransporter = null;
113
+ constructor({ credentials, account }) {
114
+ this.imapCreds = credentials.imap;
115
+ this.smtpCreds = credentials.smtp;
116
+ this.account = account;
117
+ }
118
+ // =========================================================================
119
+ // IMAP connection helpers
120
+ // =========================================================================
121
+ createImapClient() {
122
+ if (!this.imapCreds)
123
+ throw new Error('IMAP not configured for this account');
124
+ return new ImapFlow({
125
+ host: this.imapCreds.host,
126
+ port: this.imapCreds.port,
127
+ secure: this.imapCreds.tls,
128
+ auth: { user: this.imapCreds.user, pass: this.imapCreds.password },
129
+ logger: false,
130
+ });
131
+ }
132
+ /**
133
+ * Resolve a zele folder name (inbox, sent, trash, etc.) to the actual IMAP
134
+ * mailbox path by checking RFC 6154 specialUse attributes first, then
135
+ * falling back to common mailbox name variants.
136
+ */
137
+ async resolveMailboxPath(client, folder) {
138
+ const lower = folder.toLowerCase();
139
+ if (lower === 'inbox')
140
+ return 'INBOX';
141
+ if (lower === 'starred' || lower === 'all')
142
+ return 'INBOX';
143
+ // Check if it's a raw IMAP path that doesn't match any known folder name
144
+ const specialUse = SPECIAL_USE_MAP[lower];
145
+ if (!specialUse)
146
+ return folder; // raw IMAP path, pass through
147
+ // Discover via specialUse (RFC 6154)
148
+ const mailboxes = await client.list();
149
+ const bySpecialUse = mailboxes.find((m) => m.specialUse === specialUse);
150
+ if (bySpecialUse)
151
+ return bySpecialUse.path;
152
+ // Fallback: try common folder names
153
+ const fallbacks = FOLDER_FALLBACKS[lower];
154
+ if (fallbacks) {
155
+ const paths = new Set(mailboxes.map((m) => m.path));
156
+ for (const name of fallbacks) {
157
+ if (paths.has(name))
158
+ return name;
159
+ }
160
+ // Case-insensitive search as last resort
161
+ const lowerPaths = new Map(mailboxes.map((m) => [m.path.toLowerCase(), m.path]));
162
+ for (const name of fallbacks) {
163
+ const found = lowerPaths.get(name.toLowerCase());
164
+ if (found)
165
+ return found;
166
+ }
167
+ }
168
+ // Ultimate fallback: capitalize first letter
169
+ return folder.charAt(0).toUpperCase() + folder.slice(1);
170
+ }
171
+ /** Run an IMAP operation with auto-connect/logout.
172
+ * The entire callback is wrapped in imapBoundary so any IMAP error
173
+ * (getMailboxLock, search, fetch, etc.) becomes an error value. */
174
+ async withImap(fn) {
175
+ const client = this.createImapClient();
176
+ const connectResult = await imapBoundary(this.account.email, () => client.connect());
177
+ if (connectResult instanceof Error)
178
+ return connectResult;
179
+ const result = await imapBoundary(this.account.email, () => fn(client));
180
+ await client.logout().catch(() => { });
181
+ return result;
182
+ }
183
+ async getSmtpTransporter() {
184
+ if (this.smtpTransporter)
185
+ return this.smtpTransporter;
186
+ if (!this.smtpCreds)
187
+ return new UnsupportedError({ feature: 'Sending email', accountType: 'IMAP-only', hint: 'Add SMTP with: zele login imap --email ... --smtp-host ...' });
188
+ const nodemailer = await import('nodemailer');
189
+ this.smtpTransporter = nodemailer.default.createTransport({
190
+ host: this.smtpCreds.host,
191
+ port: this.smtpCreds.port,
192
+ secure: this.smtpCreds.tls,
193
+ auth: { user: this.smtpCreds.user, pass: this.smtpCreds.password },
194
+ });
195
+ return this.smtpTransporter;
196
+ }
197
+ // =========================================================================
198
+ // Thread operations (IMAP messages as single-message "threads")
199
+ // =========================================================================
200
+ async listThreads({ query, folder, maxResults = 25, labelIds, pageToken, } = {}) {
201
+ const lowerFolder = folder?.toLowerCase();
202
+ const isStarred = lowerFolder === 'starred';
203
+ // IMAP has no "all mail" folder on most servers — reject explicitly
204
+ if (lowerFolder === 'all') {
205
+ return new UnsupportedError({
206
+ feature: '"All Mail" folder',
207
+ accountType: 'IMAP/SMTP',
208
+ hint: 'Use --folder inbox, sent, trash, or another specific folder.',
209
+ });
210
+ }
211
+ return this.withImap(async (client) => {
212
+ const imapFolder = await this.resolveMailboxPath(client, folder ?? 'inbox');
213
+ const lock = await client.getMailboxLock(imapFolder);
214
+ try {
215
+ // Build search criteria — start with base criteria from folder
216
+ let searchCriteria = isStarred ? { flagged: true } : { all: true };
217
+ if (query) {
218
+ // Best-effort IMAP search: translate Gmail query syntax to IMAP SEARCH.
219
+ // Supported: from:, to:, subject:, newer_than:Nd/Nm, older_than:Nd/Nm,
220
+ // after:YYYY/MM/DD, before:YYYY/MM/DD, is:unread, is:starred,
221
+ // has:attachment, and plain text.
222
+ // Preserve base criteria (e.g. flagged from --folder starred)
223
+ const baseCriteria = isStarred ? { flagged: true } : {};
224
+ searchCriteria = { ...baseCriteria };
225
+ let hasSpecificCriteria = isStarred;
226
+ const fromMatch = query.match(/from:(\S+)/i);
227
+ if (fromMatch) {
228
+ searchCriteria.from = fromMatch[1];
229
+ hasSpecificCriteria = true;
230
+ }
231
+ const toMatch = query.match(/to:(\S+)/i);
232
+ if (toMatch) {
233
+ searchCriteria.to = toMatch[1];
234
+ hasSpecificCriteria = true;
235
+ }
236
+ const subjectMatch = query.match(/subject:(?:"([^"]+)"|(\S+))/i);
237
+ if (subjectMatch) {
238
+ searchCriteria.subject = subjectMatch[1] ?? subjectMatch[2];
239
+ hasSpecificCriteria = true;
240
+ }
241
+ // Date filters: newer_than:2d, newer_than:1m (days/months)
242
+ const newerMatch = query.match(/newer_than:(\d+)([dm])/i);
243
+ if (newerMatch) {
244
+ const n = Number(newerMatch[1]);
245
+ const unit = newerMatch[2].toLowerCase();
246
+ const since = new Date();
247
+ if (unit === 'd')
248
+ since.setDate(since.getDate() - n);
249
+ else
250
+ since.setMonth(since.getMonth() - n);
251
+ searchCriteria.since = since;
252
+ hasSpecificCriteria = true;
253
+ }
254
+ const olderMatch = query.match(/older_than:(\d+)([dm])/i);
255
+ if (olderMatch) {
256
+ const n = Number(olderMatch[1]);
257
+ const unit = olderMatch[2].toLowerCase();
258
+ const before = new Date();
259
+ if (unit === 'd')
260
+ before.setDate(before.getDate() - n);
261
+ else
262
+ before.setMonth(before.getMonth() - n);
263
+ searchCriteria.before = before;
264
+ hasSpecificCriteria = true;
265
+ }
266
+ // after:YYYY/MM/DD and before:YYYY/MM/DD
267
+ const afterMatch = query.match(/after:(\d{4}\/\d{1,2}\/\d{1,2})/i);
268
+ if (afterMatch) {
269
+ searchCriteria.since = new Date(afterMatch[1].replace(/\//g, '-'));
270
+ hasSpecificCriteria = true;
271
+ }
272
+ const beforeMatch = query.match(/before:(\d{4}\/\d{1,2}\/\d{1,2})/i);
273
+ if (beforeMatch) {
274
+ searchCriteria.before = new Date(beforeMatch[1].replace(/\//g, '-'));
275
+ hasSpecificCriteria = true;
276
+ }
277
+ // Flag filters
278
+ if (/is:unread/i.test(query)) {
279
+ searchCriteria.unseen = true;
280
+ hasSpecificCriteria = true;
281
+ }
282
+ if (/is:starred/i.test(query)) {
283
+ searchCriteria.flagged = true;
284
+ hasSpecificCriteria = true;
285
+ }
286
+ if (/has:attachment/i.test(query)) {
287
+ searchCriteria.header = { 'Content-Type': 'multipart/mixed' };
288
+ hasSpecificCriteria = true;
289
+ }
290
+ // Plain text remainder (strip known operators)
291
+ const plainText = query
292
+ .replace(/from:\S+/gi, '')
293
+ .replace(/to:\S+/gi, '')
294
+ .replace(/subject:(?:"[^"]+"|[^\s]+)/gi, '')
295
+ .replace(/newer_than:\S+/gi, '')
296
+ .replace(/older_than:\S+/gi, '')
297
+ .replace(/after:\S+/gi, '')
298
+ .replace(/before:\S+/gi, '')
299
+ .replace(/is:\S+/gi, '')
300
+ .replace(/has:\S+/gi, '')
301
+ .trim();
302
+ if (plainText) {
303
+ // Search in subject and body for remaining text
304
+ if (hasSpecificCriteria) {
305
+ searchCriteria.body = plainText;
306
+ }
307
+ else {
308
+ searchCriteria = { or: [{ subject: plainText }, { body: plainText }] };
309
+ }
310
+ }
311
+ else if (!hasSpecificCriteria) {
312
+ searchCriteria = { all: true };
313
+ }
314
+ }
315
+ const searchResult = await client.search(searchCriteria, { uid: true });
316
+ const uids = searchResult === false ? [] : searchResult;
317
+ if (uids.length === 0) {
318
+ return {
319
+ threads: [],
320
+ rawThreads: [],
321
+ nextPageToken: null,
322
+ resultSizeEstimate: 0,
323
+ };
324
+ }
325
+ // Sort by UID descending (newest first) and paginate
326
+ const sorted = [...uids].sort((a, b) => b - a);
327
+ const startIndex = pageToken ? Number(pageToken) : 0;
328
+ const page = sorted.slice(startIndex, startIndex + maxResults);
329
+ const nextPageToken = startIndex + maxResults < sorted.length
330
+ ? String(startIndex + maxResults)
331
+ : null;
332
+ // Fetch envelope data for the page
333
+ const threads = [];
334
+ if (page.length > 0) {
335
+ const uidRange = page.join(',');
336
+ for await (const msg of client.fetch(uidRange, {
337
+ uid: true,
338
+ envelope: true,
339
+ flags: true,
340
+ bodyStructure: true,
341
+ }, { uid: true })) {
342
+ const env = msg.envelope;
343
+ if (!env)
344
+ continue;
345
+ const flags = msg.flags ?? new Set();
346
+ const threadId = makeThreadId(imapFolder, msg.uid);
347
+ threads.push({
348
+ id: threadId,
349
+ historyId: null,
350
+ snippet: env.subject ?? '',
351
+ subject: env.subject ?? '(no subject)',
352
+ from: toSender(env.from?.[0]),
353
+ to: toSenders(env.to),
354
+ cc: toSenders(env.cc),
355
+ date: env.date?.toISOString() ?? new Date().toISOString(),
356
+ labelIds: [],
357
+ unread: !flags.has('\\Seen'),
358
+ starred: flags.has('\\Flagged'),
359
+ messageCount: 1,
360
+ inReplyTo: env.inReplyTo ?? null,
361
+ hasAttachments: this.hasAttachments(msg),
362
+ // IMAP list view uses envelope-only fetch, so raw headers aren't
363
+ // available. List-Unsubscribe stays null in list mode; it's
364
+ // resolved during getThread() where `source: true` is fetched.
365
+ listUnsubscribe: null,
366
+ listUnsubscribePost: null,
367
+ });
368
+ }
369
+ }
370
+ // Sort by date descending (envelopes may not come in order)
371
+ threads.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
372
+ return {
373
+ threads,
374
+ rawThreads: [],
375
+ nextPageToken,
376
+ resultSizeEstimate: sorted.length,
377
+ };
378
+ }
379
+ finally {
380
+ lock.release();
381
+ }
382
+ });
383
+ }
384
+ async getThread({ threadId }) {
385
+ const { folder, uid } = parseThreadId(threadId);
386
+ const result = await this.withImap(async (client) => {
387
+ const lock = await client.getMailboxLock(folder);
388
+ try {
389
+ // Fetch full message with body
390
+ let message = null;
391
+ for await (const msg of client.fetch(String(uid), {
392
+ uid: true,
393
+ envelope: true,
394
+ flags: true,
395
+ bodyStructure: true,
396
+ source: true,
397
+ }, { uid: true })) {
398
+ message = msg;
399
+ }
400
+ if (!message) {
401
+ return new NotFoundError({ resource: `message ${threadId}` });
402
+ }
403
+ const parsed = this.parseImapMessage(message, folder);
404
+ const threadData = {
405
+ id: threadId,
406
+ historyId: null,
407
+ messages: [parsed],
408
+ subject: parsed.subject,
409
+ snippet: parsed.snippet,
410
+ from: parsed.from,
411
+ date: parsed.date,
412
+ labelIds: [],
413
+ hasUnread: parsed.unread,
414
+ messageCount: 1,
415
+ };
416
+ return { parsed: threadData, raw: {} };
417
+ }
418
+ finally {
419
+ lock.release();
420
+ }
421
+ });
422
+ // getThread is expected to throw on failure (same as GmailClient)
423
+ // because callers like mail read destructure the result directly.
424
+ if (result instanceof Error)
425
+ throw result;
426
+ return result;
427
+ }
428
+ async getMessage({ messageId }) {
429
+ // For IMAP, messageId is the same as threadId
430
+ const result = await this.getThread({ threadId: messageId });
431
+ return result.parsed.messages[0];
432
+ }
433
+ async getRawMessage({ messageId }) {
434
+ const { folder, uid } = parseThreadId(messageId);
435
+ return this.withImap(async (client) => {
436
+ const lock = await client.getMailboxLock(folder);
437
+ try {
438
+ for await (const msg of client.fetch(String(uid), {
439
+ uid: true,
440
+ source: true,
441
+ }, { uid: true })) {
442
+ if (msg.source) {
443
+ return msg.source.toString('utf-8');
444
+ }
445
+ }
446
+ return new NotFoundError({ resource: `message ${messageId}` });
447
+ }
448
+ finally {
449
+ lock.release();
450
+ }
451
+ });
452
+ }
453
+ // =========================================================================
454
+ // Send operations (SMTP)
455
+ // =========================================================================
456
+ async sendMessage({ to, subject, body, cc, bcc, inReplyTo, references, attachments, }) {
457
+ const transporter = await this.getSmtpTransporter();
458
+ if (transporter instanceof Error)
459
+ return transporter;
460
+ const fromEmail = this.account.email;
461
+ const mailOptions = {
462
+ from: fromEmail,
463
+ to: to.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', '),
464
+ subject,
465
+ text: body,
466
+ };
467
+ if (cc && cc.length > 0) {
468
+ mailOptions.cc = cc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ');
469
+ }
470
+ if (bcc && bcc.length > 0) {
471
+ mailOptions.bcc = bcc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ');
472
+ }
473
+ if (inReplyTo) {
474
+ mailOptions.inReplyTo = inReplyTo;
475
+ }
476
+ if (references) {
477
+ mailOptions.references = references;
478
+ }
479
+ if (attachments && attachments.length > 0) {
480
+ mailOptions.attachments = attachments.map((a) => ({
481
+ filename: a.filename,
482
+ content: a.content,
483
+ contentType: a.mimeType,
484
+ }));
485
+ }
486
+ const sendResult = await transporter.sendMail(mailOptions)
487
+ .catch((e) => new ApiError({ reason: `SMTP send failed: ${String(e)}`, cause: e }));
488
+ if (sendResult instanceof Error)
489
+ return sendResult;
490
+ // APPEND a copy to the Sent folder so `mail list --folder sent` shows it.
491
+ // SMTP alone doesn't guarantee a copy in the mailbox.
492
+ // Build the raw MIME using nodemailer's MailComposer so attachments, HTML, etc. are preserved.
493
+ if (this.imapCreds) {
494
+ const nodemailer = await import('nodemailer');
495
+ const MailComposer = nodemailer.default?.MailComposer ?? nodemailer.MailComposer;
496
+ const rawMime = MailComposer
497
+ ? await new Promise((resolve, reject) => {
498
+ const mail = new MailComposer({ ...mailOptions, messageId: sendResult.messageId });
499
+ mail.compile().build((err, message) => {
500
+ if (err)
501
+ reject(err);
502
+ else
503
+ resolve(message);
504
+ });
505
+ }).catch((e) => new ApiError({ reason: `Failed to compile MIME for Sent copy: ${String(e)}`, cause: e }))
506
+ : (() => {
507
+ // Fallback: build plain-text RFC 822 if MailComposer unavailable
508
+ const rawHeaders = [
509
+ `From: ${fromEmail}`,
510
+ `To: ${mailOptions.to}`,
511
+ `Subject: ${subject}`,
512
+ `Date: ${new Date().toUTCString()}`,
513
+ `MIME-Version: 1.0`,
514
+ `Content-Type: text/plain; charset=utf-8`,
515
+ ...(mailOptions.cc ? [`Cc: ${mailOptions.cc}`] : []),
516
+ ...(inReplyTo ? [`In-Reply-To: ${inReplyTo}`] : []),
517
+ ...(references ? [`References: ${references}`] : []),
518
+ ...(sendResult.messageId ? [`Message-ID: ${sendResult.messageId}`] : []),
519
+ ];
520
+ return Buffer.from(rawHeaders.join('\r\n') + '\r\n\r\n' + body);
521
+ })();
522
+ if (rawMime instanceof Error) {
523
+ console.warn('Failed to build MIME for Sent copy:', rawMime.message);
524
+ }
525
+ else {
526
+ const appendResult = await this.withImap(async (client) => {
527
+ const sentPath = await this.resolveMailboxPath(client, 'sent');
528
+ await client.append(sentPath, rawMime, ['\\Seen']);
529
+ });
530
+ if (appendResult instanceof Error) {
531
+ console.warn('Sent message but failed to save to Sent folder:', appendResult.message);
532
+ }
533
+ }
534
+ }
535
+ return {
536
+ id: sendResult.messageId ?? 'unknown',
537
+ threadId: 'unknown',
538
+ labelIds: ['SENT'],
539
+ };
540
+ }
541
+ async replyToThread({ threadId, body, replyAll = false, cc, fromEmail, }) {
542
+ const thread = await this.getThread({ threadId });
543
+ if (thread.parsed.messages.length === 0) {
544
+ return new EmptyThreadError({ threadId });
545
+ }
546
+ const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1];
547
+ const replyTo = lastMsg.replyTo ?? lastMsg.from.email;
548
+ const to = [{ email: replyTo }];
549
+ let resolvedCc;
550
+ if (replyAll) {
551
+ const myEmail = this.account.email.toLowerCase();
552
+ const allRecipients = [
553
+ ...lastMsg.to.map((r) => r.email),
554
+ ...(lastMsg.cc?.map((r) => r.email) ?? []),
555
+ ]
556
+ .filter((e) => e.toLowerCase() !== myEmail)
557
+ .filter((e) => e.toLowerCase() !== replyTo.toLowerCase());
558
+ if (allRecipients.length > 0) {
559
+ resolvedCc = allRecipients.map((e) => ({ email: e }));
560
+ }
561
+ }
562
+ if (cc) {
563
+ resolvedCc = [...(resolvedCc ?? []), ...cc];
564
+ }
565
+ const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ');
566
+ return this.sendMessage({
567
+ to,
568
+ subject: lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`,
569
+ body,
570
+ cc: resolvedCc,
571
+ inReplyTo: lastMsg.messageId,
572
+ references: refs || undefined,
573
+ });
574
+ }
575
+ async forwardThread({ threadId, to, body, fromEmail, }) {
576
+ const thread = await this.getThread({ threadId });
577
+ if (thread.parsed.messages.length === 0) {
578
+ return new EmptyThreadError({ threadId });
579
+ }
580
+ const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1];
581
+ const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType);
582
+ const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
583
+ ? `${lastMsg.from.name} <${lastMsg.from.email}>`
584
+ : lastMsg.from.email;
585
+ const fullBody = [
586
+ body ?? '',
587
+ '',
588
+ '---------- Forwarded message ----------',
589
+ `From: ${fromStr}`,
590
+ `Date: ${lastMsg.date}`,
591
+ `Subject: ${lastMsg.subject}`,
592
+ `To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
593
+ '',
594
+ renderedBody,
595
+ ].join('\n');
596
+ return this.sendMessage({
597
+ to,
598
+ subject: `Fwd: ${lastMsg.subject}`,
599
+ body: fullBody,
600
+ });
601
+ }
602
+ // =========================================================================
603
+ // Flag operations (IMAP STORE)
604
+ // =========================================================================
605
+ async star({ threadIds }) {
606
+ return this.modifyFlags(threadIds, { add: ['\\Flagged'] });
607
+ }
608
+ async unstar({ threadIds }) {
609
+ return this.modifyFlags(threadIds, { remove: ['\\Flagged'] });
610
+ }
611
+ async markAsRead({ threadIds }) {
612
+ return this.modifyFlags(threadIds, { add: ['\\Seen'] });
613
+ }
614
+ async markAsUnread({ threadIds }) {
615
+ return this.modifyFlags(threadIds, { remove: ['\\Seen'] });
616
+ }
617
+ async trash({ threadId }) {
618
+ const { folder, uid } = parseThreadId(threadId);
619
+ return this.withImap(async (client) => {
620
+ const trashPath = await this.resolveMailboxPath(client, 'trash');
621
+ const lock = await client.getMailboxLock(folder);
622
+ try {
623
+ const moved = await errore.tryAsync({
624
+ try: () => client.messageMove(String(uid), trashPath, { uid: true }),
625
+ catch: (err) => new ApiError({ reason: `Failed to move to Trash: ${String(err)}`, cause: err }),
626
+ });
627
+ if (moved instanceof Error) {
628
+ // Fallback: set \Deleted flag
629
+ await client.messageFlagsAdd(String(uid), ['\\Deleted'], { uid: true });
630
+ }
631
+ }
632
+ finally {
633
+ lock.release();
634
+ }
635
+ });
636
+ }
637
+ async untrash({ threadId }) {
638
+ const { folder, uid } = parseThreadId(threadId);
639
+ // Move from whatever folder back to INBOX
640
+ return this.withImap(async (client) => {
641
+ const lock = await client.getMailboxLock(folder);
642
+ try {
643
+ await client.messageMove(String(uid), 'INBOX', { uid: true });
644
+ }
645
+ finally {
646
+ lock.release();
647
+ }
648
+ });
649
+ }
650
+ async archive({ threadIds }) {
651
+ for (const threadId of threadIds) {
652
+ const { folder, uid } = parseThreadId(threadId);
653
+ const result = await this.withImap(async (client) => {
654
+ const archivePath = await this.resolveMailboxPath(client, 'archive');
655
+ const lock = await client.getMailboxLock(folder);
656
+ try {
657
+ const moved = await errore.tryAsync({
658
+ try: () => client.messageMove(String(uid), archivePath, { uid: true }),
659
+ catch: (err) => new ApiError({ reason: `Failed to move to Archive: ${String(err)}`, cause: err }),
660
+ });
661
+ if (moved instanceof Error) {
662
+ // No archive folder available — mark as read as a minimal archive behavior
663
+ await client.messageFlagsAdd(String(uid), ['\\Seen'], { uid: true });
664
+ }
665
+ }
666
+ finally {
667
+ lock.release();
668
+ }
669
+ });
670
+ if (result instanceof Error)
671
+ return result;
672
+ }
673
+ }
674
+ async markAsSpam({ threadIds }) {
675
+ for (const threadId of threadIds) {
676
+ const { folder, uid } = parseThreadId(threadId);
677
+ const result = await this.withImap(async (client) => {
678
+ const junkPath = await this.resolveMailboxPath(client, 'spam');
679
+ const lock = await client.getMailboxLock(folder);
680
+ try {
681
+ const moveResult = await errore.tryAsync({
682
+ try: () => client.messageMove(String(uid), junkPath, { uid: true }),
683
+ catch: (err) => new ApiError({ reason: `Failed to move to Junk: ${String(err)}`, cause: err }),
684
+ });
685
+ if (moveResult instanceof Error) {
686
+ // Fallback: set $Junk keyword
687
+ await client.messageFlagsAdd(String(uid), ['$Junk'], { uid: true });
688
+ }
689
+ }
690
+ finally {
691
+ lock.release();
692
+ }
693
+ });
694
+ if (result instanceof Error)
695
+ return result;
696
+ }
697
+ }
698
+ async unmarkSpam({ threadIds }) {
699
+ for (const threadId of threadIds) {
700
+ const { folder, uid } = parseThreadId(threadId);
701
+ const result = await this.withImap(async (client) => {
702
+ const lock = await client.getMailboxLock(folder);
703
+ try {
704
+ await client.messageMove(String(uid), 'INBOX', { uid: true });
705
+ }
706
+ finally {
707
+ lock.release();
708
+ }
709
+ });
710
+ if (result instanceof Error)
711
+ return result;
712
+ }
713
+ }
714
+ async trashAllSpam() {
715
+ return this.withImap(async (client) => {
716
+ const junkPath = await this.resolveMailboxPath(client, 'spam');
717
+ const lock = await client.getMailboxLock(junkPath);
718
+ try {
719
+ const searchResult = await client.search({ all: true }, { uid: true });
720
+ const uids = searchResult === false ? [] : searchResult;
721
+ if (uids.length === 0)
722
+ return { count: 0 };
723
+ // Move all to Trash
724
+ const uidRange = uids.join(',');
725
+ const trashPath = await this.resolveMailboxPath(client, 'trash');
726
+ const moveResult = await errore.tryAsync({
727
+ try: () => client.messageMove(uidRange, trashPath, { uid: true }),
728
+ catch: (err) => new ApiError({ reason: `Failed to move spam to Trash: ${String(err)}`, cause: err }),
729
+ });
730
+ if (moveResult instanceof Error) {
731
+ await client.messageFlagsAdd(uidRange, ['\\Deleted'], { uid: true });
732
+ }
733
+ return { count: uids.length };
734
+ }
735
+ finally {
736
+ lock.release();
737
+ }
738
+ });
739
+ }
740
+ // =========================================================================
741
+ // Label operations (not supported for IMAP)
742
+ // =========================================================================
743
+ async listLabels() {
744
+ return new UnsupportedError({
745
+ feature: 'Labels',
746
+ accountType: 'IMAP/SMTP',
747
+ hint: 'IMAP accounts use folders. Use --folder to browse different mailboxes.',
748
+ });
749
+ }
750
+ async modifyLabels(_opts) {
751
+ return new UnsupportedError({
752
+ feature: 'Label modification',
753
+ accountType: 'IMAP/SMTP',
754
+ hint: 'IMAP accounts use folders, not labels.',
755
+ });
756
+ }
757
+ // =========================================================================
758
+ // Profile
759
+ // =========================================================================
760
+ async getProfile() {
761
+ // For IMAP, we can get basic info but not Gmail-specific stats
762
+ return {
763
+ emailAddress: this.account.email,
764
+ messagesTotal: 0,
765
+ threadsTotal: 0,
766
+ historyId: '0',
767
+ };
768
+ }
769
+ async getEmailAliases() {
770
+ // IMAP doesn't have send-as aliases
771
+ return [{ email: this.account.email, primary: true }];
772
+ }
773
+ // =========================================================================
774
+ // Attachment operations
775
+ // =========================================================================
776
+ async getAttachment({ messageId, attachmentId }) {
777
+ const { folder, uid } = parseThreadId(messageId);
778
+ return this.withImap(async (client) => {
779
+ const lock = await client.getMailboxLock(folder);
780
+ try {
781
+ // attachmentId is the MIME part number (e.g. "2", "1.2")
782
+ for await (const msg of client.fetch(String(uid), {
783
+ uid: true,
784
+ bodyParts: [attachmentId],
785
+ }, { uid: true })) {
786
+ const parts = msg.bodyParts;
787
+ if (parts) {
788
+ for (const [_key, value] of parts) {
789
+ return value.toString('base64');
790
+ }
791
+ }
792
+ }
793
+ return new NotFoundError({ resource: `attachment ${attachmentId} in message ${messageId}` });
794
+ }
795
+ finally {
796
+ lock.release();
797
+ }
798
+ });
799
+ }
800
+ // =========================================================================
801
+ // Watch (IMAP polling — simplified version without IDLE)
802
+ // =========================================================================
803
+ async *watchInbox({ folder = 'inbox', intervalMs = 15_000, query, once = false, } = {}) {
804
+ // Resolve folder path once (use a fresh connection)
805
+ let imapFolder = 'INBOX';
806
+ const resolveResult = await this.withImap(async (client) => {
807
+ return this.resolveMailboxPath(client, folder);
808
+ });
809
+ if (resolveResult instanceof Error)
810
+ throw resolveResult;
811
+ imapFolder = resolveResult;
812
+ let lastUid = 0;
813
+ // Seed with current highest UID
814
+ const seedResult = await this.withImap(async (client) => {
815
+ const lock = await client.getMailboxLock(imapFolder);
816
+ try {
817
+ const searchResult = await client.search({ all: true }, { uid: true });
818
+ const uids = searchResult === false ? [] : searchResult;
819
+ return uids.length > 0 ? Math.max(...uids) : 0;
820
+ }
821
+ finally {
822
+ lock.release();
823
+ }
824
+ });
825
+ if (seedResult instanceof Error)
826
+ throw seedResult;
827
+ lastUid = seedResult;
828
+ while (true) {
829
+ // Check for new messages since lastUid
830
+ const pollResult = await this.withImap(async (client) => {
831
+ const lock = await client.getMailboxLock(imapFolder);
832
+ try {
833
+ // Search for UIDs > lastUid
834
+ const searchResult = await client.search({ uid: `${lastUid + 1}:*` }, { uid: true });
835
+ const uids = searchResult === false ? [] : searchResult;
836
+ const newUids = uids.filter((u) => u > lastUid);
837
+ const events = [];
838
+ if (newUids.length > 0) {
839
+ const uidRange = newUids.join(',');
840
+ for await (const msg of client.fetch(uidRange, {
841
+ uid: true,
842
+ envelope: true,
843
+ flags: true,
844
+ source: true,
845
+ }, { uid: true })) {
846
+ const parsed = this.parseImapMessage(msg, imapFolder);
847
+ events.push({
848
+ account: this.account,
849
+ type: 'new_message',
850
+ message: parsed,
851
+ threadId: makeThreadId(imapFolder, msg.uid),
852
+ });
853
+ if (msg.uid > lastUid)
854
+ lastUid = msg.uid;
855
+ }
856
+ }
857
+ return events;
858
+ }
859
+ finally {
860
+ lock.release();
861
+ }
862
+ });
863
+ if (pollResult instanceof Error)
864
+ throw pollResult;
865
+ for (const event of pollResult) {
866
+ // Client-side query filtering (basic: from:, to:, subject:, is:unread, is:starred)
867
+ if (query && !matchesQuery(event.message, query))
868
+ continue;
869
+ yield event;
870
+ }
871
+ if (once)
872
+ return;
873
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
874
+ }
875
+ }
876
+ // =========================================================================
877
+ // Draft operations (IMAP Drafts folder)
878
+ // =========================================================================
879
+ async listDrafts({ query, maxResults = 20, pageToken, } = {}) {
880
+ return this.withImap(async (client) => {
881
+ const draftsPath = await this.resolveMailboxPath(client, 'drafts');
882
+ const lock = await client.getMailboxLock(draftsPath);
883
+ try {
884
+ const searchCriteria = query ? { or: [{ subject: query }, { body: query }] } : { all: true };
885
+ const searchResult = await client.search(searchCriteria, { uid: true });
886
+ const uids = searchResult === false ? [] : searchResult;
887
+ const sorted = [...uids].sort((a, b) => b - a);
888
+ const startIndex = pageToken ? Number(pageToken) : 0;
889
+ const page = sorted.slice(startIndex, startIndex + maxResults);
890
+ const nextPageToken = startIndex + maxResults < sorted.length ? String(startIndex + maxResults) : null;
891
+ const drafts = [];
892
+ if (page.length > 0) {
893
+ const uidRange = page.join(',');
894
+ for await (const msg of client.fetch(uidRange, {
895
+ uid: true,
896
+ envelope: true,
897
+ }, { uid: true })) {
898
+ const env = msg.envelope;
899
+ if (!env)
900
+ continue;
901
+ drafts.push({
902
+ id: makeThreadId(draftsPath, msg.uid),
903
+ subject: env.subject ?? '(no subject)',
904
+ to: (env.to ?? []).map((a) => a.address ?? '').filter(Boolean),
905
+ date: env.date?.toISOString() ?? new Date().toISOString(),
906
+ });
907
+ }
908
+ }
909
+ return { drafts, nextPageToken };
910
+ }
911
+ finally {
912
+ lock.release();
913
+ }
914
+ });
915
+ }
916
+ async createDraft({ to, subject, body, cc, bcc, threadId, fromEmail, }) {
917
+ // Build MIME message and APPEND to Drafts folder
918
+ const headers = [
919
+ `From: ${fromEmail ?? this.account.email}`,
920
+ `To: ${to.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')}`,
921
+ `Subject: ${subject}`,
922
+ `Date: ${new Date().toUTCString()}`,
923
+ `MIME-Version: 1.0`,
924
+ `Content-Type: text/plain; charset=utf-8`,
925
+ ];
926
+ if (cc && cc.length > 0) {
927
+ headers.push(`Cc: ${cc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')}`);
928
+ }
929
+ if (bcc && bcc.length > 0) {
930
+ headers.push(`Bcc: ${bcc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')}`);
931
+ }
932
+ const raw = headers.join('\r\n') + '\r\n\r\n' + body;
933
+ const rawBuffer = Buffer.from(raw);
934
+ const result = await this.withImap(async (client) => {
935
+ const draftsPath = await this.resolveMailboxPath(client, 'drafts');
936
+ const appendResult = await client.append(draftsPath, rawBuffer, ['\\Draft', '\\Seen']);
937
+ const uid = appendResult && typeof appendResult === 'object' && 'uid' in appendResult ? appendResult.uid : undefined;
938
+ return {
939
+ id: uid ? makeThreadId(draftsPath, uid) : 'unknown',
940
+ message: { id: 'unknown' },
941
+ threadId: uid ? makeThreadId(draftsPath, uid) : 'unknown',
942
+ };
943
+ });
944
+ if (result instanceof Error)
945
+ throw result;
946
+ return result;
947
+ }
948
+ async getDraft({ draftId }) {
949
+ // Reuse getThread to fetch the full message from Drafts folder
950
+ const result = await this.getThread({ threadId: draftId });
951
+ const msg = result.parsed.messages[0];
952
+ return {
953
+ id: draftId,
954
+ message: msg,
955
+ to: msg.to,
956
+ cc: msg.cc ?? [],
957
+ bcc: msg.bcc,
958
+ };
959
+ }
960
+ async sendDraft({ draftId }) {
961
+ // Fetch the draft message, send it via SMTP, then delete the draft
962
+ const draft = await this.getDraft({ draftId });
963
+ const result = await this.sendMessage({
964
+ to: draft.to,
965
+ subject: draft.message.subject,
966
+ body: draft.message.body,
967
+ cc: draft.cc.length > 0 ? draft.cc : undefined,
968
+ bcc: draft.bcc.length > 0 ? draft.bcc : undefined,
969
+ });
970
+ if (result instanceof Error)
971
+ return result;
972
+ // Delete the draft after sending
973
+ await this.deleteDraft({ draftId });
974
+ return result;
975
+ }
976
+ async deleteDraft({ draftId }) {
977
+ const { folder, uid } = parseThreadId(draftId);
978
+ return this.withImap(async (client) => {
979
+ const lock = await client.getMailboxLock(folder);
980
+ try {
981
+ await client.messageFlagsAdd(String(uid), ['\\Deleted'], { uid: true });
982
+ await client.messageDelete(String(uid), { uid: true });
983
+ }
984
+ finally {
985
+ lock.release();
986
+ }
987
+ });
988
+ }
989
+ /**
990
+ * Update an existing draft. IMAP has no native update — we delete the old
991
+ * draft and APPEND a new message to the Drafts folder.
992
+ */
993
+ async updateDraft({ draftId, to, subject, body, cc, bcc, fromEmail, }) {
994
+ // Delete old draft first — check for errors before creating replacement
995
+ const deleted = await this.deleteDraft({ draftId });
996
+ if (deleted instanceof Error)
997
+ return deleted;
998
+ // Create new draft with updated content
999
+ return this.createDraft({ to, subject, body, cc, bcc, fromEmail });
1000
+ }
1001
+ /**
1002
+ * Create a draft reply to a thread. Resolves reply-to, reply-all CCs,
1003
+ * and sets In-Reply-To/References headers, then appends to Drafts.
1004
+ */
1005
+ async createDraftReply({ threadId, body, replyAll = false, cc, fromEmail, }) {
1006
+ const thread = await this.getThread({ threadId });
1007
+ if (thread.parsed.messages.length === 0) {
1008
+ return new EmptyThreadError({ threadId });
1009
+ }
1010
+ const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1];
1011
+ const replyTo = lastMsg.replyTo ?? lastMsg.from.email;
1012
+ const to = [{ email: replyTo }];
1013
+ let resolvedCc;
1014
+ if (replyAll) {
1015
+ const myEmail = this.account.email.toLowerCase();
1016
+ const allRecipients = [
1017
+ ...lastMsg.to.map((r) => r.email),
1018
+ ...(lastMsg.cc?.map((r) => r.email) ?? []),
1019
+ ]
1020
+ .filter((e) => e.toLowerCase() !== myEmail)
1021
+ .filter((e) => e.toLowerCase() !== replyTo.toLowerCase());
1022
+ if (allRecipients.length > 0) {
1023
+ resolvedCc = allRecipients.map((e) => ({ email: e }));
1024
+ }
1025
+ }
1026
+ if (cc) {
1027
+ resolvedCc = [...(resolvedCc ?? []), ...cc];
1028
+ }
1029
+ const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ');
1030
+ const subject = lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`;
1031
+ // Build MIME with reply headers
1032
+ const headers = [
1033
+ `From: ${fromEmail ?? this.account.email}`,
1034
+ `To: ${to.map((r) => r.email).join(', ')}`,
1035
+ `Subject: ${subject}`,
1036
+ `Date: ${new Date().toUTCString()}`,
1037
+ `MIME-Version: 1.0`,
1038
+ `Content-Type: text/plain; charset=utf-8`,
1039
+ ];
1040
+ if (resolvedCc && resolvedCc.length > 0) {
1041
+ headers.push(`Cc: ${resolvedCc.map((r) => r.email).join(', ')}`);
1042
+ }
1043
+ if (lastMsg.messageId) {
1044
+ headers.push(`In-Reply-To: ${lastMsg.messageId}`);
1045
+ }
1046
+ if (refs) {
1047
+ headers.push(`References: ${refs}`);
1048
+ }
1049
+ const raw = headers.join('\r\n') + '\r\n\r\n' + body;
1050
+ const rawBuffer = Buffer.from(raw);
1051
+ const result = await this.withImap(async (client) => {
1052
+ const draftsPath = await this.resolveMailboxPath(client, 'drafts');
1053
+ const appendResult = await client.append(draftsPath, rawBuffer, ['\\Draft', '\\Seen']);
1054
+ const uid = appendResult && typeof appendResult === 'object' && 'uid' in appendResult ? appendResult.uid : undefined;
1055
+ return {
1056
+ id: uid ? makeThreadId(draftsPath, uid) : 'unknown',
1057
+ message: { id: 'unknown' },
1058
+ threadId: uid ? makeThreadId(draftsPath, uid) : 'unknown',
1059
+ };
1060
+ });
1061
+ if (result instanceof Error)
1062
+ return result;
1063
+ return result;
1064
+ }
1065
+ /**
1066
+ * Create a draft forwarding a thread. Builds the forwarded-message body
1067
+ * and appends to Drafts folder.
1068
+ */
1069
+ async createDraftForward({ threadId, to, body, fromEmail, }) {
1070
+ const thread = await this.getThread({ threadId });
1071
+ if (thread.parsed.messages.length === 0) {
1072
+ return new EmptyThreadError({ threadId });
1073
+ }
1074
+ const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1];
1075
+ const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType);
1076
+ const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
1077
+ ? `${lastMsg.from.name} <${lastMsg.from.email}>`
1078
+ : lastMsg.from.email;
1079
+ const fullBody = [
1080
+ body ?? '',
1081
+ '',
1082
+ '---------- Forwarded message ----------',
1083
+ `From: ${fromStr}`,
1084
+ `Date: ${lastMsg.date}`,
1085
+ `Subject: ${lastMsg.subject}`,
1086
+ `To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
1087
+ '',
1088
+ renderedBody,
1089
+ ].join('\n');
1090
+ return this.createDraft({
1091
+ to,
1092
+ subject: `Fwd: ${lastMsg.subject}`,
1093
+ body: fullBody,
1094
+ fromEmail,
1095
+ });
1096
+ }
1097
+ // =========================================================================
1098
+ // Folder listing (IMAP equivalent of labels)
1099
+ // =========================================================================
1100
+ async listFolders() {
1101
+ return this.withImap(async (client) => {
1102
+ const mailboxes = await client.list();
1103
+ return mailboxes.map((m) => ({
1104
+ name: m.name,
1105
+ path: m.path,
1106
+ specialUse: m.specialUse ?? undefined,
1107
+ flags: Array.from(m.flags),
1108
+ }));
1109
+ });
1110
+ }
1111
+ // =========================================================================
1112
+ // Cache stubs (no-op for IMAP — no local thread cache)
1113
+ // =========================================================================
1114
+ async invalidateThreads(_threadIds) { }
1115
+ async invalidateThread(_threadId) { }
1116
+ // =========================================================================
1117
+ // Private helpers
1118
+ // =========================================================================
1119
+ /** Modify IMAP flags on messages. Groups by folder for efficiency. */
1120
+ async modifyFlags(threadIds, opts) {
1121
+ // Group by folder
1122
+ const byFolder = new Map();
1123
+ for (const threadId of threadIds) {
1124
+ const { folder, uid } = parseThreadId(threadId);
1125
+ const uids = byFolder.get(folder) ?? [];
1126
+ uids.push(uid);
1127
+ byFolder.set(folder, uids);
1128
+ }
1129
+ for (const [folder, uids] of byFolder) {
1130
+ const result = await this.withImap(async (client) => {
1131
+ const lock = await client.getMailboxLock(folder);
1132
+ try {
1133
+ const uidRange = uids.join(',');
1134
+ if (opts.add && opts.add.length > 0) {
1135
+ await client.messageFlagsAdd(uidRange, opts.add, { uid: true });
1136
+ }
1137
+ if (opts.remove && opts.remove.length > 0) {
1138
+ await client.messageFlagsRemove(uidRange, opts.remove, { uid: true });
1139
+ }
1140
+ }
1141
+ finally {
1142
+ lock.release();
1143
+ }
1144
+ });
1145
+ if (result instanceof Error)
1146
+ return result;
1147
+ }
1148
+ }
1149
+ /** Check if a message has attachments from its bodyStructure. */
1150
+ hasAttachments(msg) {
1151
+ const bs = msg.bodyStructure;
1152
+ if (!bs)
1153
+ return false;
1154
+ // Check for non-inline parts
1155
+ const check = (part) => {
1156
+ if (part.disposition === 'attachment')
1157
+ return true;
1158
+ if (part.childNodes)
1159
+ return part.childNodes.some(check);
1160
+ return false;
1161
+ };
1162
+ return check(bs);
1163
+ }
1164
+ /** Parse an imapflow FetchMessageObject into our ParsedMessage type. */
1165
+ parseImapMessage(msg, folder) {
1166
+ const env = msg.envelope ?? {};
1167
+ const flags = msg.flags ?? new Set();
1168
+ const threadId = makeThreadId(folder, msg.uid);
1169
+ // Extract body from source if available
1170
+ let body = '';
1171
+ let mimeType = 'text/plain';
1172
+ let textBody = null;
1173
+ let listUnsubscribe;
1174
+ let listUnsubscribePost;
1175
+ if (msg.source) {
1176
+ const source = msg.source.toString('utf-8');
1177
+ const bodyResult = this.extractBodyFromSource(source);
1178
+ body = bodyResult.body;
1179
+ mimeType = bodyResult.mimeType;
1180
+ textBody = bodyResult.textBody;
1181
+ // Extract List-Unsubscribe / List-Unsubscribe-Post from raw MIME headers.
1182
+ // envelope-based fetches don't surface these, but getThread always fetches
1183
+ // source so it's available by the time we parse a full message.
1184
+ const headerEnd = source.indexOf('\r\n\r\n');
1185
+ const altEnd = source.indexOf('\n\n');
1186
+ const headerSplit = headerEnd !== -1 ? headerEnd : altEnd;
1187
+ const headerText = headerSplit === -1 ? source : source.slice(0, headerSplit);
1188
+ listUnsubscribe = this.getHeader(headerText, 'list-unsubscribe');
1189
+ listUnsubscribePost = this.getHeader(headerText, 'list-unsubscribe-post');
1190
+ }
1191
+ // Extract attachments from bodyStructure
1192
+ const attachments = [];
1193
+ if (msg.bodyStructure) {
1194
+ this.collectAttachments(msg.bodyStructure, '', attachments);
1195
+ }
1196
+ return {
1197
+ id: threadId,
1198
+ threadId,
1199
+ subject: env.subject ?? '(no subject)',
1200
+ snippet: (env.subject ?? '').slice(0, 100),
1201
+ from: toSender(env.from?.[0]),
1202
+ to: toSenders(env.to),
1203
+ cc: env.cc ? toSenders(env.cc) : null,
1204
+ bcc: toSenders(env.bcc),
1205
+ replyTo: env.replyTo?.[0]?.address,
1206
+ date: env.date?.toISOString() ?? new Date().toISOString(),
1207
+ labelIds: [],
1208
+ unread: !flags.has('\\Seen'),
1209
+ starred: flags.has('\\Flagged'),
1210
+ isDraft: flags.has('\\Draft'),
1211
+ messageId: env.messageId ?? '',
1212
+ inReplyTo: env.inReplyTo,
1213
+ references: undefined,
1214
+ listUnsubscribe,
1215
+ listUnsubscribePost,
1216
+ body,
1217
+ mimeType,
1218
+ textBody,
1219
+ attachments,
1220
+ auth: null, // IMAP doesn't provide SPF/DKIM/DMARC
1221
+ };
1222
+ }
1223
+ /** Extract body text from raw RFC 2822 source. */
1224
+ extractBodyFromSource(source) {
1225
+ // Find the boundary between headers and body
1226
+ const headerEnd = source.indexOf('\r\n\r\n');
1227
+ if (headerEnd === -1) {
1228
+ const altEnd = source.indexOf('\n\n');
1229
+ if (altEnd === -1)
1230
+ return { body: '', mimeType: 'text/plain', textBody: null };
1231
+ return this.parseBody(source.slice(altEnd + 2), source.slice(0, altEnd));
1232
+ }
1233
+ return this.parseBody(source.slice(headerEnd + 4), source.slice(0, headerEnd));
1234
+ }
1235
+ parseBody(bodyContent, headers) {
1236
+ const contentType = this.getHeader(headers, 'content-type') ?? 'text/plain';
1237
+ const transferEncoding = this.getHeader(headers, 'content-transfer-encoding') ?? '7bit';
1238
+ // Check if multipart
1239
+ const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/i);
1240
+ if (boundaryMatch) {
1241
+ const boundary = boundaryMatch[1];
1242
+ return this.parseMultipart(bodyContent, boundary);
1243
+ }
1244
+ // Single-part body
1245
+ let decoded = this.decodeTransferEncoding(bodyContent, transferEncoding);
1246
+ const charsetMatch = contentType.match(/charset="?([^";\s]+)"?/i);
1247
+ if (charsetMatch) {
1248
+ // Already UTF-8 string, but note the charset for future handling
1249
+ }
1250
+ const isHtml = contentType.toLowerCase().includes('text/html');
1251
+ return {
1252
+ body: decoded,
1253
+ mimeType: isHtml ? 'text/html' : 'text/plain',
1254
+ textBody: isHtml ? null : decoded,
1255
+ };
1256
+ }
1257
+ parseMultipart(body, boundary) {
1258
+ const parts = body.split(`--${boundary}`);
1259
+ let htmlBody = null;
1260
+ let textBody = null;
1261
+ for (const part of parts) {
1262
+ if (part.trim() === '--' || part.trim() === '')
1263
+ continue;
1264
+ const partHeaderEnd = part.indexOf('\r\n\r\n');
1265
+ const altEnd = part.indexOf('\n\n');
1266
+ const splitPos = partHeaderEnd !== -1 ? partHeaderEnd : altEnd;
1267
+ if (splitPos === -1)
1268
+ continue;
1269
+ const partHeaders = part.slice(0, splitPos);
1270
+ const partBody = part.slice(splitPos + (partHeaderEnd !== -1 ? 4 : 2));
1271
+ const partContentType = this.getHeader(partHeaders, 'content-type') ?? 'text/plain';
1272
+ const partEncoding = this.getHeader(partHeaders, 'content-transfer-encoding') ?? '7bit';
1273
+ // Recursive multipart
1274
+ const nestedBoundary = partContentType.match(/boundary="?([^";\s]+)"?/i);
1275
+ if (nestedBoundary) {
1276
+ const nested = this.parseMultipart(partBody, nestedBoundary[1]);
1277
+ if (nested.mimeType === 'text/html')
1278
+ htmlBody = nested.body;
1279
+ if (nested.textBody)
1280
+ textBody = nested.textBody;
1281
+ continue;
1282
+ }
1283
+ const decoded = this.decodeTransferEncoding(partBody, partEncoding);
1284
+ if (partContentType.toLowerCase().includes('text/html')) {
1285
+ htmlBody = decoded;
1286
+ }
1287
+ else if (partContentType.toLowerCase().includes('text/plain')) {
1288
+ textBody = decoded;
1289
+ }
1290
+ }
1291
+ // Prefer HTML, fall back to text
1292
+ if (htmlBody)
1293
+ return { body: htmlBody, mimeType: 'text/html', textBody };
1294
+ if (textBody)
1295
+ return { body: textBody, mimeType: 'text/plain', textBody };
1296
+ return { body: '', mimeType: 'text/plain', textBody: null };
1297
+ }
1298
+ decodeTransferEncoding(content, encoding) {
1299
+ const enc = encoding.toLowerCase().trim();
1300
+ if (enc === 'base64') {
1301
+ return Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8');
1302
+ }
1303
+ if (enc === 'quoted-printable') {
1304
+ return content
1305
+ .replace(/=\r?\n/g, '') // Soft line breaks
1306
+ .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
1307
+ }
1308
+ return content;
1309
+ }
1310
+ getHeader(headers, name) {
1311
+ const regex = new RegExp(`^${name}:\\s*(.+?)$`, 'im');
1312
+ const match = headers.match(regex);
1313
+ if (!match)
1314
+ return undefined;
1315
+ // Handle folded headers (continuation lines starting with whitespace)
1316
+ let value = match[1].trim();
1317
+ const lines = headers.split(/\r?\n/);
1318
+ let found = false;
1319
+ for (const line of lines) {
1320
+ if (found && /^\s/.test(line)) {
1321
+ value += ' ' + line.trim();
1322
+ }
1323
+ else if (line.toLowerCase().startsWith(name.toLowerCase() + ':')) {
1324
+ found = true;
1325
+ }
1326
+ else if (found) {
1327
+ break;
1328
+ }
1329
+ }
1330
+ return value;
1331
+ }
1332
+ /** Recursively collect attachment metadata from bodyStructure. */
1333
+ collectAttachments(part, prefix, attachments) {
1334
+ if (part.disposition === 'attachment' || (part.disposition === 'inline' && part.parameters?.name)) {
1335
+ attachments.push({
1336
+ attachmentId: part.part ?? prefix,
1337
+ filename: part.dispositionParameters?.filename ?? part.parameters?.name ?? 'attachment',
1338
+ mimeType: part.type ?? 'application/octet-stream',
1339
+ size: part.size ?? 0,
1340
+ });
1341
+ }
1342
+ if (part.childNodes) {
1343
+ for (let i = 0; i < part.childNodes.length; i++) {
1344
+ this.collectAttachments(part.childNodes[i], prefix ? `${prefix}.${i + 1}` : String(i + 1), attachments);
1345
+ }
1346
+ }
1347
+ }
1348
+ }
1349
+ //# sourceMappingURL=imap-smtp-client.js.map