@stacksjs/ts-cloud 0.1.8 → 0.1.9

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 (75) hide show
  1. package/dist/bin/cli.js +1 -1
  2. package/package.json +18 -16
  3. package/src/aws/acm.ts +768 -0
  4. package/src/aws/application-autoscaling.ts +845 -0
  5. package/src/aws/bedrock.ts +4074 -0
  6. package/src/aws/client.ts +891 -0
  7. package/src/aws/cloudformation.ts +896 -0
  8. package/src/aws/cloudfront.ts +1531 -0
  9. package/src/aws/cloudwatch-logs.ts +154 -0
  10. package/src/aws/comprehend.ts +839 -0
  11. package/src/aws/connect.ts +1056 -0
  12. package/src/aws/deploy-imap.ts +384 -0
  13. package/src/aws/dynamodb.ts +340 -0
  14. package/src/aws/ec2.ts +1385 -0
  15. package/src/aws/ecr.ts +621 -0
  16. package/src/aws/ecs.ts +615 -0
  17. package/src/aws/elasticache.ts +301 -0
  18. package/src/aws/elbv2.ts +942 -0
  19. package/src/aws/email.ts +928 -0
  20. package/src/aws/eventbridge.ts +248 -0
  21. package/src/aws/iam.ts +1689 -0
  22. package/src/aws/imap-server.ts +2100 -0
  23. package/src/aws/index.ts +213 -0
  24. package/src/aws/kendra.ts +1097 -0
  25. package/src/aws/lambda.ts +786 -0
  26. package/src/aws/opensearch.ts +158 -0
  27. package/src/aws/personalize.ts +977 -0
  28. package/src/aws/polly.ts +559 -0
  29. package/src/aws/rds.ts +888 -0
  30. package/src/aws/rekognition.ts +846 -0
  31. package/src/aws/route53-domains.ts +359 -0
  32. package/src/aws/route53.ts +1046 -0
  33. package/src/aws/s3.ts +2334 -0
  34. package/src/aws/scheduler.ts +571 -0
  35. package/src/aws/secrets-manager.ts +769 -0
  36. package/src/aws/ses.ts +1081 -0
  37. package/src/aws/setup-phone.ts +104 -0
  38. package/src/aws/setup-sms.ts +580 -0
  39. package/src/aws/sms.ts +1735 -0
  40. package/src/aws/smtp-server.ts +531 -0
  41. package/src/aws/sns.ts +758 -0
  42. package/src/aws/sqs.ts +382 -0
  43. package/src/aws/ssm.ts +807 -0
  44. package/src/aws/sts.ts +92 -0
  45. package/src/aws/support.ts +391 -0
  46. package/src/aws/test-imap.ts +86 -0
  47. package/src/aws/textract.ts +780 -0
  48. package/src/aws/transcribe.ts +108 -0
  49. package/src/aws/translate.ts +641 -0
  50. package/src/aws/voice.ts +1379 -0
  51. package/src/config.ts +35 -0
  52. package/src/deploy/index.ts +7 -0
  53. package/src/deploy/static-site-external-dns.ts +945 -0
  54. package/src/deploy/static-site.ts +1175 -0
  55. package/src/dns/cloudflare.ts +548 -0
  56. package/src/dns/godaddy.ts +412 -0
  57. package/src/dns/index.ts +205 -0
  58. package/src/dns/porkbun.ts +362 -0
  59. package/src/dns/route53-adapter.ts +414 -0
  60. package/src/dns/types.ts +119 -0
  61. package/src/dns/validator.ts +369 -0
  62. package/src/generators/index.ts +5 -0
  63. package/src/generators/infrastructure.ts +1660 -0
  64. package/src/index.ts +163 -0
  65. package/src/push/apns.ts +452 -0
  66. package/src/push/fcm.ts +506 -0
  67. package/src/push/index.ts +58 -0
  68. package/src/security/pre-deploy-scanner.ts +655 -0
  69. package/src/ssl/acme-client.ts +478 -0
  70. package/src/ssl/index.ts +7 -0
  71. package/src/ssl/letsencrypt.ts +747 -0
  72. package/src/types.ts +2 -0
  73. package/src/utils/cli.ts +398 -0
  74. package/src/validation/index.ts +5 -0
  75. package/src/validation/template.ts +405 -0
