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,1560 @@
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
+ // IMAP list view uses envelope-only fetch, so raw headers aren't
386
+ // available. List-Unsubscribe stays null in list mode; it's
387
+ // resolved during getThread() where `source: true` is fetched.
388
+ listUnsubscribe: null,
389
+ listUnsubscribePost: null,
390
+ })
391
+ }
392
+ }
393
+
394
+ // Sort by date descending (envelopes may not come in order)
395
+ threads.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
396
+
397
+ return {
398
+ threads,
399
+ rawThreads: [],
400
+ nextPageToken,
401
+ resultSizeEstimate: sorted.length,
402
+ }
403
+ } finally {
404
+ lock.release()
405
+ }
406
+ }) as Promise<ThreadListResult | AuthError | ApiError>
407
+ }
408
+
409
+ async getThread({ threadId }: { threadId: string }): Promise<ThreadResult> {
410
+ const { folder, uid } = parseThreadId(threadId)
411
+
412
+ const result = await this.withImap(async (client) => {
413
+ const lock = await client.getMailboxLock(folder)
414
+ try {
415
+ // Fetch full message with body
416
+ let message: FetchMessageObject | null = null
417
+ for await (const msg of client.fetch(String(uid), {
418
+ uid: true,
419
+ envelope: true,
420
+ flags: true,
421
+ bodyStructure: true,
422
+ source: true,
423
+ }, { uid: true })) {
424
+ message = msg
425
+ }
426
+
427
+ if (!message) {
428
+ return new NotFoundError({ resource: `message ${threadId}` })
429
+ }
430
+
431
+ const parsed = this.parseImapMessage(message, folder)
432
+
433
+ const threadData: ThreadData = {
434
+ id: threadId,
435
+ historyId: null,
436
+ messages: [parsed],
437
+ subject: parsed.subject,
438
+ snippet: parsed.snippet,
439
+ from: parsed.from,
440
+ date: parsed.date,
441
+ labelIds: [],
442
+ hasUnread: parsed.unread,
443
+ messageCount: 1,
444
+ }
445
+
446
+ return { parsed: threadData, raw: {} } as ThreadResult
447
+ } finally {
448
+ lock.release()
449
+ }
450
+ })
451
+
452
+ // getThread is expected to throw on failure (same as GmailClient)
453
+ // because callers like mail read destructure the result directly.
454
+ if (result instanceof Error) throw result
455
+ return result as ThreadResult
456
+ }
457
+
458
+ async getMessage({ messageId }: { messageId: string }): Promise<ParsedMessage | AuthError | ApiError> {
459
+ // For IMAP, messageId is the same as threadId
460
+ const result = await this.getThread({ threadId: messageId })
461
+ return result.parsed.messages[0]!
462
+ }
463
+
464
+ async getRawMessage({ messageId }: { messageId: string }): Promise<string | NotFoundError | AuthError | ApiError> {
465
+ const { folder, uid } = parseThreadId(messageId)
466
+
467
+ return this.withImap(async (client) => {
468
+ const lock = await client.getMailboxLock(folder)
469
+ try {
470
+ for await (const msg of client.fetch(String(uid), {
471
+ uid: true,
472
+ source: true,
473
+ }, { uid: true })) {
474
+ if (msg.source) {
475
+ return msg.source.toString('utf-8')
476
+ }
477
+ }
478
+ return new NotFoundError({ resource: `message ${messageId}` })
479
+ } finally {
480
+ lock.release()
481
+ }
482
+ }) as Promise<string | NotFoundError | AuthError | ApiError>
483
+ }
484
+
485
+ // =========================================================================
486
+ // Send operations (SMTP)
487
+ // =========================================================================
488
+
489
+ async sendMessage({
490
+ to,
491
+ subject,
492
+ body,
493
+ cc,
494
+ bcc,
495
+ inReplyTo,
496
+ references,
497
+ attachments,
498
+ }: {
499
+ to: Array<{ name?: string; email: string }>
500
+ subject: string
501
+ body: string
502
+ cc?: Array<{ name?: string; email: string }>
503
+ bcc?: Array<{ name?: string; email: string }>
504
+ threadId?: string
505
+ inReplyTo?: string
506
+ references?: string
507
+ attachments?: Array<{ filename: string; mimeType: string; content: Buffer }>
508
+ fromEmail?: string
509
+ }): Promise<{ id: string; threadId: string; labelIds: string[] } | UnsupportedError | AuthError | ApiError> {
510
+ const transporter = await this.getSmtpTransporter()
511
+ if (transporter instanceof Error) return transporter
512
+ const fromEmail = this.account.email
513
+
514
+ const mailOptions: any = {
515
+ from: fromEmail,
516
+ to: to.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', '),
517
+ subject,
518
+ text: body,
519
+ }
520
+
521
+ if (cc && cc.length > 0) {
522
+ mailOptions.cc = cc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')
523
+ }
524
+ if (bcc && bcc.length > 0) {
525
+ mailOptions.bcc = bcc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')
526
+ }
527
+ if (inReplyTo) {
528
+ mailOptions.inReplyTo = inReplyTo
529
+ }
530
+ if (references) {
531
+ mailOptions.references = references
532
+ }
533
+ if (attachments && attachments.length > 0) {
534
+ mailOptions.attachments = attachments.map((a) => ({
535
+ filename: a.filename,
536
+ content: a.content,
537
+ contentType: a.mimeType,
538
+ }))
539
+ }
540
+
541
+ const sendResult = await transporter.sendMail(mailOptions)
542
+ .catch((e: unknown) => new ApiError({ reason: `SMTP send failed: ${String(e)}`, cause: e as Error }))
543
+ if (sendResult instanceof Error) return sendResult as ApiError
544
+
545
+ // APPEND a copy to the Sent folder so `mail list --folder sent` shows it.
546
+ // SMTP alone doesn't guarantee a copy in the mailbox.
547
+ // Build the raw MIME using nodemailer's MailComposer so attachments, HTML, etc. are preserved.
548
+ if (this.imapCreds) {
549
+ const nodemailer = await import('nodemailer')
550
+ const MailComposer = (nodemailer as any).default?.MailComposer ?? (nodemailer as any).MailComposer
551
+ const rawMime: Buffer | ApiError = MailComposer
552
+ ? await new Promise<Buffer>((resolve, reject) => {
553
+ const mail = new MailComposer({ ...mailOptions, messageId: sendResult.messageId })
554
+ mail.compile().build((err: Error | null, message: Buffer) => {
555
+ if (err) reject(err)
556
+ else resolve(message)
557
+ })
558
+ }).catch((e: unknown) => new ApiError({ reason: `Failed to compile MIME for Sent copy: ${String(e)}`, cause: e as Error }))
559
+ : (() => {
560
+ // Fallback: build plain-text RFC 822 if MailComposer unavailable
561
+ const rawHeaders = [
562
+ `From: ${fromEmail}`,
563
+ `To: ${mailOptions.to}`,
564
+ `Subject: ${subject}`,
565
+ `Date: ${new Date().toUTCString()}`,
566
+ `MIME-Version: 1.0`,
567
+ `Content-Type: text/plain; charset=utf-8`,
568
+ ...(mailOptions.cc ? [`Cc: ${mailOptions.cc}`] : []),
569
+ ...(inReplyTo ? [`In-Reply-To: ${inReplyTo}`] : []),
570
+ ...(references ? [`References: ${references}`] : []),
571
+ ...(sendResult.messageId ? [`Message-ID: ${sendResult.messageId}`] : []),
572
+ ]
573
+ return Buffer.from(rawHeaders.join('\r\n') + '\r\n\r\n' + body)
574
+ })()
575
+
576
+ if (rawMime instanceof Error) {
577
+ console.warn('Failed to build MIME for Sent copy:', rawMime.message)
578
+ } else {
579
+ const appendResult = await this.withImap(async (client) => {
580
+ const sentPath = await this.resolveMailboxPath(client, 'sent')
581
+ await client.append(sentPath, rawMime, ['\\Seen'])
582
+ })
583
+ if (appendResult instanceof Error) {
584
+ console.warn('Sent message but failed to save to Sent folder:', appendResult.message)
585
+ }
586
+ }
587
+ }
588
+
589
+ return {
590
+ id: sendResult.messageId ?? 'unknown',
591
+ threadId: 'unknown',
592
+ labelIds: ['SENT'],
593
+ }
594
+ }
595
+
596
+ async replyToThread({
597
+ threadId,
598
+ body,
599
+ replyAll = false,
600
+ cc,
601
+ fromEmail,
602
+ }: {
603
+ threadId: string
604
+ body: string
605
+ replyAll?: boolean
606
+ cc?: Array<{ email: string }>
607
+ fromEmail?: string
608
+ }): Promise<EmptyThreadError | UnsupportedError | AuthError | ApiError | { id: string; threadId: string; labelIds: string[] }> {
609
+ const thread = await this.getThread({ threadId })
610
+ if (thread.parsed.messages.length === 0) {
611
+ return new EmptyThreadError({ threadId })
612
+ }
613
+
614
+ const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1]!
615
+ const replyTo = lastMsg.replyTo ?? lastMsg.from.email
616
+ const to = [{ email: replyTo }]
617
+
618
+ let resolvedCc: Array<{ email: string }> | undefined
619
+ if (replyAll) {
620
+ const myEmail = this.account.email.toLowerCase()
621
+ const allRecipients = [
622
+ ...lastMsg.to.map((r) => r.email),
623
+ ...(lastMsg.cc?.map((r) => r.email) ?? []),
624
+ ]
625
+ .filter((e) => e.toLowerCase() !== myEmail)
626
+ .filter((e) => e.toLowerCase() !== replyTo.toLowerCase())
627
+
628
+ if (allRecipients.length > 0) {
629
+ resolvedCc = allRecipients.map((e) => ({ email: e }))
630
+ }
631
+ }
632
+
633
+ if (cc) {
634
+ resolvedCc = [...(resolvedCc ?? []), ...cc]
635
+ }
636
+
637
+ const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ')
638
+
639
+ return this.sendMessage({
640
+ to,
641
+ subject: lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`,
642
+ body,
643
+ cc: resolvedCc,
644
+ inReplyTo: lastMsg.messageId,
645
+ references: refs || undefined,
646
+ })
647
+ }
648
+
649
+ async forwardThread({
650
+ threadId,
651
+ to,
652
+ body,
653
+ fromEmail,
654
+ }: {
655
+ threadId: string
656
+ to: Array<{ email: string }>
657
+ body?: string
658
+ fromEmail?: string
659
+ }): Promise<EmptyThreadError | UnsupportedError | AuthError | ApiError | { id: string; threadId: string; labelIds: string[] }> {
660
+ const thread = await this.getThread({ threadId })
661
+ if (thread.parsed.messages.length === 0) {
662
+ return new EmptyThreadError({ threadId })
663
+ }
664
+
665
+ const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1]!
666
+ const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType)
667
+
668
+ const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
669
+ ? `${lastMsg.from.name} <${lastMsg.from.email}>`
670
+ : lastMsg.from.email
671
+
672
+ const fullBody = [
673
+ body ?? '',
674
+ '',
675
+ '---------- Forwarded message ----------',
676
+ `From: ${fromStr}`,
677
+ `Date: ${lastMsg.date}`,
678
+ `Subject: ${lastMsg.subject}`,
679
+ `To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
680
+ '',
681
+ renderedBody,
682
+ ].join('\n')
683
+
684
+ return this.sendMessage({
685
+ to,
686
+ subject: `Fwd: ${lastMsg.subject}`,
687
+ body: fullBody,
688
+ })
689
+ }
690
+
691
+ // =========================================================================
692
+ // Flag operations (IMAP STORE)
693
+ // =========================================================================
694
+
695
+ async star({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
696
+ return this.modifyFlags(threadIds, { add: ['\\Flagged'] })
697
+ }
698
+
699
+ async unstar({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
700
+ return this.modifyFlags(threadIds, { remove: ['\\Flagged'] })
701
+ }
702
+
703
+ async markAsRead({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
704
+ return this.modifyFlags(threadIds, { add: ['\\Seen'] })
705
+ }
706
+
707
+ async markAsUnread({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
708
+ return this.modifyFlags(threadIds, { remove: ['\\Seen'] })
709
+ }
710
+
711
+ async trash({ threadId }: { threadId: string }): Promise<void | AuthError | ApiError> {
712
+ const { folder, uid } = parseThreadId(threadId)
713
+ return this.withImap(async (client) => {
714
+ const trashPath = await this.resolveMailboxPath(client, 'trash')
715
+ const lock = await client.getMailboxLock(folder)
716
+ try {
717
+ const moved = await errore.tryAsync({
718
+ try: () => client.messageMove(String(uid), trashPath, { uid: true }),
719
+ catch: (err) => new ApiError({ reason: `Failed to move to Trash: ${String(err)}`, cause: err }),
720
+ })
721
+ if (moved instanceof Error) {
722
+ // Fallback: set \Deleted flag
723
+ await client.messageFlagsAdd(String(uid), ['\\Deleted'], { uid: true })
724
+ }
725
+ } finally {
726
+ lock.release()
727
+ }
728
+ }) as Promise<void | AuthError | ApiError>
729
+ }
730
+
731
+ async untrash({ threadId }: { threadId: string }): Promise<void | AuthError | ApiError> {
732
+ const { folder, uid } = parseThreadId(threadId)
733
+ // Move from whatever folder back to INBOX
734
+ return this.withImap(async (client) => {
735
+ const lock = await client.getMailboxLock(folder)
736
+ try {
737
+ await client.messageMove(String(uid), 'INBOX', { uid: true })
738
+ } finally {
739
+ lock.release()
740
+ }
741
+ }) as Promise<void | AuthError | ApiError>
742
+ }
743
+
744
+ async archive({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
745
+ for (const threadId of threadIds) {
746
+ const { folder, uid } = parseThreadId(threadId)
747
+ const result = await this.withImap(async (client) => {
748
+ const archivePath = await this.resolveMailboxPath(client, 'archive')
749
+ const lock = await client.getMailboxLock(folder)
750
+ try {
751
+ const moved = await errore.tryAsync({
752
+ try: () => client.messageMove(String(uid), archivePath, { uid: true }),
753
+ catch: (err) => new ApiError({ reason: `Failed to move to Archive: ${String(err)}`, cause: err }),
754
+ })
755
+ if (moved instanceof Error) {
756
+ // No archive folder available — mark as read as a minimal archive behavior
757
+ await client.messageFlagsAdd(String(uid), ['\\Seen'], { uid: true })
758
+ }
759
+ } finally {
760
+ lock.release()
761
+ }
762
+ })
763
+ if (result instanceof Error) return result
764
+ }
765
+ }
766
+
767
+ async markAsSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
768
+ for (const threadId of threadIds) {
769
+ const { folder, uid } = parseThreadId(threadId)
770
+ const result = await this.withImap(async (client) => {
771
+ const junkPath = await this.resolveMailboxPath(client, 'spam')
772
+ const lock = await client.getMailboxLock(folder)
773
+ try {
774
+ const moveResult = await errore.tryAsync({
775
+ try: () => client.messageMove(String(uid), junkPath, { uid: true }),
776
+ catch: (err) => new ApiError({ reason: `Failed to move to Junk: ${String(err)}`, cause: err }),
777
+ })
778
+ if (moveResult instanceof Error) {
779
+ // Fallback: set $Junk keyword
780
+ await client.messageFlagsAdd(String(uid), ['$Junk'], { uid: true })
781
+ }
782
+ } finally {
783
+ lock.release()
784
+ }
785
+ })
786
+ if (result instanceof Error) return result
787
+ }
788
+ }
789
+
790
+ async unmarkSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
791
+ for (const threadId of threadIds) {
792
+ const { folder, uid } = parseThreadId(threadId)
793
+ const result = await this.withImap(async (client) => {
794
+ const lock = await client.getMailboxLock(folder)
795
+ try {
796
+ await client.messageMove(String(uid), 'INBOX', { uid: true })
797
+ } finally {
798
+ lock.release()
799
+ }
800
+ })
801
+ if (result instanceof Error) return result
802
+ }
803
+ }
804
+
805
+ async trashAllSpam(): Promise<{ count: number } | AuthError | ApiError> {
806
+ return this.withImap(async (client) => {
807
+ const junkPath = await this.resolveMailboxPath(client, 'spam')
808
+ const lock = await client.getMailboxLock(junkPath)
809
+ try {
810
+ const searchResult = await client.search({ all: true }, { uid: true })
811
+ const uids = searchResult === false ? [] : searchResult
812
+ if (uids.length === 0) return { count: 0 }
813
+ // Move all to Trash
814
+ const uidRange = uids.join(',')
815
+ const trashPath = await this.resolveMailboxPath(client, 'trash')
816
+ const moveResult = await errore.tryAsync({
817
+ try: () => client.messageMove(uidRange, trashPath, { uid: true }),
818
+ catch: (err) => new ApiError({ reason: `Failed to move spam to Trash: ${String(err)}`, cause: err }),
819
+ })
820
+ if (moveResult instanceof Error) {
821
+ await client.messageFlagsAdd(uidRange, ['\\Deleted'], { uid: true })
822
+ }
823
+ return { count: uids.length }
824
+ } finally {
825
+ lock.release()
826
+ }
827
+ }) as Promise<{ count: number } | AuthError | ApiError>
828
+ }
829
+
830
+ // =========================================================================
831
+ // Label operations (not supported for IMAP)
832
+ // =========================================================================
833
+
834
+ async listLabels(): Promise<UnsupportedError> {
835
+ return new UnsupportedError({
836
+ feature: 'Labels',
837
+ accountType: 'IMAP/SMTP',
838
+ hint: 'IMAP accounts use folders. Use --folder to browse different mailboxes.',
839
+ })
840
+ }
841
+
842
+ async modifyLabels(_opts: { threadIds: string[]; addLabelIds: string[]; removeLabelIds: string[] }): Promise<UnsupportedError> {
843
+ return new UnsupportedError({
844
+ feature: 'Label modification',
845
+ accountType: 'IMAP/SMTP',
846
+ hint: 'IMAP accounts use folders, not labels.',
847
+ })
848
+ }
849
+
850
+ // =========================================================================
851
+ // Profile
852
+ // =========================================================================
853
+
854
+ async getProfile(): Promise<{ emailAddress: string; messagesTotal: number; threadsTotal: number; historyId: string } | AuthError | ApiError> {
855
+ // For IMAP, we can get basic info but not Gmail-specific stats
856
+ return {
857
+ emailAddress: this.account.email,
858
+ messagesTotal: 0,
859
+ threadsTotal: 0,
860
+ historyId: '0',
861
+ }
862
+ }
863
+
864
+ async getEmailAliases(): Promise<Array<{ email: string; name?: string; primary: boolean }> | AuthError | ApiError> {
865
+ // IMAP doesn't have send-as aliases
866
+ return [{ email: this.account.email, primary: true }]
867
+ }
868
+
869
+ // =========================================================================
870
+ // Attachment operations
871
+ // =========================================================================
872
+
873
+ async getAttachment({ messageId, attachmentId }: { messageId: string; attachmentId: string }): Promise<string | NotFoundError | AuthError | ApiError> {
874
+ const { folder, uid } = parseThreadId(messageId)
875
+
876
+ return this.withImap(async (client) => {
877
+ const lock = await client.getMailboxLock(folder)
878
+ try {
879
+ // attachmentId is the MIME part number (e.g. "2", "1.2")
880
+ for await (const msg of client.fetch(String(uid), {
881
+ uid: true,
882
+ bodyParts: [attachmentId],
883
+ }, { uid: true })) {
884
+ const parts = msg.bodyParts
885
+ if (parts) {
886
+ for (const [_key, value] of parts) {
887
+ return value.toString('base64')
888
+ }
889
+ }
890
+ }
891
+ return new NotFoundError({ resource: `attachment ${attachmentId} in message ${messageId}` })
892
+ } finally {
893
+ lock.release()
894
+ }
895
+ }) as Promise<string | NotFoundError | AuthError | ApiError>
896
+ }
897
+
898
+ // =========================================================================
899
+ // Watch (IMAP polling — simplified version without IDLE)
900
+ // =========================================================================
901
+
902
+ async *watchInbox({
903
+ folder = 'inbox',
904
+ intervalMs = 15_000,
905
+ query,
906
+ once = false,
907
+ }: {
908
+ folder?: string
909
+ intervalMs?: number
910
+ query?: string
911
+ once?: boolean
912
+ } = {}): AsyncGenerator<WatchEvent> {
913
+ // Resolve folder path once (use a fresh connection)
914
+ let imapFolder = 'INBOX'
915
+ const resolveResult = await this.withImap(async (client) => {
916
+ return this.resolveMailboxPath(client, folder)
917
+ })
918
+ if (resolveResult instanceof Error) throw resolveResult
919
+ imapFolder = resolveResult as string
920
+
921
+ let lastUid = 0
922
+
923
+ // Seed with current highest UID
924
+ const seedResult = await this.withImap(async (client) => {
925
+ const lock = await client.getMailboxLock(imapFolder)
926
+ try {
927
+ const searchResult = await client.search({ all: true }, { uid: true })
928
+ const uids = searchResult === false ? [] : searchResult
929
+ return uids.length > 0 ? Math.max(...uids) : 0
930
+ } finally {
931
+ lock.release()
932
+ }
933
+ })
934
+ if (seedResult instanceof Error) throw seedResult
935
+ lastUid = seedResult as number
936
+
937
+ while (true) {
938
+ // Check for new messages since lastUid
939
+ const pollResult = await this.withImap(async (client) => {
940
+ const lock = await client.getMailboxLock(imapFolder)
941
+ try {
942
+ // Search for UIDs > lastUid
943
+ const searchResult = await client.search({ uid: `${lastUid + 1}:*` }, { uid: true })
944
+ const uids = searchResult === false ? [] : searchResult
945
+ const newUids = uids.filter((u) => u > lastUid)
946
+
947
+ const events: WatchEvent[] = []
948
+ if (newUids.length > 0) {
949
+ const uidRange = newUids.join(',')
950
+ for await (const msg of client.fetch(uidRange, {
951
+ uid: true,
952
+ envelope: true,
953
+ flags: true,
954
+ source: true,
955
+ }, { uid: true })) {
956
+ const parsed = this.parseImapMessage(msg, imapFolder)
957
+ events.push({
958
+ account: this.account,
959
+ type: 'new_message',
960
+ message: parsed,
961
+ threadId: makeThreadId(imapFolder, msg.uid),
962
+ })
963
+ if (msg.uid > lastUid) lastUid = msg.uid
964
+ }
965
+ }
966
+ return events
967
+ } finally {
968
+ lock.release()
969
+ }
970
+ })
971
+
972
+ if (pollResult instanceof Error) throw pollResult
973
+ for (const event of pollResult as WatchEvent[]) {
974
+ // Client-side query filtering (basic: from:, to:, subject:, is:unread, is:starred)
975
+ if (query && !matchesQuery(event.message, query)) continue
976
+ yield event
977
+ }
978
+
979
+ if (once) return
980
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
981
+ }
982
+ }
983
+
984
+ // =========================================================================
985
+ // Draft operations (IMAP Drafts folder)
986
+ // =========================================================================
987
+
988
+ async listDrafts({
989
+ query,
990
+ maxResults = 20,
991
+ pageToken,
992
+ }: {
993
+ query?: string
994
+ maxResults?: number
995
+ pageToken?: string
996
+ } = {}): Promise<{ drafts: Array<{ id: string; subject: string; to: string[]; date: string }>; nextPageToken: string | null } | AuthError | ApiError> {
997
+ return this.withImap(async (client) => {
998
+ const draftsPath = await this.resolveMailboxPath(client, 'drafts')
999
+ const lock = await client.getMailboxLock(draftsPath)
1000
+ try {
1001
+ const searchCriteria = query ? { or: [{ subject: query }, { body: query }] } : { all: true }
1002
+ const searchResult = await client.search(searchCriteria as any, { uid: true })
1003
+ const uids = searchResult === false ? [] : searchResult
1004
+
1005
+ const sorted = [...uids].sort((a, b) => b - a)
1006
+ const startIndex = pageToken ? Number(pageToken) : 0
1007
+ const page = sorted.slice(startIndex, startIndex + maxResults)
1008
+ const nextPageToken = startIndex + maxResults < sorted.length ? String(startIndex + maxResults) : null
1009
+
1010
+ const drafts: Array<{ id: string; subject: string; to: string[]; date: string }> = []
1011
+ if (page.length > 0) {
1012
+ const uidRange = page.join(',')
1013
+ for await (const msg of client.fetch(uidRange, {
1014
+ uid: true,
1015
+ envelope: true,
1016
+ }, { uid: true })) {
1017
+ const env = msg.envelope
1018
+ if (!env) continue
1019
+ drafts.push({
1020
+ id: makeThreadId(draftsPath, msg.uid),
1021
+ subject: env.subject ?? '(no subject)',
1022
+ to: (env.to ?? []).map((a) => a.address ?? '').filter(Boolean),
1023
+ date: env.date?.toISOString() ?? new Date().toISOString(),
1024
+ })
1025
+ }
1026
+ }
1027
+
1028
+ return { drafts, nextPageToken }
1029
+ } finally {
1030
+ lock.release()
1031
+ }
1032
+ }) as Promise<{ drafts: Array<{ id: string; subject: string; to: string[]; date: string }>; nextPageToken: string | null } | AuthError | ApiError>
1033
+ }
1034
+
1035
+ async createDraft({
1036
+ to,
1037
+ subject,
1038
+ body,
1039
+ cc,
1040
+ bcc,
1041
+ threadId,
1042
+ fromEmail,
1043
+ }: {
1044
+ to: Array<{ name?: string; email: string }>
1045
+ subject: string
1046
+ body: string
1047
+ cc?: Array<{ name?: string; email: string }>
1048
+ bcc?: Array<{ name?: string; email: string }>
1049
+ threadId?: string
1050
+ fromEmail?: string
1051
+ attachments?: Array<{ filename: string; mimeType: string; content: Buffer }>
1052
+ }) {
1053
+ // Build MIME message and APPEND to Drafts folder
1054
+ const headers = [
1055
+ `From: ${fromEmail ?? this.account.email}`,
1056
+ `To: ${to.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')}`,
1057
+ `Subject: ${subject}`,
1058
+ `Date: ${new Date().toUTCString()}`,
1059
+ `MIME-Version: 1.0`,
1060
+ `Content-Type: text/plain; charset=utf-8`,
1061
+ ]
1062
+ if (cc && cc.length > 0) {
1063
+ headers.push(`Cc: ${cc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')}`)
1064
+ }
1065
+ if (bcc && bcc.length > 0) {
1066
+ headers.push(`Bcc: ${bcc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')}`)
1067
+ }
1068
+
1069
+ const raw = headers.join('\r\n') + '\r\n\r\n' + body
1070
+ const rawBuffer = Buffer.from(raw)
1071
+
1072
+ const result = await this.withImap(async (client) => {
1073
+ const draftsPath = await this.resolveMailboxPath(client, 'drafts')
1074
+ const appendResult = await client.append(draftsPath, rawBuffer, ['\\Draft', '\\Seen'])
1075
+ const uid = appendResult && typeof appendResult === 'object' && 'uid' in appendResult ? appendResult.uid : undefined
1076
+ return {
1077
+ id: uid ? makeThreadId(draftsPath, uid) : 'unknown',
1078
+ message: { id: 'unknown' },
1079
+ threadId: uid ? makeThreadId(draftsPath, uid) : 'unknown',
1080
+ }
1081
+ })
1082
+ if (result instanceof Error) throw result
1083
+ return result
1084
+ }
1085
+
1086
+ async getDraft({ draftId }: { draftId: string }) {
1087
+ // Reuse getThread to fetch the full message from Drafts folder
1088
+ const result = await this.getThread({ threadId: draftId })
1089
+ const msg = result.parsed.messages[0]!
1090
+ return {
1091
+ id: draftId,
1092
+ message: msg,
1093
+ to: msg.to,
1094
+ cc: msg.cc ?? [],
1095
+ bcc: msg.bcc,
1096
+ }
1097
+ }
1098
+
1099
+ async sendDraft({ draftId }: { draftId: string }) {
1100
+ // Fetch the draft message, send it via SMTP, then delete the draft
1101
+ const draft = await this.getDraft({ draftId })
1102
+
1103
+ const result = await this.sendMessage({
1104
+ to: draft.to,
1105
+ subject: draft.message.subject,
1106
+ body: draft.message.body,
1107
+ cc: draft.cc.length > 0 ? draft.cc : undefined,
1108
+ bcc: draft.bcc.length > 0 ? draft.bcc : undefined,
1109
+ })
1110
+ if (result instanceof Error) return result
1111
+
1112
+ // Delete the draft after sending
1113
+ await this.deleteDraft({ draftId })
1114
+
1115
+ return result
1116
+ }
1117
+
1118
+ async deleteDraft({ draftId }: { draftId: string }) {
1119
+ const { folder, uid } = parseThreadId(draftId)
1120
+ return this.withImap(async (client) => {
1121
+ const lock = await client.getMailboxLock(folder)
1122
+ try {
1123
+ await client.messageFlagsAdd(String(uid), ['\\Deleted'], { uid: true })
1124
+ await client.messageDelete(String(uid), { uid: true })
1125
+ } finally {
1126
+ lock.release()
1127
+ }
1128
+ })
1129
+ }
1130
+
1131
+ /**
1132
+ * Update an existing draft. IMAP has no native update — we delete the old
1133
+ * draft and APPEND a new message to the Drafts folder.
1134
+ */
1135
+ async updateDraft({
1136
+ draftId,
1137
+ to,
1138
+ subject,
1139
+ body,
1140
+ cc,
1141
+ bcc,
1142
+ fromEmail,
1143
+ }: {
1144
+ draftId: string
1145
+ to: Array<{ name?: string; email: string }>
1146
+ subject: string
1147
+ body: string
1148
+ cc?: Array<{ name?: string; email: string }>
1149
+ bcc?: Array<{ name?: string; email: string }>
1150
+ threadId?: string
1151
+ fromEmail?: string
1152
+ attachments?: Array<{ filename: string; mimeType: string; content: Buffer }>
1153
+ }) {
1154
+ // Delete old draft first — check for errors before creating replacement
1155
+ const deleted = await this.deleteDraft({ draftId })
1156
+ if (deleted instanceof Error) return deleted
1157
+
1158
+ // Create new draft with updated content
1159
+ return this.createDraft({ to, subject, body, cc, bcc, fromEmail })
1160
+ }
1161
+
1162
+ /**
1163
+ * Create a draft reply to a thread. Resolves reply-to, reply-all CCs,
1164
+ * and sets In-Reply-To/References headers, then appends to Drafts.
1165
+ */
1166
+ async createDraftReply({
1167
+ threadId,
1168
+ body,
1169
+ replyAll = false,
1170
+ cc,
1171
+ fromEmail,
1172
+ }: {
1173
+ threadId: string
1174
+ body: string
1175
+ replyAll?: boolean
1176
+ cc?: Array<{ email: string }>
1177
+ fromEmail?: string
1178
+ }): Promise<EmptyThreadError | AuthError | ApiError | { id: string; message: { id: string }; threadId: string }> {
1179
+ const thread = await this.getThread({ threadId })
1180
+ if (thread.parsed.messages.length === 0) {
1181
+ return new EmptyThreadError({ threadId })
1182
+ }
1183
+
1184
+ const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1]!
1185
+ const replyTo = lastMsg.replyTo ?? lastMsg.from.email
1186
+ const to = [{ email: replyTo }]
1187
+
1188
+ let resolvedCc: Array<{ email: string }> | undefined
1189
+ if (replyAll) {
1190
+ const myEmail = this.account.email.toLowerCase()
1191
+ const allRecipients = [
1192
+ ...lastMsg.to.map((r) => r.email),
1193
+ ...(lastMsg.cc?.map((r) => r.email) ?? []),
1194
+ ]
1195
+ .filter((e) => e.toLowerCase() !== myEmail)
1196
+ .filter((e) => e.toLowerCase() !== replyTo.toLowerCase())
1197
+
1198
+ if (allRecipients.length > 0) {
1199
+ resolvedCc = allRecipients.map((e) => ({ email: e }))
1200
+ }
1201
+ }
1202
+
1203
+ if (cc) {
1204
+ resolvedCc = [...(resolvedCc ?? []), ...cc]
1205
+ }
1206
+
1207
+ const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ')
1208
+ const subject = lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`
1209
+
1210
+ // Build MIME with reply headers
1211
+ const headers = [
1212
+ `From: ${fromEmail ?? this.account.email}`,
1213
+ `To: ${to.map((r) => r.email).join(', ')}`,
1214
+ `Subject: ${subject}`,
1215
+ `Date: ${new Date().toUTCString()}`,
1216
+ `MIME-Version: 1.0`,
1217
+ `Content-Type: text/plain; charset=utf-8`,
1218
+ ]
1219
+ if (resolvedCc && resolvedCc.length > 0) {
1220
+ headers.push(`Cc: ${resolvedCc.map((r) => r.email).join(', ')}`)
1221
+ }
1222
+ if (lastMsg.messageId) {
1223
+ headers.push(`In-Reply-To: ${lastMsg.messageId}`)
1224
+ }
1225
+ if (refs) {
1226
+ headers.push(`References: ${refs}`)
1227
+ }
1228
+
1229
+ const raw = headers.join('\r\n') + '\r\n\r\n' + body
1230
+ const rawBuffer = Buffer.from(raw)
1231
+
1232
+ const result = await this.withImap(async (client) => {
1233
+ const draftsPath = await this.resolveMailboxPath(client, 'drafts')
1234
+ const appendResult = await client.append(draftsPath, rawBuffer, ['\\Draft', '\\Seen'])
1235
+ const uid = appendResult && typeof appendResult === 'object' && 'uid' in appendResult ? appendResult.uid : undefined
1236
+ return {
1237
+ id: uid ? makeThreadId(draftsPath, uid) : 'unknown',
1238
+ message: { id: 'unknown' },
1239
+ threadId: uid ? makeThreadId(draftsPath, uid) : 'unknown',
1240
+ }
1241
+ })
1242
+ if (result instanceof Error) return result
1243
+ return result
1244
+ }
1245
+
1246
+ /**
1247
+ * Create a draft forwarding a thread. Builds the forwarded-message body
1248
+ * and appends to Drafts folder.
1249
+ */
1250
+ async createDraftForward({
1251
+ threadId,
1252
+ to,
1253
+ body,
1254
+ fromEmail,
1255
+ }: {
1256
+ threadId: string
1257
+ to: Array<{ email: string }>
1258
+ body?: string
1259
+ fromEmail?: string
1260
+ }): Promise<EmptyThreadError | AuthError | ApiError | { id: string; message: { id: string }; threadId: string }> {
1261
+ const thread = await this.getThread({ threadId })
1262
+ if (thread.parsed.messages.length === 0) {
1263
+ return new EmptyThreadError({ threadId })
1264
+ }
1265
+
1266
+ const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1]!
1267
+ const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType)
1268
+
1269
+ const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
1270
+ ? `${lastMsg.from.name} <${lastMsg.from.email}>`
1271
+ : lastMsg.from.email
1272
+
1273
+ const fullBody = [
1274
+ body ?? '',
1275
+ '',
1276
+ '---------- Forwarded message ----------',
1277
+ `From: ${fromStr}`,
1278
+ `Date: ${lastMsg.date}`,
1279
+ `Subject: ${lastMsg.subject}`,
1280
+ `To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
1281
+ '',
1282
+ renderedBody,
1283
+ ].join('\n')
1284
+
1285
+ return this.createDraft({
1286
+ to,
1287
+ subject: `Fwd: ${lastMsg.subject}`,
1288
+ body: fullBody,
1289
+ fromEmail,
1290
+ })
1291
+ }
1292
+
1293
+ // =========================================================================
1294
+ // Folder listing (IMAP equivalent of labels)
1295
+ // =========================================================================
1296
+
1297
+ async listFolders(): Promise<Array<{ name: string; path: string; specialUse?: string; flags: string[] }> | AuthError | ApiError> {
1298
+ return this.withImap(async (client) => {
1299
+ const mailboxes = await client.list()
1300
+ return mailboxes.map((m) => ({
1301
+ name: m.name,
1302
+ path: m.path,
1303
+ specialUse: m.specialUse ?? undefined,
1304
+ flags: Array.from(m.flags),
1305
+ }))
1306
+ }) as Promise<Array<{ name: string; path: string; specialUse?: string; flags: string[] }> | AuthError | ApiError>
1307
+ }
1308
+
1309
+ // =========================================================================
1310
+ // Cache stubs (no-op for IMAP — no local thread cache)
1311
+ // =========================================================================
1312
+
1313
+ async invalidateThreads(_threadIds: string[]): Promise<void> {}
1314
+ async invalidateThread(_threadId: string): Promise<void> {}
1315
+
1316
+ // =========================================================================
1317
+ // Private helpers
1318
+ // =========================================================================
1319
+
1320
+ /** Modify IMAP flags on messages. Groups by folder for efficiency. */
1321
+ private async modifyFlags(
1322
+ threadIds: string[],
1323
+ opts: { add?: string[]; remove?: string[] },
1324
+ ): Promise<void | AuthError | ApiError> {
1325
+ // Group by folder
1326
+ const byFolder = new Map<string, number[]>()
1327
+ for (const threadId of threadIds) {
1328
+ const { folder, uid } = parseThreadId(threadId)
1329
+ const uids = byFolder.get(folder) ?? []
1330
+ uids.push(uid)
1331
+ byFolder.set(folder, uids)
1332
+ }
1333
+
1334
+ for (const [folder, uids] of byFolder) {
1335
+ const result = await this.withImap(async (client) => {
1336
+ const lock = await client.getMailboxLock(folder)
1337
+ try {
1338
+ const uidRange = uids.join(',')
1339
+ if (opts.add && opts.add.length > 0) {
1340
+ await client.messageFlagsAdd(uidRange, opts.add, { uid: true })
1341
+ }
1342
+ if (opts.remove && opts.remove.length > 0) {
1343
+ await client.messageFlagsRemove(uidRange, opts.remove, { uid: true })
1344
+ }
1345
+ } finally {
1346
+ lock.release()
1347
+ }
1348
+ })
1349
+ if (result instanceof Error) return result
1350
+ }
1351
+ }
1352
+
1353
+ /** Check if a message has attachments from its bodyStructure. */
1354
+ private hasAttachments(msg: FetchMessageObject): boolean {
1355
+ const bs = msg.bodyStructure
1356
+ if (!bs) return false
1357
+ // Check for non-inline parts
1358
+ const check = (part: any): boolean => {
1359
+ if (part.disposition === 'attachment') return true
1360
+ if (part.childNodes) return part.childNodes.some(check)
1361
+ return false
1362
+ }
1363
+ return check(bs)
1364
+ }
1365
+
1366
+ /** Parse an imapflow FetchMessageObject into our ParsedMessage type. */
1367
+ private parseImapMessage(msg: FetchMessageObject, folder: string): ParsedMessage {
1368
+ const env = msg.envelope ?? {} as Partial<MessageEnvelopeObject>
1369
+ const flags = msg.flags ?? new Set()
1370
+ const threadId = makeThreadId(folder, msg.uid)
1371
+
1372
+ // Extract body from source if available
1373
+ let body = ''
1374
+ let mimeType = 'text/plain'
1375
+ let textBody: string | null = null
1376
+ let listUnsubscribe: string | undefined
1377
+ let listUnsubscribePost: string | undefined
1378
+
1379
+ if (msg.source) {
1380
+ const source = msg.source.toString('utf-8')
1381
+ const bodyResult = this.extractBodyFromSource(source)
1382
+ body = bodyResult.body
1383
+ mimeType = bodyResult.mimeType
1384
+ textBody = bodyResult.textBody
1385
+
1386
+ // Extract List-Unsubscribe / List-Unsubscribe-Post from raw MIME headers.
1387
+ // envelope-based fetches don't surface these, but getThread always fetches
1388
+ // source so it's available by the time we parse a full message.
1389
+ const headerEnd = source.indexOf('\r\n\r\n')
1390
+ const altEnd = source.indexOf('\n\n')
1391
+ const headerSplit = headerEnd !== -1 ? headerEnd : altEnd
1392
+ const headerText = headerSplit === -1 ? source : source.slice(0, headerSplit)
1393
+ listUnsubscribe = this.getHeader(headerText, 'list-unsubscribe')
1394
+ listUnsubscribePost = this.getHeader(headerText, 'list-unsubscribe-post')
1395
+ }
1396
+
1397
+ // Extract attachments from bodyStructure
1398
+ const attachments: AttachmentMeta[] = []
1399
+ if (msg.bodyStructure) {
1400
+ this.collectAttachments(msg.bodyStructure, '', attachments)
1401
+ }
1402
+
1403
+ return {
1404
+ id: threadId,
1405
+ threadId,
1406
+ subject: env.subject ?? '(no subject)',
1407
+ snippet: (env.subject ?? '').slice(0, 100),
1408
+ from: toSender(env.from?.[0]),
1409
+ to: toSenders(env.to),
1410
+ cc: env.cc ? toSenders(env.cc) : null,
1411
+ bcc: toSenders(env.bcc),
1412
+ replyTo: env.replyTo?.[0]?.address,
1413
+ date: env.date?.toISOString() ?? new Date().toISOString(),
1414
+ labelIds: [],
1415
+ unread: !flags.has('\\Seen'),
1416
+ starred: flags.has('\\Flagged'),
1417
+ isDraft: flags.has('\\Draft'),
1418
+ messageId: env.messageId ?? '',
1419
+ inReplyTo: env.inReplyTo,
1420
+ references: undefined,
1421
+ listUnsubscribe,
1422
+ listUnsubscribePost,
1423
+ body,
1424
+ mimeType,
1425
+ textBody,
1426
+ attachments,
1427
+ auth: null, // IMAP doesn't provide SPF/DKIM/DMARC
1428
+ }
1429
+ }
1430
+
1431
+ /** Extract body text from raw RFC 2822 source. */
1432
+ private extractBodyFromSource(source: string): { body: string; mimeType: string; textBody: string | null } {
1433
+ // Find the boundary between headers and body
1434
+ const headerEnd = source.indexOf('\r\n\r\n')
1435
+ if (headerEnd === -1) {
1436
+ const altEnd = source.indexOf('\n\n')
1437
+ if (altEnd === -1) return { body: '', mimeType: 'text/plain', textBody: null }
1438
+ return this.parseBody(source.slice(altEnd + 2), source.slice(0, altEnd))
1439
+ }
1440
+ return this.parseBody(source.slice(headerEnd + 4), source.slice(0, headerEnd))
1441
+ }
1442
+
1443
+ private parseBody(bodyContent: string, headers: string): { body: string; mimeType: string; textBody: string | null } {
1444
+ const contentType = this.getHeader(headers, 'content-type') ?? 'text/plain'
1445
+ const transferEncoding = this.getHeader(headers, 'content-transfer-encoding') ?? '7bit'
1446
+
1447
+ // Check if multipart
1448
+ const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/i)
1449
+ if (boundaryMatch) {
1450
+ const boundary = boundaryMatch[1]!
1451
+ return this.parseMultipart(bodyContent, boundary)
1452
+ }
1453
+
1454
+ // Single-part body
1455
+ let decoded = this.decodeTransferEncoding(bodyContent, transferEncoding)
1456
+ const charsetMatch = contentType.match(/charset="?([^";\s]+)"?/i)
1457
+ if (charsetMatch) {
1458
+ // Already UTF-8 string, but note the charset for future handling
1459
+ }
1460
+
1461
+ const isHtml = contentType.toLowerCase().includes('text/html')
1462
+ return {
1463
+ body: decoded,
1464
+ mimeType: isHtml ? 'text/html' : 'text/plain',
1465
+ textBody: isHtml ? null : decoded,
1466
+ }
1467
+ }
1468
+
1469
+ private parseMultipart(body: string, boundary: string): { body: string; mimeType: string; textBody: string | null } {
1470
+ const parts = body.split(`--${boundary}`)
1471
+ let htmlBody: string | null = null
1472
+ let textBody: string | null = null
1473
+
1474
+ for (const part of parts) {
1475
+ if (part.trim() === '--' || part.trim() === '') continue
1476
+
1477
+ const partHeaderEnd = part.indexOf('\r\n\r\n')
1478
+ const altEnd = part.indexOf('\n\n')
1479
+ const splitPos = partHeaderEnd !== -1 ? partHeaderEnd : altEnd
1480
+ if (splitPos === -1) continue
1481
+
1482
+ const partHeaders = part.slice(0, splitPos)
1483
+ const partBody = part.slice(splitPos + (partHeaderEnd !== -1 ? 4 : 2))
1484
+ const partContentType = this.getHeader(partHeaders, 'content-type') ?? 'text/plain'
1485
+ const partEncoding = this.getHeader(partHeaders, 'content-transfer-encoding') ?? '7bit'
1486
+
1487
+ // Recursive multipart
1488
+ const nestedBoundary = partContentType.match(/boundary="?([^";\s]+)"?/i)
1489
+ if (nestedBoundary) {
1490
+ const nested = this.parseMultipart(partBody, nestedBoundary[1]!)
1491
+ if (nested.mimeType === 'text/html') htmlBody = nested.body
1492
+ if (nested.textBody) textBody = nested.textBody
1493
+ continue
1494
+ }
1495
+
1496
+ const decoded = this.decodeTransferEncoding(partBody, partEncoding)
1497
+
1498
+ if (partContentType.toLowerCase().includes('text/html')) {
1499
+ htmlBody = decoded
1500
+ } else if (partContentType.toLowerCase().includes('text/plain')) {
1501
+ textBody = decoded
1502
+ }
1503
+ }
1504
+
1505
+ // Prefer HTML, fall back to text
1506
+ if (htmlBody) return { body: htmlBody, mimeType: 'text/html', textBody }
1507
+ if (textBody) return { body: textBody, mimeType: 'text/plain', textBody }
1508
+ return { body: '', mimeType: 'text/plain', textBody: null }
1509
+ }
1510
+
1511
+ private decodeTransferEncoding(content: string, encoding: string): string {
1512
+ const enc = encoding.toLowerCase().trim()
1513
+ if (enc === 'base64') {
1514
+ return Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8')
1515
+ }
1516
+ if (enc === 'quoted-printable') {
1517
+ return content
1518
+ .replace(/=\r?\n/g, '') // Soft line breaks
1519
+ .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
1520
+ }
1521
+ return content
1522
+ }
1523
+
1524
+ private getHeader(headers: string, name: string): string | undefined {
1525
+ const regex = new RegExp(`^${name}:\\s*(.+?)$`, 'im')
1526
+ const match = headers.match(regex)
1527
+ if (!match) return undefined
1528
+ // Handle folded headers (continuation lines starting with whitespace)
1529
+ let value = match[1]!.trim()
1530
+ const lines = headers.split(/\r?\n/)
1531
+ let found = false
1532
+ for (const line of lines) {
1533
+ if (found && /^\s/.test(line)) {
1534
+ value += ' ' + line.trim()
1535
+ } else if (line.toLowerCase().startsWith(name.toLowerCase() + ':')) {
1536
+ found = true
1537
+ } else if (found) {
1538
+ break
1539
+ }
1540
+ }
1541
+ return value
1542
+ }
1543
+
1544
+ /** Recursively collect attachment metadata from bodyStructure. */
1545
+ private collectAttachments(part: any, prefix: string, attachments: AttachmentMeta[]): void {
1546
+ if (part.disposition === 'attachment' || (part.disposition === 'inline' && part.parameters?.name)) {
1547
+ attachments.push({
1548
+ attachmentId: part.part ?? prefix,
1549
+ filename: part.dispositionParameters?.filename ?? part.parameters?.name ?? 'attachment',
1550
+ mimeType: part.type ?? 'application/octet-stream',
1551
+ size: part.size ?? 0,
1552
+ })
1553
+ }
1554
+ if (part.childNodes) {
1555
+ for (let i = 0; i < part.childNodes.length; i++) {
1556
+ this.collectAttachments(part.childNodes[i], prefix ? `${prefix}.${i + 1}` : String(i + 1), attachments)
1557
+ }
1558
+ }
1559
+ }
1560
+ }