@stacksjs/ts-cloud 0.1.7 → 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.
- package/dist/aws/s3.d.ts +1 -1
- package/dist/bin/cli.js +223 -222
- package/dist/index.js +132 -132
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- 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
|
+
}
|