zele 0.3.16 → 0.3.17

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