@@ -0,0 +1,2100 @@
1
+ /**
2
+ * IMAP-to-S3 Bridge Server
3
+ * Provides IMAP access to emails stored in S3 via SES receipt rules
4
+ *
5
+ * This allows email clients like Mail.app to access emails that are
6
+ * stored in S3 buckets through SES inbound email processing.
7
+ */
8
+
9
+ import * as net from 'node:net'
10
+ import * as tls from 'node:tls'
11
+ import * as fs from 'node:fs'
12
+ import * as crypto from 'node:crypto'
13
+ import { S3Client } from './s3'
14
+
15
+ export interface ImapServerConfig {
16
+ port?: number
17
+ sslPort?: number
18
+ host?: string
19
+ region?: string
20
+ bucket: string
21
+ prefix?: string
22
+ domain: string
23
+ users: Record<string, { password: string; email: string }>
24
+ tls?: {
25
+ key: string
26
+ cert: string
27
+ }
28
+ }
29
+
30
+ interface ImapSession {
31
+ id: string
32
+ socket: net.Socket
33
+ state: 'not_authenticated' | 'authenticated' | 'selected' | 'logout'
34
+ username?: string
35
+ email?: string
36
+ selectedMailbox?: string
37
+ tag?: string
38
+ idling?: boolean
39
+ idleTag?: string
40
+ }
41
+
42
+ interface EmailMessage {
43
+ uid: number
44
+ key: string
45
+ size: number
46
+ date: Date
47
+ flags: string[]
48
+ from?: string
49
+ to?: string
50
+ subject?: string
51
+ raw?: string
52
+ }
53
+
54
+ /**
55
+ * IMAP Server that reads emails from S3
56
+ */
57
+ export class ImapServer {
58
+ private config: ImapServerConfig
59
+ private s3: S3Client
60
+ private server?: net.Server
61
+ private tlsServer?: tls.Server
62
+ private sessions: Map<string, ImapSession> = new Map()
63
+ private messageCache: Map<string, Map<string, EmailMessage[]>> = new Map() // email -> folder -> messages
64
+ private flagsCache: Map<string, Record<string, string[]>> = new Map() // email -> {s3Key: flags[]}
65
+ private uidMappingCache: Map<string, Record<string, number>> = new Map() // email -> {s3Key: uid}
66
+ private nextUidCache: Map<string, number> = new Map() // email -> nextUid
67
+ private cacheTimestamp: Map<string, number> = new Map()
68
+ private uidCounter: Map<string, Map<string, number>> = new Map() // email -> folder -> uidCounter
69
+ private readonly CACHE_TTL_MS = 10000 // 10 seconds cache TTL
70
+
71
+ constructor(config: ImapServerConfig) {
72
+ this.config = {
73
+ port: 143,
74
+ sslPort: 993,
75
+ host: '0.0.0.0',
76
+ prefix: 'incoming/',
77
+ ...config,
78
+ }
79
+ this.s3 = new S3Client(config.region || 'us-east-1')
80
+ }
81
+
82
+ /**
83
+ * Start the IMAP server
84
+ */
85
+ async start(): Promise<void> {
86
+ // Start plain IMAP server
87
+ this.server = net.createServer((socket) => {
88
+ this.handleConnection(socket)
89
+ })
90
+
91
+ this.server.listen(this.config.port, this.config.host, () => {
92
+ console.log(`IMAP server listening on ${this.config.host}:${this.config.port}`)
93
+ })
94
+
95
+ // Start TLS IMAP server if certificates provided
96
+ if (this.config.tls?.key && this.config.tls?.cert) {
97
+ const tlsOptions: tls.TlsOptions = {
98
+ key: fs.readFileSync(this.config.tls.key),
99
+ cert: fs.readFileSync(this.config.tls.cert),
100
+ }
101
+
102
+ this.tlsServer = tls.createServer(tlsOptions, (socket) => {
103
+ this.handleConnection(socket)
104
+ })
105
+
106
+ this.tlsServer.listen(this.config.sslPort, this.config.host, () => {
107
+ console.log(`IMAPS server listening on ${this.config.host}:${this.config.sslPort}`)
108
+ })
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Stop the IMAP server
114
+ */
115
+ async stop(): Promise<void> {
116
+ if (this.server) {
117
+ this.server.close()
118
+ }
119
+ if (this.tlsServer) {
120
+ this.tlsServer.close()
121
+ }
122
+ for (const session of this.sessions.values()) {
123
+ session.socket.destroy()
124
+ }
125
+ this.sessions.clear()
126
+ }
127
+
128
+ /**
129
+ * Handle new connection
130
+ */
131
+ private handleConnection(socket: net.Socket): void {
132
+ const sessionId = crypto.randomUUID()
133
+ const session: ImapSession = {
134
+ id: sessionId,
135
+ socket,
136
+ state: 'not_authenticated',
137
+ }
138
+
139
+ this.sessions.set(sessionId, session)
140
+
141
+ // Send greeting
142
+ this.send(session, `* OK [CAPABILITY IMAP4rev1 STARTTLS AUTH=PLAIN] ${this.config.domain} IMAP4rev1 ready`)
143
+
144
+ let buffer = ''
145
+
146
+ socket.on('data', async (data) => {
147
+ buffer += data.toString()
148
+
149
+ // Process complete lines
150
+ let lineEnd: number
151
+ while ((lineEnd = buffer.indexOf('\r\n')) !== -1) {
152
+ const line = buffer.substring(0, lineEnd)
153
+ buffer = buffer.substring(lineEnd + 2)
154
+
155
+ try {
156
+ await this.processCommand(session, line)
157
+ }
158
+ catch (err) {
159
+ console.error(`Error processing command: ${err}`)
160
+ this.send(session, `* BAD Internal server error`)
161
+ }
162
+ }
163
+ })
164
+
165
+ socket.on('close', () => {
166
+ this.sessions.delete(sessionId)
167
+ })
168
+
169
+ socket.on('error', (err) => {
170
+ console.error(`Socket error: ${err}`)
171
+ this.sessions.delete(sessionId)
172
+ })
173
+ }
174
+
175
+ /**
176
+ * Send response to client
177
+ */
178
+ private send(session: ImapSession, message: string): void {
179
+ session.socket.write(`${message}\r\n`)
180
+ }
181
+
182
+ /**
183
+ * Process IMAP command
184
+ */
185
+ private async processCommand(session: ImapSession, line: string): Promise<void> {
186
+ // Log command for debugging
187
+ console.log(`IMAP CMD [${session.username || 'unauth'}]: ${line}`)
188
+
189
+ // Handle DONE command (ends IDLE state) - no tag required
190
+ if (line.toUpperCase() === 'DONE') {
191
+ if (session.idling && session.idleTag) {
192
+ session.idling = false
193
+ this.send(session, `${session.idleTag} OK IDLE terminated`)
194
+ session.idleTag = undefined
195
+ }
196
+ return
197
+ }
198
+
199
+ // Parse: TAG COMMAND [ARGS...]
200
+ const match = line.match(/^(\S+)\s+(\S+)(?:\s+(.*))?$/)
201
+ if (!match) {
202
+ this.send(session, '* BAD Invalid command')
203
+ return
204
+ }
205
+
206
+ const [, tag, command, args] = match
207
+ const cmd = command.toUpperCase()
208
+
209
+ switch (cmd) {
210
+ case 'CAPABILITY':
211
+ await this.handleCapability(session, tag)
212
+ break
213
+ case 'NOOP':
214
+ this.send(session, `${tag} OK NOOP completed`)
215
+ break
216
+ case 'LOGOUT':
217
+ await this.handleLogout(session, tag)
218
+ break
219
+ case 'LOGIN':
220
+ await this.handleLogin(session, tag, args || '')
221
+ break
222
+ case 'AUTHENTICATE':
223
+ await this.handleAuthenticate(session, tag, args || '')
224
+ break
225
+ case 'SELECT':
226
+ await this.handleSelect(session, tag, args || '')
227
+ break
228
+ case 'EXAMINE':
229
+ await this.handleExamine(session, tag, args || '')
230
+ break
231
+ case 'LIST':
232
+ await this.handleList(session, tag, args || '')
233
+ break
234
+ case 'LSUB':
235
+ await this.handleLsub(session, tag, args || '')
236
+ break
237
+ case 'STATUS':
238
+ await this.handleStatus(session, tag, args || '')
239
+ break
240
+ case 'FETCH':
241
+ await this.handleFetch(session, tag, args || '')
242
+ break
243
+ case 'UID':
244
+ await this.handleUid(session, tag, args || '')
245
+ break
246
+ case 'SEARCH':
247
+ await this.handleSearch(session, tag, args || '')
248
+ break
249
+ case 'CLOSE':
250
+ await this.handleClose(session, tag)
251
+ break
252
+ case 'EXPUNGE':
253
+ await this.handleExpunge(session, tag)
254
+ break
255
+ case 'STORE':
256
+ await this.handleStore(session, tag, args || '')
257
+ break
258
+ case 'COPY':
259
+ await this.handleCopy(session, tag, args || '')
260
+ break
261
+ case 'IDLE':
262
+ await this.handleIdle(session, tag)
263
+ break
264
+ case 'STARTTLS':
265
+ await this.handleStartTls(session, tag)
266
+ break
267
+ case 'NAMESPACE':
268
+ await this.handleNamespace(session, tag)
269
+ break
270
+ case 'XLIST':
271
+ // XLIST is deprecated Gmail extension but some clients use it
272
+ await this.handleXlist(session, tag, args || '')
273
+ break
274
+ case 'CREATE':
275
+ await this.handleCreate(session, tag, args || '')
276
+ break
277
+ case 'DELETE':
278
+ await this.handleDelete(session, tag, args || '')
279
+ break
280
+ case 'SUBSCRIBE':
281
+ await this.handleSubscribe(session, tag, args || '')
282
+ break
283
+ case 'UNSUBSCRIBE':
284
+ await this.handleUnsubscribe(session, tag, args || '')
285
+ break
286
+ case 'RENAME':
287
+ await this.handleRename(session, tag, args || '')
288
+ break
289
+ case 'APPEND':
290
+ await this.handleAppend(session, tag, args || '')
291
+ break
292
+ case 'CHECK':
293
+ await this.handleCheck(session, tag)
294
+ break
295
+ case 'MOVE':
296
+ await this.handleMove(session, tag, args || '')
297
+ break
298
+ default:
299
+ this.send(session, `${tag} BAD Unknown command ${cmd}`)
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Handle CAPABILITY command
305
+ */
306
+ private async handleCapability(session: ImapSession, tag: string): Promise<void> {
307
+ const capabilities = [
308
+ 'IMAP4rev1',
309
+ 'STARTTLS',
310
+ 'AUTH=PLAIN',
311
+ 'AUTH=LOGIN',
312
+ 'IDLE',
313
+ 'NAMESPACE',
314
+ 'UIDPLUS',
315
+ 'UNSELECT',
316
+ 'CHILDREN',
317
+ 'SPECIAL-USE',
318
+ 'MOVE',
319
+ ]
320
+ this.send(session, `* CAPABILITY ${capabilities.join(' ')}`)
321
+ this.send(session, `${tag} OK CAPABILITY completed`)
322
+ }
323
+
324
+ /**
325
+ * Handle LOGOUT command
326
+ */
327
+ private async handleLogout(session: ImapSession, tag: string): Promise<void> {
328
+ this.send(session, `* BYE ${this.config.domain} IMAP4rev1 server logging out`)
329
+ this.send(session, `${tag} OK LOGOUT completed`)
330
+ session.state = 'logout'
331
+ session.socket.end()
332
+ }
333
+
334
+ /**
335
+ * Handle LOGIN command
336
+ */
337
+ private async handleLogin(session: ImapSession, tag: string, args: string): Promise<void> {
338
+ // Parse LOGIN username password
339
+ const match = args.match(/^"?([^"\s]+)"?\s+"?([^"\s]+)"?$/)
340
+ if (!match) {
341
+ this.send(session, `${tag} BAD Invalid LOGIN syntax`)
342
+ return
343
+ }
344
+
345
+ const [, username, password] = match
346
+ const user = this.config.users[username]
347
+
348
+ if (!user || user.password !== password) {
349
+ this.send(session, `${tag} NO LOGIN failed`)
350
+ return
351
+ }
352
+
353
+ session.username = username
354
+ session.email = user.email
355
+ session.state = 'authenticated'
356
+ this.send(session, `${tag} OK LOGIN completed`)
357
+ }
358
+
359
+ /**
360
+ * Handle AUTHENTICATE command
361
+ */
362
+ private async handleAuthenticate(session: ImapSession, tag: string, args: string): Promise<void> {
363
+ // For simplicity, reject AUTHENTICATE and require LOGIN
364
+ this.send(session, `${tag} NO Use LOGIN command`)
365
+ }
366
+
367
+ /**
368
+ * Handle SELECT command
369
+ */
370
+ private async handleSelect(session: ImapSession, tag: string, args: string): Promise<void> {
371
+ if (session.state === 'not_authenticated') {
372
+ this.send(session, `${tag} NO Must authenticate first`)
373
+ return
374
+ }
375
+
376
+ // Parse mailbox name (might be quoted)
377
+ const mailbox = args.replace(/^"(.*)"$/, '$1').toUpperCase()
378
+
379
+ session.selectedMailbox = mailbox
380
+ session.state = 'selected'
381
+
382
+ // Load messages from S3 for this folder (force refresh on SELECT)
383
+ await this.loadMessages(session, true)
384
+ const messages = this.getMessagesForFolder(session.email || '', mailbox)
385
+
386
+ const exists = messages.length
387
+ const recent = messages.filter(m => m.flags.includes('\\Recent')).length
388
+ const unseenIdx = messages.findIndex(m => !m.flags.includes('\\Seen'))
389
+ const unseen = unseenIdx >= 0 ? unseenIdx + 1 : 0
390
+ const uidnext = this.getUidCounterForFolder(session.email || '', mailbox) + 1
391
+
392
+ const uidvalidity = this.getUidValidity(session.email || '')
393
+
394
+ this.send(session, `* ${exists} EXISTS`)
395
+ this.send(session, `* ${recent} RECENT`)
396
+ this.send(session, `* OK [UNSEEN ${unseen || 1}] First unseen message`)
397
+ this.send(session, `* OK [UIDVALIDITY ${uidvalidity}]`)
398
+ this.send(session, `* OK [UIDNEXT ${uidnext}]`)
399
+ this.send(session, `* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)`)
400
+ this.send(session, `* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)]`)
401
+ this.send(session, `${tag} OK [READ-WRITE] SELECT completed`)
402
+ }
403
+
404
+ /**
405
+ * Handle EXAMINE command (read-only SELECT)
406
+ */
407
+ private async handleExamine(session: ImapSession, tag: string, args: string): Promise<void> {
408
+ // Same as SELECT but read-only
409
+ await this.handleSelect(session, tag, args)
410
+ }
411
+
412
+ /**
413
+ * Handle NOOP command - check for new messages
414
+ */
415
+ private async handleNoop(session: ImapSession, tag: string): Promise<void> {
416
+ if (session.state === 'selected' && session.selectedMailbox === 'INBOX') {
417
+ // Get old message count
418
+ const oldMessages = this.getMessagesForFolder(session.email || '', session.selectedMailbox || 'INBOX')
419
+ const oldCount = oldMessages.length
420
+
421
+ // Force refresh to check for new messages
422
+ await this.loadMessages(session, true)
423
+ const newMessages = this.getMessagesForFolder(session.email || '', session.selectedMailbox || 'INBOX')
424
+ const newCount = newMessages.length
425
+
426
+ // Notify client of changes
427
+ if (newCount !== oldCount) {
428
+ this.send(session, `* ${newCount} EXISTS`)
429
+ const recent = newMessages.filter(m => m.flags.includes('\\Recent')).length
430
+ this.send(session, `* ${recent} RECENT`)
431
+ }
432
+ }
433
+ this.send(session, `${tag} OK NOOP completed`)
434
+ }
435
+
436
+ /**
437
+ * Handle CHECK command - request a checkpoint/sync
438
+ */
439
+ private async handleCheck(session: ImapSession, tag: string): Promise<void> {
440
+ // CHECK requests a checkpoint, we use it to refresh the cache
441
+ if (session.state === 'selected') {
442
+ await this.loadMessages(session, true)
443
+ const messages = this.getMessagesForFolder(session.email || '', session.selectedMailbox || 'INBOX')
444
+ this.send(session, `* ${messages.length} EXISTS`)
445
+ }
446
+ this.send(session, `${tag} OK CHECK completed`)
447
+ }
448
+
449
+ /**
450
+ * Handle LIST command
451
+ */
452
+ private async handleList(session: ImapSession, tag: string, args: string): Promise<void> {
453
+ if (session.state === 'not_authenticated') {
454
+ this.send(session, `${tag} NO Must authenticate first`)
455
+ return
456
+ }
457
+
458
+ // Parse reference and pattern
459
+ const match = args.match(/^"?([^"]*)"?\s+"?([^"]*)"?$/)
460
+ if (!match) {
461
+ this.send(session, `${tag} BAD Invalid LIST syntax`)
462
+ return
463
+ }
464
+
465
+ const [, reference, pattern] = match
466
+
467
+ // Return standard mailboxes with SPECIAL-USE attributes
468
+ // These attributes help email clients identify folder purposes
469
+ if (pattern === '*' || pattern === '%' || pattern === '') {
470
+ // INBOX - main inbox
471
+ this.send(session, `* LIST (\\HasNoChildren) "/" "INBOX"`)
472
+ // Sent - sent messages
473
+ this.send(session, `* LIST (\\HasNoChildren \\Sent) "/" "Sent"`)
474
+ // Drafts - draft messages
475
+ this.send(session, `* LIST (\\HasNoChildren \\Drafts) "/" "Drafts"`)
476
+ // Trash - deleted messages
477
+ this.send(session, `* LIST (\\HasNoChildren \\Trash) "/" "Trash"`)
478
+ // Junk/Spam - spam messages
479
+ this.send(session, `* LIST (\\HasNoChildren \\Junk) "/" "Junk"`)
480
+ // Archive - archived messages
481
+ this.send(session, `* LIST (\\HasNoChildren \\Archive) "/" "Archive"`)
482
+ // All Mail - all messages (Gmail-style)
483
+ this.send(session, `* LIST (\\HasNoChildren \\All) "/" "All Mail"`)
484
+ }
485
+ else if (pattern.toUpperCase() === 'INBOX') {
486
+ this.send(session, `* LIST (\\HasNoChildren) "/" "INBOX"`)
487
+ }
488
+ else {
489
+ // Check for specific folder patterns
490
+ const upperPattern = pattern.toUpperCase()
491
+ if (upperPattern === 'SENT' || upperPattern === '*SENT*') {
492
+ this.send(session, `* LIST (\\HasNoChildren \\Sent) "/" "Sent"`)
493
+ }
494
+ if (upperPattern === 'DRAFTS' || upperPattern === '*DRAFT*') {
495
+ this.send(session, `* LIST (\\HasNoChildren \\Drafts) "/" "Drafts"`)
496
+ }
497
+ if (upperPattern === 'TRASH' || upperPattern === '*TRASH*') {
498
+ this.send(session, `* LIST (\\HasNoChildren \\Trash) "/" "Trash"`)
499
+ }
500
+ if (upperPattern === 'JUNK' || upperPattern === 'SPAM' || upperPattern === '*JUNK*' || upperPattern === '*SPAM*') {
501
+ this.send(session, `* LIST (\\HasNoChildren \\Junk) "/" "Junk"`)
502
+ }
503
+ if (upperPattern === 'ARCHIVE' || upperPattern === '*ARCHIVE*') {
504
+ this.send(session, `* LIST (\\HasNoChildren \\Archive) "/" "Archive"`)
505
+ }
506
+ }
507
+
508
+ this.send(session, `${tag} OK LIST completed`)
509
+ }
510
+
511
+ /**
512
+ * Handle LSUB command (subscribed folders)
513
+ */
514
+ private async handleLsub(session: ImapSession, tag: string, args: string): Promise<void> {
515
+ if (session.state === 'not_authenticated') {
516
+ this.send(session, `${tag} NO Must authenticate first`)
517
+ return
518
+ }
519
+
520
+ // Parse reference and pattern
521
+ const match = args.match(/^"?([^"]*)"?\s+"?([^"]*)"?$/)
522
+ if (!match) {
523
+ this.send(session, `${tag} BAD Invalid LSUB syntax`)
524
+ return
525
+ }
526
+
527
+ const [, reference, pattern] = match
528
+
529
+ // Return all folders as subscribed
530
+ if (pattern === '*' || pattern === '%' || pattern === '') {
531
+ this.send(session, `* LSUB (\\HasNoChildren) "/" "INBOX"`)
532
+ this.send(session, `* LSUB (\\HasNoChildren \\Sent) "/" "Sent"`)
533
+ this.send(session, `* LSUB (\\HasNoChildren \\Drafts) "/" "Drafts"`)
534
+ this.send(session, `* LSUB (\\HasNoChildren \\Trash) "/" "Trash"`)
535
+ this.send(session, `* LSUB (\\HasNoChildren \\Junk) "/" "Junk"`)
536
+ this.send(session, `* LSUB (\\HasNoChildren \\Archive) "/" "Archive"`)
537
+ this.send(session, `* LSUB (\\HasNoChildren \\All) "/" "All Mail"`)
538
+ }
539
+ else if (pattern.toUpperCase() === 'INBOX') {
540
+ this.send(session, `* LSUB (\\HasNoChildren) "/" "INBOX"`)
541
+ }
542
+
543
+ this.send(session, `${tag} OK LSUB completed`)
544
+ }
545
+
546
+ /**
547
+ * Handle STATUS command
548
+ */
549
+ private async handleStatus(session: ImapSession, tag: string, args: string): Promise<void> {
550
+ if (session.state === 'not_authenticated') {
551
+ this.send(session, `${tag} NO Must authenticate first`)
552
+ return
553
+ }
554
+
555
+ const match = args.match(/^"?([^"]+)"?\s+\(([^)]+)\)$/i)
556
+ if (!match) {
557
+ this.send(session, `${tag} BAD Invalid STATUS syntax`)
558
+ return
559
+ }
560
+
561
+ const [, mailbox, itemsStr] = match
562
+
563
+ // Load messages for the requested mailbox
564
+ await this.loadMessagesForFolder(session, mailbox)
565
+ const messages = this.getMessagesForFolder(session.email || '', mailbox) || []
566
+ const items = itemsStr.toUpperCase().split(/\s+/)
567
+ const results: string[] = []
568
+
569
+ for (const item of items) {
570
+ switch (item) {
571
+ case 'MESSAGES':
572
+ results.push(`MESSAGES ${messages.length}`)
573
+ break
574
+ case 'RECENT':
575
+ results.push(`RECENT ${messages.filter(m => m.flags.includes('\\Recent')).length}`)
576
+ break
577
+ case 'UIDNEXT':
578
+ results.push(`UIDNEXT ${this.getUidCounterForFolder(session.email || '', mailbox) + 1}`)
579
+ break
580
+ case 'UIDVALIDITY':
581
+ results.push(`UIDVALIDITY ${this.getUidValidity(session.email || '')}`)
582
+ break
583
+ case 'UNSEEN':
584
+ results.push(`UNSEEN ${messages.filter(m => !m.flags.includes('\\Seen')).length}`)
585
+ break
586
+ }
587
+ }
588
+
589
+ this.send(session, `* STATUS "${mailbox}" (${results.join(' ')})`)
590
+ this.send(session, `${tag} OK STATUS completed`)
591
+ }
592
+
593
+ /**
594
+ * Handle FETCH command
595
+ */
596
+ private async handleFetch(session: ImapSession, tag: string, args: string): Promise<void> {
597
+ if (session.state !== 'selected') {
598
+ this.send(session, `${tag} NO Must select mailbox first`)
599
+ return
600
+ }
601
+
602
+ // Parse sequence set and items
603
+ const match = args.match(/^(\S+)\s+(.+)$/i)
604
+ if (!match) {
605
+ this.send(session, `${tag} BAD Invalid FETCH syntax`)
606
+ return
607
+ }
608
+
609
+ const [, sequenceSet, itemsStr] = match
610
+ const folder = session.selectedMailbox || 'INBOX'
611
+ const messages = this.getMessagesForFolder(session.email || '', folder)
612
+ const indices = this.parseSequenceSet(sequenceSet, messages.length)
613
+
614
+ for (const idx of indices) {
615
+ const msg = messages[idx - 1] // 1-indexed
616
+ if (!msg)
617
+ continue
618
+
619
+ const fetchData = await this.buildFetchResponse(session, msg, idx, itemsStr)
620
+ this.send(session, `* ${idx} FETCH ${fetchData}`)
621
+ }
622
+
623
+ this.send(session, `${tag} OK FETCH completed`)
624
+ }
625
+
626
+ /**
627
+ * Handle UID command (UID FETCH, UID SEARCH, etc.)
628
+ */
629
+ private async handleUid(session: ImapSession, tag: string, args: string): Promise<void> {
630
+ if (session.state !== 'selected') {
631
+ this.send(session, `${tag} NO Must select mailbox first`)
632
+ return
633
+ }
634
+
635
+ const match = args.match(/^(\S+)\s+(.+)$/i)
636
+ if (!match) {
637
+ this.send(session, `${tag} BAD Invalid UID syntax`)
638
+ return
639
+ }
640
+
641
+ const [, subCommand, subArgs] = match
642
+
643
+ switch (subCommand.toUpperCase()) {
644
+ case 'FETCH':
645
+ await this.handleUidFetch(session, tag, subArgs)
646
+ break
647
+ case 'SEARCH':
648
+ await this.handleUidSearch(session, tag, subArgs)
649
+ break
650
+ case 'STORE':
651
+ await this.handleUidStore(session, tag, subArgs)
652
+ break
653
+ case 'COPY':
654
+ await this.handleUidCopy(session, tag, subArgs)
655
+ break
656
+ case 'MOVE':
657
+ await this.handleUidMove(session, tag, subArgs)
658
+ break
659
+ case 'EXPUNGE':
660
+ await this.handleUidExpunge(session, tag, subArgs)
661
+ break
662
+ default:
663
+ this.send(session, `${tag} BAD Unknown UID command`)
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Handle UID FETCH
669
+ */
670
+ private async handleUidFetch(session: ImapSession, tag: string, args: string): Promise<void> {
671
+ const match = args.match(/^(\S+)\s+(.+)$/i)
672
+ if (!match) {
673
+ this.send(session, `${tag} BAD Invalid UID FETCH syntax`)
674
+ return
675
+ }
676
+
677
+ const [, uidSet, itemsStr] = match
678
+ const folder = session.selectedMailbox || 'INBOX'
679
+ const messages = this.getMessagesForFolder(session.email || '', folder)
680
+
681
+ // Parse UID set
682
+ const maxUid = messages.length > 0 ? Math.max(...messages.map(m => m.uid)) : 0
683
+ const uids = this.parseSequenceSet(uidSet, maxUid)
684
+
685
+ for (const uid of uids) {
686
+ const idx = messages.findIndex(m => m.uid === uid)
687
+ if (idx === -1)
688
+ continue
689
+
690
+ const msg = messages[idx]
691
+ const fetchData = await this.buildFetchResponse(session, msg, idx + 1, itemsStr, true)
692
+ this.send(session, `* ${idx + 1} FETCH ${fetchData}`)
693
+ }
694
+
695
+ this.send(session, `${tag} OK UID FETCH completed`)
696
+ }
697
+
698
+ /**
699
+ * Handle SEARCH command
700
+ */
701
+ private async handleSearch(session: ImapSession, tag: string, args: string): Promise<void> {
702
+ if (session.state !== 'selected') {
703
+ this.send(session, `${tag} NO Must select mailbox first`)
704
+ return
705
+ }
706
+
707
+ const folder = session.selectedMailbox || 'INBOX'
708
+ const messages = this.getMessagesForFolder(session.email || '', folder)
709
+
710
+ // Simple search - return all message sequence numbers
711
+ if (messages.length === 0) {
712
+ this.send(session, `* SEARCH`)
713
+ } else {
714
+ const results = messages.map((_, i) => i + 1)
715
+ this.send(session, `* SEARCH ${results.join(' ')}`)
716
+ }
717
+ this.send(session, `${tag} OK SEARCH completed`)
718
+ }
719
+
720
+ /**
721
+ * Handle UID SEARCH
722
+ */
723
+ private async handleUidSearch(session: ImapSession, tag: string, args: string): Promise<void> {
724
+ const folder = session.selectedMailbox || 'INBOX'
725
+ const messages = this.getMessagesForFolder(session.email || '', folder)
726
+
727
+ // Simple UID search - return all UIDs
728
+ if (messages.length === 0) {
729
+ this.send(session, `* SEARCH`)
730
+ } else {
731
+ const results = messages.map(m => m.uid)
732
+ this.send(session, `* SEARCH ${results.join(' ')}`)
733
+ }
734
+ this.send(session, `${tag} OK UID SEARCH completed`)
735
+ }
736
+
737
+ /**
738
+ * Handle STORE command
739
+ * STORE sequence-set flags-operation flags
740
+ * Example: STORE 1 +FLAGS (\Deleted)
741
+ */
742
+ private async handleStore(session: ImapSession, tag: string, args: string): Promise<void> {
743
+ if (session.state !== 'selected') {
744
+ this.send(session, `${tag} NO Must select mailbox first`)
745
+ return
746
+ }
747
+
748
+ // Parse: sequence-set flags-operation flags
749
+ // Example: 1 +FLAGS (\Deleted)
750
+ // Example: 1:* FLAGS.SILENT (\Seen)
751
+ const match = args.match(/^(\S+)\s+([+-]?FLAGS(?:\.SILENT)?)\s+\(([^)]*)\)/i)
752
+ if (!match) {
753
+ this.send(session, `${tag} BAD Invalid STORE syntax`)
754
+ return
755
+ }
756
+
757
+ const [, sequenceSet, operation, flagsStr] = match
758
+ const folder = session.selectedMailbox || 'INBOX'
759
+ const messages = this.getMessagesForFolder(session.email || '', folder)
760
+ const indices = this.parseSequenceSet(sequenceSet, messages.length)
761
+ const silent = operation.toUpperCase().includes('.SILENT')
762
+ const flags = flagsStr.split(/\s+/).filter(f => f)
763
+
764
+ // Load persisted flags
765
+ const persistedFlags = await this.loadFlags(session.email || '')
766
+
767
+ // For each message, update flags and send FETCH response (unless SILENT)
768
+ for (const idx of indices) {
769
+ const msg = messages[idx - 1]
770
+ if (!msg) continue
771
+
772
+ // Update flags based on operation
773
+ if (operation.startsWith('+')) {
774
+ // Add flags
775
+ for (const flag of flags) {
776
+ if (!msg.flags.includes(flag)) {
777
+ msg.flags.push(flag)
778
+ }
779
+ }
780
+ } else if (operation.startsWith('-')) {
781
+ // Remove flags
782
+ msg.flags = msg.flags.filter(f => !flags.includes(f))
783
+ } else {
784
+ // Replace flags
785
+ msg.flags = [...flags]
786
+ }
787
+
788
+ // Persist flags
789
+ persistedFlags[msg.key] = [...msg.flags]
790
+
791
+ // Send FETCH response unless SILENT
792
+ if (!silent) {
793
+ this.send(session, `* ${idx} FETCH (FLAGS (${msg.flags.join(' ')}))`)
794
+ }
795
+ }
796
+
797
+ // Save flags to S3
798
+ await this.saveFlags(session.email || '')
799
+
800
+ this.send(session, `${tag} OK STORE completed`)
801
+ }
802
+
803
+ /**
804
+ * Handle UID STORE
805
+ * UID STORE uid-set flags-operation flags
806
+ */
807
+ private async handleUidStore(session: ImapSession, tag: string, args: string): Promise<void> {
808
+ if (session.state !== 'selected') {
809
+ this.send(session, `${tag} NO Must select mailbox first`)
810
+ return
811
+ }
812
+
813
+ // Parse: uid-set flags-operation flags
814
+ const match = args.match(/^(\S+)\s+([+-]?FLAGS(?:\.SILENT)?)\s+\(([^)]*)\)/i)
815
+ if (!match) {
816
+ this.send(session, `${tag} BAD Invalid UID STORE syntax`)
817
+ return
818
+ }
819
+
820
+ const [, uidSet, operation, flagsStr] = match
821
+ const folder = session.selectedMailbox || 'INBOX'
822
+ const messages = this.getMessagesForFolder(session.email || '', folder)
823
+ const maxUid = messages.length > 0 ? Math.max(...messages.map(m => m.uid)) : 0
824
+ const uids = this.parseSequenceSet(uidSet, maxUid)
825
+ const silent = operation.toUpperCase().includes('.SILENT')
826
+ const flags = flagsStr.split(/\s+/).filter(f => f)
827
+
828
+ // Load persisted flags
829
+ const persistedFlags = await this.loadFlags(session.email || '')
830
+
831
+ // For each message, update flags and send FETCH response (unless SILENT)
832
+ for (const uid of uids) {
833
+ const idx = messages.findIndex(m => m.uid === uid)
834
+ if (idx === -1) continue
835
+
836
+ const msg = messages[idx]
837
+
838
+ // Update flags based on operation
839
+ if (operation.startsWith('+')) {
840
+ // Add flags
841
+ for (const flag of flags) {
842
+ if (!msg.flags.includes(flag)) {
843
+ msg.flags.push(flag)
844
+ }
845
+ }
846
+ } else if (operation.startsWith('-')) {
847
+ // Remove flags
848
+ msg.flags = msg.flags.filter(f => !flags.includes(f))
849
+ } else {
850
+ // Replace flags
851
+ msg.flags = [...flags]
852
+ }
853
+
854
+ // Persist flags
855
+ persistedFlags[msg.key] = [...msg.flags]
856
+
857
+ // Send FETCH response unless SILENT
858
+ if (!silent) {
859
+ this.send(session, `* ${idx + 1} FETCH (UID ${msg.uid} FLAGS (${msg.flags.join(' ')}))`)
860
+ }
861
+ }
862
+
863
+ // Save flags to S3
864
+ await this.saveFlags(session.email || '')
865
+
866
+ this.send(session, `${tag} OK UID STORE completed`)
867
+ }
868
+
869
+ /**
870
+ * Handle COPY command
871
+ * COPY sequence-set mailbox
872
+ */
873
+ private async handleCopy(session: ImapSession, tag: string, args: string): Promise<void> {
874
+ if (session.state !== 'selected') {
875
+ this.send(session, `${tag} NO Must select mailbox first`)
876
+ return
877
+ }
878
+
879
+ // Parse: sequence-set mailbox
880
+ const match = args.match(/^(\S+)\s+"?([^"]+)"?$/i)
881
+ if (!match) {
882
+ this.send(session, `${tag} BAD Invalid COPY syntax`)
883
+ return
884
+ }
885
+
886
+ const [, sequenceSet, destMailbox] = match
887
+ const sourceFolder = session.selectedMailbox || 'INBOX'
888
+ const messages = this.getMessagesForFolder(session.email || '', sourceFolder)
889
+ const indices = this.parseSequenceSet(sequenceSet, messages.length)
890
+
891
+ // Get destination folder S3 prefix
892
+ const destPrefix = this.getFolderPrefix(destMailbox)
893
+
894
+ // Copy messages to destination folder
895
+ for (const idx of indices) {
896
+ const msg = messages[idx - 1]
897
+ if (!msg) continue
898
+
899
+ try {
900
+ // Get the message content
901
+ const content = msg.raw || await this.s3.getObject(this.config.bucket, msg.key)
902
+
903
+ // Generate new key for destination folder
904
+ const filename = msg.key.split('/').pop() || `${Date.now()}`
905
+ const newKey = `${destPrefix}${filename}`
906
+
907
+ // Copy to destination
908
+ await this.s3.putObject({
909
+ bucket: this.config.bucket,
910
+ key: newKey,
911
+ body: content,
912
+ contentType: 'message/rfc822',
913
+ })
914
+
915
+ console.log(`Copied message from ${msg.key} to ${newKey}`)
916
+ } catch (err) {
917
+ console.error(`Failed to copy message: ${err}`)
918
+ }
919
+ }
920
+
921
+ this.send(session, `${tag} OK COPY completed`)
922
+ }
923
+
924
+ /**
925
+ * Handle UID COPY
926
+ * UID COPY uid-set mailbox
927
+ */
928
+ private async handleUidCopy(session: ImapSession, tag: string, args: string): Promise<void> {
929
+ if (session.state !== 'selected') {
930
+ this.send(session, `${tag} NO Must select mailbox first`)
931
+ return
932
+ }
933
+
934
+ // Parse: uid-set mailbox
935
+ const match = args.match(/^(\S+)\s+"?([^"]+)"?$/i)
936
+ if (!match) {
937
+ this.send(session, `${tag} BAD Invalid UID COPY syntax`)
938
+ return
939
+ }
940
+
941
+ const [, uidSet, destMailbox] = match
942
+ const sourceFolder = session.selectedMailbox || 'INBOX'
943
+ const messages = this.getMessagesForFolder(session.email || '', sourceFolder)
944
+ const maxUid = messages.length > 0 ? Math.max(...messages.map(m => m.uid)) : 0
945
+ const uids = this.parseSequenceSet(uidSet, maxUid)
946
+
947
+ // Get destination folder S3 prefix
948
+ const destPrefix = this.getFolderPrefix(destMailbox)
949
+
950
+ // Copy messages to destination folder
951
+ for (const uid of uids) {
952
+ const msg = messages.find(m => m.uid === uid)
953
+ if (!msg) continue
954
+
955
+ try {
956
+ // Get the message content
957
+ const content = msg.raw || await this.s3.getObject(this.config.bucket, msg.key)
958
+
959
+ // Generate new key for destination folder
960
+ const filename = msg.key.split('/').pop() || `${Date.now()}`
961
+ const newKey = `${destPrefix}${filename}`
962
+
963
+ // Copy to destination
964
+ await this.s3.putObject({
965
+ bucket: this.config.bucket,
966
+ key: newKey,
967
+ body: content,
968
+ contentType: 'message/rfc822',
969
+ })
970
+
971
+ console.log(`UID COPY: Copied message from ${msg.key} to ${newKey}`)
972
+ } catch (err) {
973
+ console.error(`Failed to copy message: ${err}`)
974
+ }
975
+ }
976
+
977
+ this.send(session, `${tag} OK UID COPY completed`)
978
+ }
979
+
980
+ /**
981
+ * Handle MOVE command (RFC 6851)
982
+ * MOVE sequence-set mailbox
983
+ */
984
+ private async handleMove(session: ImapSession, tag: string, args: string): Promise<void> {
985
+ if (session.state !== 'selected') {
986
+ this.send(session, `${tag} NO Must select mailbox first`)
987
+ return
988
+ }
989
+
990
+ // Parse sequence set and destination mailbox
991
+ const match = args.match(/^(\S+)\s+"?([^"]+)"?$/i)
992
+ if (!match) {
993
+ this.send(session, `${tag} BAD Invalid MOVE syntax`)
994
+ return
995
+ }
996
+
997
+ const [, sequenceSet, destMailbox] = match
998
+ const sourceFolder = session.selectedMailbox || 'INBOX'
999
+ const messages = this.getMessagesForFolder(session.email || '', sourceFolder)
1000
+ const indices = this.parseSequenceSet(sequenceSet, messages.length)
1001
+ const destPrefix = this.getFolderPrefix(destMailbox)
1002
+
1003
+ const movedIndices: number[] = []
1004
+
1005
+ // Copy messages to destination then delete from source
1006
+ for (const idx of indices) {
1007
+ const msg = messages[idx - 1]
1008
+ if (!msg) continue
1009
+
1010
+ try {
1011
+ const content = msg.raw || await this.s3.getObject(this.config.bucket, msg.key)
1012
+ const filename = msg.key.split('/').pop() || `${Date.now()}`
1013
+ const newKey = `${destPrefix}${filename}`
1014
+
1015
+ // Copy to destination
1016
+ await this.s3.putObject({
1017
+ bucket: this.config.bucket,
1018
+ key: newKey,
1019
+ body: content,
1020
+ contentType: 'message/rfc822',
1021
+ })
1022
+
1023
+ // Delete from source
1024
+ await this.s3.deleteObject(this.config.bucket, msg.key)
1025
+ console.log(`MOVE: ${msg.key} -> ${newKey}`)
1026
+ movedIndices.push(idx)
1027
+ } catch (err) {
1028
+ console.error(`Failed to move message: ${err}`)
1029
+ }
1030
+ }
1031
+
1032
+ // Send EXPUNGE responses for moved messages (in reverse order per RFC)
1033
+ const sortedIndices = [...movedIndices].sort((a, b) => b - a)
1034
+ for (const idx of sortedIndices) {
1035
+ this.send(session, `* ${idx} EXPUNGE`)
1036
+ }
1037
+
1038
+ // Update cache
1039
+ const remainingMessages = messages.filter((_, i) => !movedIndices.includes(i + 1))
1040
+ this.setMessagesForFolder(session.email || '', sourceFolder, remainingMessages)
1041
+
1042
+ // Update EXISTS count
1043
+ this.send(session, `* ${remainingMessages.length} EXISTS`)
1044
+
1045
+ this.send(session, `${tag} OK MOVE completed`)
1046
+ }
1047
+
1048
+ /**
1049
+ * Handle UID MOVE command (RFC 6851)
1050
+ * UID MOVE uid-set mailbox
1051
+ */
1052
+ private async handleUidMove(session: ImapSession, tag: string, args: string): Promise<void> {
1053
+ if (session.state !== 'selected') {
1054
+ this.send(session, `${tag} NO Must select mailbox first`)
1055
+ return
1056
+ }
1057
+
1058
+ // Parse UID set and destination mailbox
1059
+ const match = args.match(/^(\S+)\s+"?([^"]+)"?$/i)
1060
+ if (!match) {
1061
+ this.send(session, `${tag} BAD Invalid UID MOVE syntax`)
1062
+ return
1063
+ }
1064
+
1065
+ const [, uidSet, destMailbox] = match
1066
+ const sourceFolder = session.selectedMailbox || 'INBOX'
1067
+ const messages = this.getMessagesForFolder(session.email || '', sourceFolder)
1068
+ const maxUid = messages.length > 0 ? Math.max(...messages.map(m => m.uid)) : 0
1069
+ const uids = this.parseSequenceSet(uidSet, maxUid)
1070
+ const destPrefix = this.getFolderPrefix(destMailbox)
1071
+
1072
+ const movedIndices: number[] = []
1073
+
1074
+ // Copy messages to destination then delete from source
1075
+ for (const uid of uids) {
1076
+ const idx = messages.findIndex(m => m.uid === uid)
1077
+ if (idx === -1) continue
1078
+
1079
+ const msg = messages[idx]
1080
+
1081
+ try {
1082
+ const content = msg.raw || await this.s3.getObject(this.config.bucket, msg.key)
1083
+ const filename = msg.key.split('/').pop() || `${Date.now()}`
1084
+ const newKey = `${destPrefix}${filename}`
1085
+
1086
+ // Copy to destination
1087
+ await this.s3.putObject({
1088
+ bucket: this.config.bucket,
1089
+ key: newKey,
1090
+ body: content,
1091
+ contentType: 'message/rfc822',
1092
+ })
1093
+
1094
+ // Delete from source
1095
+ await this.s3.deleteObject(this.config.bucket, msg.key)
1096
+ console.log(`UID MOVE: ${msg.key} -> ${newKey}`)
1097
+ movedIndices.push(idx + 1)
1098
+ } catch (err) {
1099
+ console.error(`Failed to move message: ${err}`)
1100
+ }
1101
+ }
1102
+
1103
+ // Send EXPUNGE responses for moved messages (in reverse order per RFC)
1104
+ const sortedIndices = [...movedIndices].sort((a, b) => b - a)
1105
+ for (const idx of sortedIndices) {
1106
+ this.send(session, `* ${idx} EXPUNGE`)
1107
+ }
1108
+
1109
+ // Update cache
1110
+ const remainingMessages = messages.filter((_, i) => !movedIndices.includes(i + 1))
1111
+ this.setMessagesForFolder(session.email || '', sourceFolder, remainingMessages)
1112
+
1113
+ // Update EXISTS count
1114
+ this.send(session, `* ${remainingMessages.length} EXISTS`)
1115
+
1116
+ this.send(session, `${tag} OK UID MOVE completed`)
1117
+ }
1118
+
1119
+ /**
1120
+ * Handle UID EXPUNGE command (UIDPLUS extension)
1121
+ * UID EXPUNGE uid-set - expunges only specified UIDs
1122
+ */
1123
+ private async handleUidExpunge(session: ImapSession, tag: string, args: string): Promise<void> {
1124
+ if (session.state !== 'selected') {
1125
+ this.send(session, `${tag} NO Must select mailbox first`)
1126
+ return
1127
+ }
1128
+
1129
+ const folder = session.selectedMailbox || 'INBOX'
1130
+ const messages = this.getMessagesForFolder(session.email || '', folder)
1131
+
1132
+ // Parse UID set from args
1133
+ const uidSet = args.trim()
1134
+ const maxUid = messages.length > 0 ? Math.max(...messages.map(m => m.uid)) : 0
1135
+ const uids = this.parseSequenceSet(uidSet, maxUid)
1136
+
1137
+ const indicesToExpunge: number[] = []
1138
+ const keysToDelete: string[] = []
1139
+
1140
+ // Find messages with specified UIDs that are marked as \Deleted
1141
+ for (let i = messages.length - 1; i >= 0; i--) {
1142
+ const msg = messages[i]
1143
+ if (uids.includes(msg.uid) && msg.flags.includes('\\Deleted')) {
1144
+ indicesToExpunge.push(i + 1) // 1-indexed
1145
+ keysToDelete.push(msg.key)
1146
+ }
1147
+ }
1148
+
1149
+ // Delete from S3
1150
+ for (const key of keysToDelete) {
1151
+ try {
1152
+ await this.s3.deleteObject(this.config.bucket, key)
1153
+ console.log(`Deleted from S3: ${key}`)
1154
+ } catch (err) {
1155
+ console.error(`Failed to delete from S3: ${key}`, err)
1156
+ }
1157
+ }
1158
+
1159
+ // Send EXPUNGE responses (already in reverse order)
1160
+ for (const idx of indicesToExpunge) {
1161
+ this.send(session, `* ${idx} EXPUNGE`)
1162
+ }
1163
+
1164
+ // Remove deleted messages from cache
1165
+ const remainingMessages = messages.filter((_, i) => !indicesToExpunge.includes(i + 1))
1166
+ this.setMessagesForFolder(session.email || '', folder, remainingMessages)
1167
+
1168
+ // Send EXISTS if messages were removed
1169
+ if (indicesToExpunge.length > 0) {
1170
+ this.send(session, `* ${remainingMessages.length} EXISTS`)
1171
+ }
1172
+
1173
+ this.send(session, `${tag} OK UID EXPUNGE completed`)
1174
+ }
1175
+
1176
+ /**
1177
+ * Handle CLOSE command
1178
+ */
1179
+ private async handleClose(session: ImapSession, tag: string): Promise<void> {
1180
+ session.state = 'authenticated'
1181
+ session.selectedMailbox = undefined
1182
+ this.send(session, `${tag} OK CLOSE completed`)
1183
+ }
1184
+
1185
+ /**
1186
+ * Handle EXPUNGE command
1187
+ * Removes messages marked with \Deleted flag
1188
+ */
1189
+ private async handleExpunge(session: ImapSession, tag: string): Promise<void> {
1190
+ if (session.state !== 'selected') {
1191
+ this.send(session, `${tag} NO Must select mailbox first`)
1192
+ return
1193
+ }
1194
+
1195
+ const folder = session.selectedMailbox || 'INBOX'
1196
+ const messages = this.getMessagesForFolder(session.email || '', folder)
1197
+ const indicesToExpunge: number[] = []
1198
+ const keysToDelete: string[] = []
1199
+
1200
+ // Find messages marked with \Deleted (in reverse order for correct EXPUNGE responses)
1201
+ for (let i = messages.length - 1; i >= 0; i--) {
1202
+ if (messages[i].flags.includes('\\Deleted')) {
1203
+ indicesToExpunge.push(i + 1) // 1-indexed
1204
+ keysToDelete.push(messages[i].key)
1205
+ }
1206
+ }
1207
+
1208
+ // Delete from S3
1209
+ for (const key of keysToDelete) {
1210
+ try {
1211
+ await this.s3.deleteObject(this.config.bucket, key)
1212
+ console.log(`EXPUNGE: Deleted from S3: ${key}`)
1213
+ } catch (err) {
1214
+ console.error(`Failed to delete from S3: ${key}`, err)
1215
+ }
1216
+ }
1217
+
1218
+ // Send EXPUNGE responses (already in reverse order)
1219
+ for (const idx of indicesToExpunge) {
1220
+ this.send(session, `* ${idx} EXPUNGE`)
1221
+ }
1222
+
1223
+ // Remove deleted messages from cache
1224
+ const remainingMessages = messages.filter(m => !m.flags.includes('\\Deleted'))
1225
+ this.setMessagesForFolder(session.email || '', folder, remainingMessages)
1226
+
1227
+ // Send EXISTS if messages were removed
1228
+ if (indicesToExpunge.length > 0) {
1229
+ this.send(session, `* ${remainingMessages.length} EXISTS`)
1230
+ }
1231
+
1232
+ this.send(session, `${tag} OK EXPUNGE completed`)
1233
+ }
1234
+
1235
+ /**
1236
+ * Handle IDLE command
1237
+ */
1238
+ private async handleIdle(session: ImapSession, tag: string): Promise<void> {
1239
+ session.idling = true
1240
+ session.idleTag = tag
1241
+ this.send(session, `+ idling`)
1242
+ // Client will send DONE to exit IDLE state
1243
+ // When DONE is received, we'll send the OK response with the saved tag
1244
+ }
1245
+
1246
+ /**
1247
+ * Handle STARTTLS command
1248
+ */
1249
+ private async handleStartTls(session: ImapSession, tag: string): Promise<void> {
1250
+ if (!this.config.tls?.key || !this.config.tls?.cert) {
1251
+ this.send(session, `${tag} NO TLS not configured`)
1252
+ return
1253
+ }
1254
+
1255
+ this.send(session, `${tag} OK Begin TLS negotiation`)
1256
+
1257
+ // Upgrade connection to TLS
1258
+ const tlsOptions: tls.TLSSocketOptions = {
1259
+ key: fs.readFileSync(this.config.tls.key),
1260
+ cert: fs.readFileSync(this.config.tls.cert),
1261
+ isServer: true,
1262
+ }
1263
+
1264
+ const tlsSocket = new tls.TLSSocket(session.socket, tlsOptions)
1265
+ session.socket = tlsSocket as net.Socket
1266
+ }
1267
+
1268
+ /**
1269
+ * Handle NAMESPACE command
1270
+ */
1271
+ private async handleNamespace(session: ImapSession, tag: string): Promise<void> {
1272
+ this.send(session, `* NAMESPACE (("" "/")) NIL NIL`)
1273
+ this.send(session, `${tag} OK NAMESPACE completed`)
1274
+ }
1275
+
1276
+ /**
1277
+ * Load messages from S3 for the selected folder
1278
+ */
1279
+ private async loadMessages(session: ImapSession, forceRefresh = false): Promise<void> {
1280
+ const email = session.email
1281
+ const folder = session.selectedMailbox || 'INBOX'
1282
+ if (!email)
1283
+ return
1284
+
1285
+ // Check cache freshness
1286
+ const cacheKey = `${email}:${folder}`
1287
+ const lastUpdate = this.cacheTimestamp.get(cacheKey) || 0
1288
+ const now = Date.now()
1289
+ const cacheAge = now - lastUpdate
1290
+
1291
+ // Use cache if fresh and not forcing refresh
1292
+ if (!forceRefresh && cacheAge < this.CACHE_TTL_MS) {
1293
+ const cached = this.getMessagesForFolder(email, folder)
1294
+ if (cached.length > 0) {
1295
+ return
1296
+ }
1297
+ }
1298
+
1299
+ // Load persisted flags and UID mapping
1300
+ const persistedFlags = await this.loadFlags(email)
1301
+ await this.loadUidMapping(email)
1302
+
1303
+ try {
1304
+ // Handle "All Mail" virtual folder - aggregate from all folders
1305
+ if (this.isAllMailFolder(folder)) {
1306
+ await this.loadAllMailFolder(email, persistedFlags)
1307
+ return
1308
+ }
1309
+
1310
+ // Determine S3 prefix for this folder
1311
+ const prefix = this.getFolderPrefix(folder)
1312
+
1313
+ // List objects in S3
1314
+ const objects = await this.s3.list({
1315
+ bucket: this.config.bucket,
1316
+ prefix,
1317
+ maxKeys: 1000,
1318
+ })
1319
+
1320
+ const messages: EmailMessage[] = []
1321
+ let hasNewMessages = false
1322
+
1323
+ for (const obj of objects) {
1324
+ if (!obj.Key)
1325
+ continue
1326
+
1327
+ // Parse email content to get headers
1328
+ let raw = ''
1329
+ try {
1330
+ raw = await this.s3.getObject(this.config.bucket, obj.Key)
1331
+ }
1332
+ catch {
1333
+ continue
1334
+ }
1335
+
1336
+ // Parse basic headers
1337
+ const headers = this.parseHeaders(raw)
1338
+
1339
+ // For INBOX, check if this email is for this user
1340
+ // For other folders, all messages in the prefix belong to the user
1341
+ if (folder === 'INBOX') {
1342
+ const toHeader = headers.to || ''
1343
+ if (!toHeader.toLowerCase().includes(email.toLowerCase())) {
1344
+ continue
1345
+ }
1346
+ }
1347
+
1348
+ // Get or assign a persistent UID for this message
1349
+ const existingUid = this.uidMappingCache.get(email)?.[obj.Key]
1350
+ const uid = this.getOrAssignUid(email, obj.Key)
1351
+ if (!existingUid) {
1352
+ hasNewMessages = true
1353
+ }
1354
+
1355
+ // Get persisted flags or use empty array for new messages (no \Recent by default)
1356
+ // Mail.app will show unread messages based on absence of \Seen flag
1357
+ const flags = persistedFlags[obj.Key] || []
1358
+
1359
+ messages.push({
1360
+ uid,
1361
+ key: obj.Key,
1362
+ size: obj.Size || raw.length,
1363
+ date: new Date(obj.LastModified || Date.now()),
1364
+ flags: [...flags],
1365
+ from: headers.from,
1366
+ to: headers.to,
1367
+ subject: headers.subject,
1368
+ raw,
1369
+ })
1370
+ }
1371
+
1372
+ // Sort messages by UID (ascending) for correct sequence numbers
1373
+ messages.sort((a, b) => a.uid - b.uid)
1374
+
1375
+ // Save UID mapping if new messages were added
1376
+ if (hasNewMessages) {
1377
+ await this.saveUidMapping(email)
1378
+ }
1379
+
1380
+ // Update UID counter for folder to max UID
1381
+ const maxUid = messages.length > 0 ? Math.max(...messages.map(m => m.uid)) : 0
1382
+ this.setUidCounterForFolder(email, folder, maxUid)
1383
+ this.setMessagesForFolder(email, folder, messages)
1384
+ this.cacheTimestamp.set(cacheKey, Date.now())
1385
+ console.log(`Loaded ${messages.length} messages for ${email} in ${folder} from S3 (hasNewMessages=${hasNewMessages})`)
1386
+ }
1387
+ catch (err) {
1388
+ console.error(`Error loading messages from S3: ${err}`)
1389
+ }
1390
+ }
1391
+
1392
+ /**
1393
+ * Load all messages from all folders for the "All Mail" virtual folder
1394
+ * Uses persistent UIDs for stable message identification across sessions
1395
+ */
1396
+ private async loadAllMailFolder(email: string, persistedFlags: Record<string, string[]>): Promise<void> {
1397
+ const folder = 'ALL MAIL'
1398
+ const cacheKey = `${email}:${folder}`
1399
+
1400
+ // Load UID mapping for persistent UIDs
1401
+ await this.loadUidMapping(email)
1402
+
1403
+ // All folder prefixes to aggregate
1404
+ const allPrefixes = [
1405
+ this.config.prefix || 'incoming/', // INBOX
1406
+ 'sent/',
1407
+ 'trash/',
1408
+ 'drafts/',
1409
+ 'junk/',
1410
+ 'archive/',
1411
+ ]
1412
+
1413
+ const allMessages: EmailMessage[] = []
1414
+ const seenKeys = new Set<string>() // Avoid duplicates
1415
+ let hasNewMessages = false
1416
+
1417
+ for (const prefix of allPrefixes) {
1418
+ try {
1419
+ const objects = await this.s3.list({
1420
+ bucket: this.config.bucket,
1421
+ prefix,
1422
+ maxKeys: 1000,
1423
+ })
1424
+
1425
+ for (const obj of objects) {
1426
+ if (!obj.Key || seenKeys.has(obj.Key))
1427
+ continue
1428
+
1429
+ seenKeys.add(obj.Key)
1430
+
1431
+ let raw = ''
1432
+ try {
1433
+ raw = await this.s3.getObject(this.config.bucket, obj.Key)
1434
+ }
1435
+ catch {
1436
+ continue
1437
+ }
1438
+
1439
+ const headers = this.parseHeaders(raw)
1440
+
1441
+ // For incoming (INBOX) folder, filter by recipient
1442
+ if (prefix === (this.config.prefix || 'incoming/')) {
1443
+ const toHeader = headers.to || ''
1444
+ if (!toHeader.toLowerCase().includes(email.toLowerCase())) {
1445
+ continue
1446
+ }
1447
+ }
1448
+
1449
+ // Get or assign a persistent UID for this message
1450
+ const existingUid = this.uidMappingCache.get(email)?.[obj.Key]
1451
+ const uid = this.getOrAssignUid(email, obj.Key)
1452
+ if (!existingUid) {
1453
+ hasNewMessages = true
1454
+ }
1455
+
1456
+ // Get persisted flags or use empty array for new messages
1457
+ const flags = persistedFlags[obj.Key] || []
1458
+
1459
+ allMessages.push({
1460
+ uid,
1461
+ key: obj.Key,
1462
+ size: obj.Size || raw.length,
1463
+ date: new Date(obj.LastModified || Date.now()),
1464
+ flags: [...flags],
1465
+ from: headers.from,
1466
+ to: headers.to,
1467
+ subject: headers.subject,
1468
+ raw,
1469
+ })
1470
+ }
1471
+ }
1472
+ catch (err) {
1473
+ console.error(`Error loading messages from prefix ${prefix}: ${err}`)
1474
+ }
1475
+ }
1476
+
1477
+ // Sort messages by UID (ascending) for correct sequence numbers
1478
+ allMessages.sort((a, b) => a.uid - b.uid)
1479
+
1480
+ // Save UID mapping if new messages were added
1481
+ if (hasNewMessages) {
1482
+ await this.saveUidMapping(email)
1483
+ }
1484
+
1485
+ // Update UID counter for folder to max UID
1486
+ const maxUid = allMessages.length > 0 ? Math.max(...allMessages.map(m => m.uid)) : 0
1487
+ this.setUidCounterForFolder(email, folder, maxUid)
1488
+ this.setMessagesForFolder(email, folder, allMessages)
1489
+ this.cacheTimestamp.set(cacheKey, Date.now())
1490
+ console.log(`Loaded ${allMessages.length} total messages for ${email} in All Mail from S3 (hasNewMessages=${hasNewMessages})`)
1491
+ }
1492
+
1493
+ /**
1494
+ * Load messages from S3 for a specific folder (used by STATUS command)
1495
+ * Uses persistent UIDs for stable message identification across sessions
1496
+ */
1497
+ private async loadMessagesForFolder(session: ImapSession, folder: string): Promise<void> {
1498
+ const email = session.email
1499
+ if (!email)
1500
+ return
1501
+
1502
+ const cacheKey = `${email}:${folder}`
1503
+ const lastUpdate = this.cacheTimestamp.get(cacheKey) || 0
1504
+ const now = Date.now()
1505
+ const cacheAge = now - lastUpdate
1506
+
1507
+ // Use cached data if fresh
1508
+ if (cacheAge < this.CACHE_TTL_MS) {
1509
+ const cached = this.getMessagesForFolder(email, folder)
1510
+ if (cached.length > 0) {
1511
+ return
1512
+ }
1513
+ }
1514
+
1515
+ const persistedFlags = await this.loadFlags(email)
1516
+ await this.loadUidMapping(email)
1517
+
1518
+ // Handle "All Mail" virtual folder - aggregate from all folders
1519
+ if (this.isAllMailFolder(folder)) {
1520
+ await this.loadAllMailFolder(email, persistedFlags)
1521
+ return
1522
+ }
1523
+
1524
+ try {
1525
+ const prefix = this.getFolderPrefix(folder)
1526
+
1527
+ const objects = await this.s3.list({
1528
+ bucket: this.config.bucket,
1529
+ prefix,
1530
+ maxKeys: 1000,
1531
+ })
1532
+
1533
+ const messages: EmailMessage[] = []
1534
+ let hasNewMessages = false
1535
+
1536
+ for (const obj of objects) {
1537
+ if (!obj.Key)
1538
+ continue
1539
+
1540
+ let raw = ''
1541
+ try {
1542
+ raw = await this.s3.getObject(this.config.bucket, obj.Key)
1543
+ }
1544
+ catch {
1545
+ continue
1546
+ }
1547
+
1548
+ const headers = this.parseHeaders(raw)
1549
+
1550
+ // For INBOX, filter by recipient
1551
+ if (folder.toUpperCase() === 'INBOX') {
1552
+ const toHeader = headers.to || ''
1553
+ if (!toHeader.toLowerCase().includes(email.toLowerCase())) {
1554
+ continue
1555
+ }
1556
+ }
1557
+
1558
+ // Get or assign a persistent UID for this message
1559
+ const existingUid = this.uidMappingCache.get(email)?.[obj.Key]
1560
+ const uid = this.getOrAssignUid(email, obj.Key)
1561
+ if (!existingUid) {
1562
+ hasNewMessages = true
1563
+ }
1564
+
1565
+ // Get persisted flags or use empty array for new messages
1566
+ const flags = persistedFlags[obj.Key] || []
1567
+
1568
+ messages.push({
1569
+ uid,
1570
+ key: obj.Key,
1571
+ size: obj.Size || raw.length,
1572
+ date: new Date(obj.LastModified || Date.now()),
1573
+ flags: [...flags],
1574
+ from: headers.from,
1575
+ to: headers.to,
1576
+ subject: headers.subject,
1577
+ raw,
1578
+ })
1579
+ }
1580
+
1581
+ // Sort messages by UID (ascending) for correct sequence numbers
1582
+ messages.sort((a, b) => a.uid - b.uid)
1583
+
1584
+ // Save UID mapping if new messages were added
1585
+ if (hasNewMessages) {
1586
+ await this.saveUidMapping(email)
1587
+ }
1588
+
1589
+ // Update UID counter for folder to max UID
1590
+ const maxUid = messages.length > 0 ? Math.max(...messages.map(m => m.uid)) : 0
1591
+ this.setUidCounterForFolder(email, folder, maxUid)
1592
+ this.setMessagesForFolder(email, folder, messages)
1593
+ this.cacheTimestamp.set(cacheKey, Date.now())
1594
+ }
1595
+ catch (err) {
1596
+ console.error(`Error loading messages for folder ${folder}: ${err}`)
1597
+ // Set empty array so we don't return undefined
1598
+ this.setMessagesForFolder(email, folder, [])
1599
+ }
1600
+ }
1601
+
1602
+ /**
1603
+ * Parse email headers
1604
+ */
1605
+ private parseHeaders(raw: string): Record<string, string> {
1606
+ const headers: Record<string, string> = {}
1607
+ const headerSection = raw.split('\r\n\r\n')[0] || raw.split('\n\n')[0] || ''
1608
+
1609
+ const lines = headerSection.split(/\r?\n/)
1610
+ let currentHeader = ''
1611
+ let currentValue = ''
1612
+
1613
+ for (const line of lines) {
1614
+ if (line.startsWith(' ') || line.startsWith('\t')) {
1615
+ // Continuation
1616
+ currentValue += ` ${line.trim()}`
1617
+ }
1618
+ else {
1619
+ // Save previous header
1620
+ if (currentHeader) {
1621
+ headers[currentHeader.toLowerCase()] = currentValue
1622
+ }
1623
+
1624
+ // Parse new header
1625
+ const colonIdx = line.indexOf(':')
1626
+ if (colonIdx > 0) {
1627
+ currentHeader = line.substring(0, colonIdx).trim()
1628
+ currentValue = line.substring(colonIdx + 1).trim()
1629
+ }
1630
+ }
1631
+ }
1632
+
1633
+ // Save last header
1634
+ if (currentHeader) {
1635
+ headers[currentHeader.toLowerCase()] = currentValue
1636
+ }
1637
+
1638
+ return headers
1639
+ }
1640
+
1641
+ /**
1642
+ * Build FETCH response
1643
+ */
1644
+ private async buildFetchResponse(
1645
+ session: ImapSession,
1646
+ msg: EmailMessage,
1647
+ seqNum: number,
1648
+ itemsStr: string,
1649
+ includeUid = false,
1650
+ ): Promise<string> {
1651
+ const items = itemsStr.toUpperCase()
1652
+ const results: string[] = []
1653
+
1654
+ if (includeUid || items.includes('UID')) {
1655
+ results.push(`UID ${msg.uid}`)
1656
+ }
1657
+
1658
+ if (items.includes('FLAGS')) {
1659
+ results.push(`FLAGS (${msg.flags.join(' ')})`)
1660
+ }
1661
+
1662
+ if (items.includes('RFC822.SIZE')) {
1663
+ results.push(`RFC822.SIZE ${msg.size}`)
1664
+ }
1665
+
1666
+ if (items.includes('INTERNALDATE')) {
1667
+ results.push(`INTERNALDATE "${this.formatImapDate(msg.date)}"`)
1668
+ }
1669
+
1670
+ if (items.includes('ENVELOPE')) {
1671
+ const envelope = this.buildEnvelope(msg)
1672
+ results.push(`ENVELOPE ${envelope}`)
1673
+ }
1674
+
1675
+ if (items.includes('BODYSTRUCTURE') || items.includes('BODY.PEEK[STRUCTURE]')) {
1676
+ results.push(`BODYSTRUCTURE ("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "7BIT" ${msg.size} ${Math.ceil(msg.size / 80)} NIL NIL NIL)`)
1677
+ }
1678
+
1679
+ if (items.includes('BODY[]') || items.includes('BODY.PEEK[]') || items.includes('RFC822')) {
1680
+ const raw = msg.raw || await this.s3.getObject(this.config.bucket, msg.key)
1681
+ results.push(`BODY[] {${raw.length}}\r\n${raw}`)
1682
+ }
1683
+
1684
+ if (items.includes('BODY[HEADER]') || items.includes('BODY.PEEK[HEADER]')) {
1685
+ const raw = msg.raw || await this.s3.getObject(this.config.bucket, msg.key)
1686
+ const header = raw.split('\r\n\r\n')[0] || raw.split('\n\n')[0] || ''
1687
+ results.push(`BODY[HEADER] {${header.length + 2}}\r\n${header}\r\n`)
1688
+ }
1689
+
1690
+ if (items.includes('BODY[TEXT]') || items.includes('BODY.PEEK[TEXT]')) {
1691
+ const raw = msg.raw || await this.s3.getObject(this.config.bucket, msg.key)
1692
+ const parts = raw.split('\r\n\r\n')
1693
+ const text = parts.slice(1).join('\r\n\r\n') || (raw.split('\n\n').slice(1).join('\n\n'))
1694
+ results.push(`BODY[TEXT] {${text.length}}\r\n${text}`)
1695
+ }
1696
+
1697
+ return `(${results.join(' ')})`
1698
+ }
1699
+
1700
+ /**
1701
+ * Build ENVELOPE response
1702
+ */
1703
+ private buildEnvelope(msg: EmailMessage): string {
1704
+ const quote = (s?: string) => s ? `"${s.replace(/"/g, '\\"')}"` : 'NIL'
1705
+
1706
+ const date = quote(msg.date.toUTCString())
1707
+ const subject = quote(msg.subject)
1708
+ const from = msg.from ? `((NIL NIL "${msg.from.split('@')[0]}" "${msg.from.split('@')[1] || ''}"))` : 'NIL'
1709
+ const to = msg.to ? `((NIL NIL "${msg.to.split('@')[0]}" "${msg.to.split('@')[1] || ''}"))` : 'NIL'
1710
+
1711
+ return `(${date} ${subject} ${from} ${from} ${from} ${to} NIL NIL NIL NIL)`
1712
+ }
1713
+
1714
+ /**
1715
+ * Format date for IMAP
1716
+ */
1717
+ private formatImapDate(date: Date): string {
1718
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
1719
+ const d = date.getDate().toString().padStart(2, '0')
1720
+ const m = months[date.getMonth()]
1721
+ const y = date.getFullYear()
1722
+ const h = date.getHours().toString().padStart(2, '0')
1723
+ const min = date.getMinutes().toString().padStart(2, '0')
1724
+ const s = date.getSeconds().toString().padStart(2, '0')
1725
+ const tz = '+0000'
1726
+ return `${d}-${m}-${y} ${h}:${min}:${s} ${tz}`
1727
+ }
1728
+
1729
+ /**
1730
+ * Parse IMAP sequence set (e.g., "1:*", "1,3,5", "1:10")
1731
+ */
1732
+ private parseSequenceSet(set: string, max: number): number[] {
1733
+ const results: number[] = []
1734
+
1735
+ for (const part of set.split(',')) {
1736
+ if (part.includes(':')) {
1737
+ const [start, end] = part.split(':')
1738
+ const s = start === '*' ? max : Number.parseInt(start, 10)
1739
+ const e = end === '*' ? max : Number.parseInt(end, 10)
1740
+ for (let i = Math.min(s, e); i <= Math.max(s, e); i++) {
1741
+ if (i > 0 && i <= max) {
1742
+ results.push(i)
1743
+ }
1744
+ }
1745
+ }
1746
+ else {
1747
+ const n = part === '*' ? max : Number.parseInt(part, 10)
1748
+ if (n > 0 && n <= max) {
1749
+ results.push(n)
1750
+ }
1751
+ }
1752
+ }
1753
+
1754
+ return [...new Set(results)].sort((a, b) => a - b)
1755
+ }
1756
+
1757
+ /**
1758
+ * Get UID validity value for a mailbox
1759
+ */
1760
+ private getUidValidity(email: string): number {
1761
+ // Use a hash of the email to generate a stable UID validity
1762
+ let hash = 0
1763
+ for (let i = 0; i < email.length; i++) {
1764
+ hash = ((hash << 5) - hash) + email.charCodeAt(i)
1765
+ hash = hash & hash // Convert to 32-bit integer
1766
+ }
1767
+ return Math.abs(hash) || 1
1768
+ }
1769
+
1770
+ /**
1771
+ * Get the S3 prefix for a folder
1772
+ */
1773
+ private getFolderPrefix(folder: string): string {
1774
+ const normalizedFolder = folder.toUpperCase()
1775
+ switch (normalizedFolder) {
1776
+ case 'INBOX':
1777
+ return this.config.prefix || 'incoming/'
1778
+ case 'TRASH':
1779
+ return 'trash/'
1780
+ case 'SENT':
1781
+ return 'sent/'
1782
+ case 'DRAFTS':
1783
+ return 'drafts/'
1784
+ case 'JUNK':
1785
+ return 'junk/'
1786
+ case 'ARCHIVE':
1787
+ return 'archive/'
1788
+ case 'ALL MAIL':
1789
+ return '' // All Mail is a virtual folder - handled specially
1790
+ default:
1791
+ return `folders/${folder.toLowerCase()}/`
1792
+ }
1793
+ }
1794
+
1795
+ /**
1796
+ * Check if a folder is the virtual "All Mail" folder
1797
+ */
1798
+ private isAllMailFolder(folder: string): boolean {
1799
+ return folder.toUpperCase() === 'ALL MAIL'
1800
+ }
1801
+
1802
+ /**
1803
+ * Load flags from S3
1804
+ */
1805
+ private async loadFlags(email: string): Promise<Record<string, string[]>> {
1806
+ const cached = this.flagsCache.get(email)
1807
+ if (cached) return cached
1808
+
1809
+ try {
1810
+ const flagsKey = `flags/${email.replace('@', '_at_')}.json`
1811
+ const content = await this.s3.getObject(this.config.bucket, flagsKey)
1812
+ const flags = JSON.parse(content)
1813
+ this.flagsCache.set(email, flags)
1814
+ return flags
1815
+ } catch {
1816
+ // No flags file yet
1817
+ const empty: Record<string, string[]> = {}
1818
+ this.flagsCache.set(email, empty)
1819
+ return empty
1820
+ }
1821
+ }
1822
+
1823
+ /**
1824
+ * Save flags to S3
1825
+ */
1826
+ private async saveFlags(email: string): Promise<void> {
1827
+ const flags = this.flagsCache.get(email)
1828
+ if (!flags) return
1829
+
1830
+ try {
1831
+ const flagsKey = `flags/${email.replace('@', '_at_')}.json`
1832
+ await this.s3.putObject({
1833
+ bucket: this.config.bucket,
1834
+ key: flagsKey,
1835
+ body: JSON.stringify(flags),
1836
+ contentType: 'application/json',
1837
+ })
1838
+ console.log(`Saved flags for ${email}`)
1839
+ } catch (err) {
1840
+ console.error(`Failed to save flags for ${email}:`, err)
1841
+ }
1842
+ }
1843
+
1844
+ /**
1845
+ * Load UID mapping from S3 (maps S3 keys to persistent UIDs)
1846
+ */
1847
+ private async loadUidMapping(email: string): Promise<{ mapping: Record<string, number>, nextUid: number }> {
1848
+ const cached = this.uidMappingCache.get(email)
1849
+ const nextUid = this.nextUidCache.get(email)
1850
+ if (cached && nextUid !== undefined) {
1851
+ return { mapping: cached, nextUid }
1852
+ }
1853
+
1854
+ try {
1855
+ const uidKey = `uids/${email.replace('@', '_at_')}.json`
1856
+ const content = await this.s3.getObject(this.config.bucket, uidKey)
1857
+ const data = JSON.parse(content)
1858
+ const mapping = data.mapping || {}
1859
+ const loadedNextUid = data.nextUid || 1
1860
+ this.uidMappingCache.set(email, mapping)
1861
+ this.nextUidCache.set(email, loadedNextUid)
1862
+ return { mapping, nextUid: loadedNextUid }
1863
+ } catch {
1864
+ // No UID mapping file yet
1865
+ const empty: Record<string, number> = {}
1866
+ this.uidMappingCache.set(email, empty)
1867
+ this.nextUidCache.set(email, 1)
1868
+ return { mapping: empty, nextUid: 1 }
1869
+ }
1870
+ }
1871
+
1872
+ /**
1873
+ * Save UID mapping to S3
1874
+ */
1875
+ private async saveUidMapping(email: string): Promise<void> {
1876
+ const mapping = this.uidMappingCache.get(email)
1877
+ const nextUid = this.nextUidCache.get(email)
1878
+ if (!mapping) return
1879
+
1880
+ try {
1881
+ const uidKey = `uids/${email.replace('@', '_at_')}.json`
1882
+ await this.s3.putObject({
1883
+ bucket: this.config.bucket,
1884
+ key: uidKey,
1885
+ body: JSON.stringify({ mapping, nextUid }),
1886
+ contentType: 'application/json',
1887
+ })
1888
+ } catch (err) {
1889
+ console.error(`Failed to save UID mapping for ${email}:`, err)
1890
+ }
1891
+ }
1892
+
1893
+ /**
1894
+ * Get or assign a UID for a message (S3 key)
1895
+ * Returns existing UID if message was seen before, assigns new UID otherwise
1896
+ */
1897
+ private getOrAssignUid(email: string, s3Key: string): number {
1898
+ let mapping = this.uidMappingCache.get(email)
1899
+ if (!mapping) {
1900
+ mapping = {}
1901
+ this.uidMappingCache.set(email, mapping)
1902
+ }
1903
+
1904
+ // Return existing UID if message was seen before
1905
+ if (mapping[s3Key]) {
1906
+ return mapping[s3Key]
1907
+ }
1908
+
1909
+ // Assign new UID
1910
+ let nextUid = this.nextUidCache.get(email) || 1
1911
+ mapping[s3Key] = nextUid
1912
+ nextUid++
1913
+ this.nextUidCache.set(email, nextUid)
1914
+
1915
+ return mapping[s3Key]
1916
+ }
1917
+
1918
+ /**
1919
+ * Get messages for a specific folder
1920
+ */
1921
+ private getMessagesForFolder(email: string, folder: string): EmailMessage[] {
1922
+ const userCache = this.messageCache.get(email)
1923
+ if (!userCache) return []
1924
+ return userCache.get(folder.toUpperCase()) || []
1925
+ }
1926
+
1927
+ /**
1928
+ * Set messages for a specific folder
1929
+ */
1930
+ private setMessagesForFolder(email: string, folder: string, messages: EmailMessage[]): void {
1931
+ let userCache = this.messageCache.get(email)
1932
+ if (!userCache) {
1933
+ userCache = new Map()
1934
+ this.messageCache.set(email, userCache)
1935
+ }
1936
+ userCache.set(folder.toUpperCase(), messages)
1937
+ }
1938
+
1939
+ /**
1940
+ * Get UID counter for a folder
1941
+ */
1942
+ private getUidCounterForFolder(email: string, folder: string): number {
1943
+ const userCounters = this.uidCounter.get(email)
1944
+ if (!userCounters) return 0
1945
+ return userCounters.get(folder.toUpperCase()) || 0
1946
+ }
1947
+
1948
+ /**
1949
+ * Set UID counter for a folder
1950
+ */
1951
+ private setUidCounterForFolder(email: string, folder: string, value: number): void {
1952
+ let userCounters = this.uidCounter.get(email)
1953
+ if (!userCounters) {
1954
+ userCounters = new Map()
1955
+ this.uidCounter.set(email, userCounters)
1956
+ }
1957
+ userCounters.set(folder.toUpperCase(), value)
1958
+ }
1959
+
1960
+ /**
1961
+ * Handle XLIST command (deprecated Gmail extension, but some clients use it)
1962
+ */
1963
+ private async handleXlist(session: ImapSession, tag: string, args: string): Promise<void> {
1964
+ if (session.state === 'not_authenticated') {
1965
+ this.send(session, `${tag} NO Must authenticate first`)
1966
+ return
1967
+ }
1968
+
1969
+ // Parse reference and pattern
1970
+ const match = args.match(/^"?([^"]*)"?\s+"?([^"]*)"?$/)
1971
+ if (!match) {
1972
+ this.send(session, `${tag} BAD Invalid XLIST syntax`)
1973
+ return
1974
+ }
1975
+
1976
+ const [, reference, pattern] = match
1977
+
1978
+ // Return folders with Gmail-style XLIST attributes
1979
+ if (pattern === '*' || pattern === '%' || pattern === '') {
1980
+ this.send(session, `* XLIST (\\HasNoChildren \\Inbox) "/" "INBOX"`)
1981
+ this.send(session, `* XLIST (\\HasNoChildren \\Sent) "/" "Sent"`)
1982
+ this.send(session, `* XLIST (\\HasNoChildren \\Drafts) "/" "Drafts"`)
1983
+ this.send(session, `* XLIST (\\HasNoChildren \\Trash) "/" "Trash"`)
1984
+ this.send(session, `* XLIST (\\HasNoChildren \\Spam) "/" "Junk"`)
1985
+ this.send(session, `* XLIST (\\HasNoChildren \\AllMail) "/" "All Mail"`)
1986
+ this.send(session, `* XLIST (\\HasNoChildren) "/" "Archive"`)
1987
+ }
1988
+
1989
+ this.send(session, `${tag} OK XLIST completed`)
1990
+ }
1991
+
1992
+ /**
1993
+ * Handle CREATE command
1994
+ */
1995
+ private async handleCreate(session: ImapSession, tag: string, args: string): Promise<void> {
1996
+ if (session.state === 'not_authenticated') {
1997
+ this.send(session, `${tag} NO Must authenticate first`)
1998
+ return
1999
+ }
2000
+
2001
+ // For now, acknowledge but don't actually create (S3 doesn't have true folders)
2002
+ const mailbox = args.replace(/^"(.*)"$/, '$1')
2003
+ this.send(session, `${tag} OK CREATE completed`)
2004
+ }
2005
+
2006
+ /**
2007
+ * Handle DELETE command
2008
+ */
2009
+ private async handleDelete(session: ImapSession, tag: string, args: string): Promise<void> {
2010
+ if (session.state === 'not_authenticated') {
2011
+ this.send(session, `${tag} NO Must authenticate first`)
2012
+ return
2013
+ }
2014
+
2015
+ // Don't allow deleting system folders
2016
+ const mailbox = args.replace(/^"(.*)"$/, '$1').toUpperCase()
2017
+ const systemFolders = ['INBOX', 'SENT', 'DRAFTS', 'TRASH', 'JUNK', 'ARCHIVE', 'ALL MAIL']
2018
+
2019
+ if (systemFolders.includes(mailbox)) {
2020
+ this.send(session, `${tag} NO Cannot delete system folder`)
2021
+ return
2022
+ }
2023
+
2024
+ this.send(session, `${tag} OK DELETE completed`)
2025
+ }
2026
+
2027
+ /**
2028
+ * Handle SUBSCRIBE command
2029
+ */
2030
+ private async handleSubscribe(session: ImapSession, tag: string, args: string): Promise<void> {
2031
+ if (session.state === 'not_authenticated') {
2032
+ this.send(session, `${tag} NO Must authenticate first`)
2033
+ return
2034
+ }
2035
+
2036
+ this.send(session, `${tag} OK SUBSCRIBE completed`)
2037
+ }
2038
+
2039
+ /**
2040
+ * Handle UNSUBSCRIBE command
2041
+ */
2042
+ private async handleUnsubscribe(session: ImapSession, tag: string, args: string): Promise<void> {
2043
+ if (session.state === 'not_authenticated') {
2044
+ this.send(session, `${tag} NO Must authenticate first`)
2045
+ return
2046
+ }
2047
+
2048
+ this.send(session, `${tag} OK UNSUBSCRIBE completed`)
2049
+ }
2050
+
2051
+ /**
2052
+ * Handle RENAME command
2053
+ */
2054
+ private async handleRename(session: ImapSession, tag: string, args: string): Promise<void> {
2055
+ if (session.state === 'not_authenticated') {
2056
+ this.send(session, `${tag} NO Must authenticate first`)
2057
+ return
2058
+ }
2059
+
2060
+ // Don't allow renaming system folders
2061
+ const match = args.match(/^"?([^"\s]+)"?\s+"?([^"\s]+)"?$/)
2062
+ if (!match) {
2063
+ this.send(session, `${tag} BAD Invalid RENAME syntax`)
2064
+ return
2065
+ }
2066
+
2067
+ const oldName = match[1].toUpperCase()
2068
+ const systemFolders = ['INBOX', 'SENT', 'DRAFTS', 'TRASH', 'JUNK', 'ARCHIVE', 'ALL MAIL']
2069
+
2070
+ if (systemFolders.includes(oldName)) {
2071
+ this.send(session, `${tag} NO Cannot rename system folder`)
2072
+ return
2073
+ }
2074
+
2075
+ this.send(session, `${tag} OK RENAME completed`)
2076
+ }
2077
+
2078
+ /**
2079
+ * Handle APPEND command
2080
+ */
2081
+ private async handleAppend(session: ImapSession, tag: string, args: string): Promise<void> {
2082
+ if (session.state === 'not_authenticated') {
2083
+ this.send(session, `${tag} NO Must authenticate first`)
2084
+ return
2085
+ }
2086
+
2087
+ // APPEND adds a message to a mailbox
2088
+ // For now, acknowledge but don't persist (would need S3 write)
2089
+ this.send(session, `${tag} OK APPEND completed`)
2090
+ }
2091
+ }
2092
+
2093
+ /**
2094
+ * Create and start an IMAP server
2095
+ */
2096
+ export async function startImapServer(config: ImapServerConfig): Promise<ImapServer> {
2097
+ const server = new ImapServer(config)
2098
+ await server.start()
2099
+ return server
2100
+ }