@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.
- package/dist/bin/cli.js +1 -1
- 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,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMTP Relay Server
|
|
3
|
+
* Provides SMTP access with user-friendly credentials that relays to AWS SES
|
|
4
|
+
*
|
|
5
|
+
* This allows email clients like Mail.app to send emails using:
|
|
6
|
+
* - Server: mail.yourdomain.com
|
|
7
|
+
* - Username: chris (or chris@yourdomain.com)
|
|
8
|
+
* - Password: your-password
|
|
9
|
+
*
|
|
10
|
+
* Instead of the unfriendly AWS SES SMTP credentials.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as net from 'node:net'
|
|
14
|
+
import * as tls from 'node:tls'
|
|
15
|
+
import * as fs from 'node:fs'
|
|
16
|
+
import * as crypto from 'node:crypto'
|
|
17
|
+
import { SESClient } from './ses'
|
|
18
|
+
import { S3Client } from './s3'
|
|
19
|
+
|
|
20
|
+
export interface SmtpServerConfig {
|
|
21
|
+
port?: number
|
|
22
|
+
tlsPort?: number
|
|
23
|
+
host?: string
|
|
24
|
+
region?: string
|
|
25
|
+
domain: string
|
|
26
|
+
users: Record<string, { password: string; email: string }>
|
|
27
|
+
tls?: {
|
|
28
|
+
key: string
|
|
29
|
+
cert: string
|
|
30
|
+
}
|
|
31
|
+
// Optional: S3 bucket for storing sent emails
|
|
32
|
+
sentBucket?: string
|
|
33
|
+
sentPrefix?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SmtpSession {
|
|
37
|
+
id: string
|
|
38
|
+
socket: net.Socket
|
|
39
|
+
secure: boolean
|
|
40
|
+
state: 'greeting' | 'ready' | 'mail' | 'rcpt' | 'data' | 'quit'
|
|
41
|
+
authenticated: boolean
|
|
42
|
+
username?: string
|
|
43
|
+
email?: string
|
|
44
|
+
mailFrom?: string
|
|
45
|
+
rcptTo: string[]
|
|
46
|
+
dataBuffer: string
|
|
47
|
+
tlsUpgraded: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* SMTP Server that relays emails through AWS SES
|
|
52
|
+
*/
|
|
53
|
+
export class SmtpServer {
|
|
54
|
+
private config: SmtpServerConfig
|
|
55
|
+
private ses: SESClient
|
|
56
|
+
private s3?: S3Client
|
|
57
|
+
private server?: net.Server
|
|
58
|
+
private tlsServer?: tls.Server
|
|
59
|
+
private sessions: Map<string, SmtpSession> = new Map()
|
|
60
|
+
|
|
61
|
+
constructor(config: SmtpServerConfig) {
|
|
62
|
+
this.config = {
|
|
63
|
+
port: 587, // Submission port (STARTTLS)
|
|
64
|
+
tlsPort: 465, // Implicit TLS
|
|
65
|
+
host: '0.0.0.0',
|
|
66
|
+
...config,
|
|
67
|
+
}
|
|
68
|
+
this.ses = new SESClient(config.region || 'us-east-1')
|
|
69
|
+
if (config.sentBucket) {
|
|
70
|
+
this.s3 = new S3Client(config.region || 'us-east-1')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Start the SMTP server
|
|
76
|
+
*/
|
|
77
|
+
async start(): Promise<void> {
|
|
78
|
+
// Start STARTTLS SMTP server on port 587
|
|
79
|
+
this.server = net.createServer((socket) => {
|
|
80
|
+
this.handleConnection(socket, false)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
this.server.listen(this.config.port, this.config.host, () => {
|
|
84
|
+
console.log(`SMTP server listening on ${this.config.host}:${this.config.port} (STARTTLS)`)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Start implicit TLS SMTP server on port 465 if certificates provided
|
|
88
|
+
if (this.config.tls?.key && this.config.tls?.cert) {
|
|
89
|
+
const tlsOptions: tls.TlsOptions = {
|
|
90
|
+
key: fs.readFileSync(this.config.tls.key),
|
|
91
|
+
cert: fs.readFileSync(this.config.tls.cert),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.tlsServer = tls.createServer(tlsOptions, (socket) => {
|
|
95
|
+
this.handleConnection(socket, true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
this.tlsServer.listen(this.config.tlsPort, this.config.host, () => {
|
|
99
|
+
console.log(`SMTP server listening on ${this.config.host}:${this.config.tlsPort} (TLS)`)
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stop the SMTP server
|
|
106
|
+
*/
|
|
107
|
+
async stop(): Promise<void> {
|
|
108
|
+
if (this.server) {
|
|
109
|
+
this.server.close()
|
|
110
|
+
}
|
|
111
|
+
if (this.tlsServer) {
|
|
112
|
+
this.tlsServer.close()
|
|
113
|
+
}
|
|
114
|
+
for (const session of this.sessions.values()) {
|
|
115
|
+
session.socket.destroy()
|
|
116
|
+
}
|
|
117
|
+
this.sessions.clear()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handle a new connection
|
|
122
|
+
*/
|
|
123
|
+
private handleConnection(socket: net.Socket, secure: boolean): void {
|
|
124
|
+
const sessionId = crypto.randomUUID()
|
|
125
|
+
const session: SmtpSession = {
|
|
126
|
+
id: sessionId,
|
|
127
|
+
socket,
|
|
128
|
+
secure,
|
|
129
|
+
state: 'greeting',
|
|
130
|
+
authenticated: false,
|
|
131
|
+
rcptTo: [],
|
|
132
|
+
dataBuffer: '',
|
|
133
|
+
tlsUpgraded: secure,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.sessions.set(sessionId, session)
|
|
137
|
+
console.log(`SMTP connection from ${socket.remoteAddress} (session: ${sessionId.slice(0, 8)})`)
|
|
138
|
+
|
|
139
|
+
// Send greeting
|
|
140
|
+
this.send(session, `220 ${this.config.domain} ESMTP Mail Server`)
|
|
141
|
+
session.state = 'ready'
|
|
142
|
+
|
|
143
|
+
let buffer = ''
|
|
144
|
+
|
|
145
|
+
socket.on('data', async (data) => {
|
|
146
|
+
buffer += data.toString()
|
|
147
|
+
|
|
148
|
+
// Process complete lines
|
|
149
|
+
let lineEnd: number
|
|
150
|
+
while ((lineEnd = buffer.indexOf('\r\n')) !== -1) {
|
|
151
|
+
const line = buffer.slice(0, lineEnd)
|
|
152
|
+
buffer = buffer.slice(lineEnd + 2)
|
|
153
|
+
|
|
154
|
+
if (session.state === 'data') {
|
|
155
|
+
await this.handleDataLine(session, line)
|
|
156
|
+
} else {
|
|
157
|
+
await this.handleCommand(session, line)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
socket.on('close', () => {
|
|
163
|
+
console.log(`SMTP session ${sessionId.slice(0, 8)} closed`)
|
|
164
|
+
this.sessions.delete(sessionId)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
socket.on('error', (err) => {
|
|
168
|
+
console.error(`SMTP session ${sessionId.slice(0, 8)} error:`, err.message)
|
|
169
|
+
this.sessions.delete(sessionId)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Send a response to the client
|
|
175
|
+
*/
|
|
176
|
+
private send(session: SmtpSession, message: string): void {
|
|
177
|
+
if (!session.socket.destroyed) {
|
|
178
|
+
session.socket.write(message + '\r\n')
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Handle an SMTP command
|
|
184
|
+
*/
|
|
185
|
+
private async handleCommand(session: SmtpSession, line: string): Promise<void> {
|
|
186
|
+
const command = line.split(' ')[0].toUpperCase()
|
|
187
|
+
const args = line.slice(command.length).trim()
|
|
188
|
+
|
|
189
|
+
console.log(`SMTP CMD [${session.username || 'anon'}]: ${command} ${args.includes('AUTH') ? '***' : args}`)
|
|
190
|
+
|
|
191
|
+
switch (command) {
|
|
192
|
+
case 'EHLO':
|
|
193
|
+
case 'HELO':
|
|
194
|
+
await this.handleEhlo(session, args)
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
case 'STARTTLS':
|
|
198
|
+
await this.handleStartTls(session)
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
case 'AUTH':
|
|
202
|
+
await this.handleAuth(session, args)
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
case 'MAIL':
|
|
206
|
+
await this.handleMailFrom(session, args)
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
case 'RCPT':
|
|
210
|
+
await this.handleRcptTo(session, args)
|
|
211
|
+
break
|
|
212
|
+
|
|
213
|
+
case 'DATA':
|
|
214
|
+
await this.handleData(session)
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
case 'RSET':
|
|
218
|
+
this.resetSession(session)
|
|
219
|
+
this.send(session, '250 OK')
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
case 'NOOP':
|
|
223
|
+
this.send(session, '250 OK')
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
case 'QUIT':
|
|
227
|
+
this.send(session, `221 ${this.config.domain} closing connection`)
|
|
228
|
+
session.socket.end()
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
default:
|
|
232
|
+
this.send(session, '500 Unrecognized command')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Handle EHLO/HELO command
|
|
238
|
+
*/
|
|
239
|
+
private async handleEhlo(session: SmtpSession, clientDomain: string): Promise<void> {
|
|
240
|
+
const capabilities = [
|
|
241
|
+
`250-${this.config.domain} Hello ${clientDomain}`,
|
|
242
|
+
'250-SIZE 26214400', // 25MB max
|
|
243
|
+
'250-8BITMIME',
|
|
244
|
+
'250-PIPELINING',
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
// Only advertise STARTTLS if not already secure and certs are available
|
|
248
|
+
if (!session.secure && this.config.tls?.key) {
|
|
249
|
+
capabilities.push('250-STARTTLS')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Always advertise AUTH
|
|
253
|
+
capabilities.push('250-AUTH PLAIN LOGIN')
|
|
254
|
+
capabilities.push('250 OK')
|
|
255
|
+
|
|
256
|
+
for (const cap of capabilities) {
|
|
257
|
+
this.send(session, cap)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Handle STARTTLS command
|
|
263
|
+
*/
|
|
264
|
+
private async handleStartTls(session: SmtpSession): Promise<void> {
|
|
265
|
+
if (session.secure || session.tlsUpgraded) {
|
|
266
|
+
this.send(session, '503 TLS already active')
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!this.config.tls?.key || !this.config.tls?.cert) {
|
|
271
|
+
this.send(session, '454 TLS not available')
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.send(session, '220 Ready to start TLS')
|
|
276
|
+
|
|
277
|
+
const tlsOptions: tls.TLSSocketOptions = {
|
|
278
|
+
key: fs.readFileSync(this.config.tls.key),
|
|
279
|
+
cert: fs.readFileSync(this.config.tls.cert),
|
|
280
|
+
isServer: true,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Upgrade the connection to TLS
|
|
284
|
+
const tlsSocket = new tls.TLSSocket(session.socket, tlsOptions)
|
|
285
|
+
|
|
286
|
+
// Replace the socket in the session
|
|
287
|
+
session.socket = tlsSocket
|
|
288
|
+
session.secure = true
|
|
289
|
+
session.tlsUpgraded = true
|
|
290
|
+
|
|
291
|
+
// Reset session state after STARTTLS
|
|
292
|
+
this.resetSession(session)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Handle AUTH command
|
|
297
|
+
*/
|
|
298
|
+
private async handleAuth(session: SmtpSession, args: string): Promise<void> {
|
|
299
|
+
const parts = args.split(' ')
|
|
300
|
+
const mechanism = parts[0].toUpperCase()
|
|
301
|
+
|
|
302
|
+
if (mechanism === 'PLAIN') {
|
|
303
|
+
// AUTH PLAIN [base64-credentials]
|
|
304
|
+
if (parts[1]) {
|
|
305
|
+
await this.handleAuthPlain(session, parts[1])
|
|
306
|
+
} else {
|
|
307
|
+
this.send(session, '334 ')
|
|
308
|
+
// Client will send credentials in next line
|
|
309
|
+
session.state = 'ready' // Will handle in next command
|
|
310
|
+
}
|
|
311
|
+
} else if (mechanism === 'LOGIN') {
|
|
312
|
+
// AUTH LOGIN - multi-step
|
|
313
|
+
this.send(session, '334 VXNlcm5hbWU6') // "Username:" in base64
|
|
314
|
+
// Set up state to receive username
|
|
315
|
+
const originalHandler = session.socket.listeners('data')[0] as (...args: any[]) => void
|
|
316
|
+
session.socket.removeAllListeners('data')
|
|
317
|
+
|
|
318
|
+
let step = 'username'
|
|
319
|
+
let username = ''
|
|
320
|
+
|
|
321
|
+
session.socket.on('data', async (data) => {
|
|
322
|
+
const line = data.toString().trim()
|
|
323
|
+
|
|
324
|
+
if (step === 'username') {
|
|
325
|
+
username = Buffer.from(line, 'base64').toString('utf-8')
|
|
326
|
+
step = 'password'
|
|
327
|
+
this.send(session, '334 UGFzc3dvcmQ6') // "Password:" in base64
|
|
328
|
+
} else if (step === 'password') {
|
|
329
|
+
const password = Buffer.from(line, 'base64').toString('utf-8')
|
|
330
|
+
session.socket.removeAllListeners('data')
|
|
331
|
+
session.socket.on('data', originalHandler)
|
|
332
|
+
|
|
333
|
+
await this.authenticateUser(session, username, password)
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
} else {
|
|
337
|
+
this.send(session, '504 Unrecognized authentication mechanism')
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Handle AUTH PLAIN credentials
|
|
343
|
+
*/
|
|
344
|
+
private async handleAuthPlain(session: SmtpSession, credentials: string): Promise<void> {
|
|
345
|
+
try {
|
|
346
|
+
// AUTH PLAIN credentials are: \0username\0password in base64
|
|
347
|
+
const decoded = Buffer.from(credentials, 'base64').toString('utf-8')
|
|
348
|
+
const parts = decoded.split('\0')
|
|
349
|
+
|
|
350
|
+
// Format can be: \0username\0password or authzid\0authcid\0password
|
|
351
|
+
const username = parts.length === 3 ? parts[1] : parts[0]
|
|
352
|
+
const password = parts.length === 3 ? parts[2] : parts[1]
|
|
353
|
+
|
|
354
|
+
await this.authenticateUser(session, username, password)
|
|
355
|
+
} catch (err) {
|
|
356
|
+
this.send(session, '535 Authentication failed')
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Authenticate a user
|
|
362
|
+
*/
|
|
363
|
+
private async authenticateUser(session: SmtpSession, username: string, password: string): Promise<void> {
|
|
364
|
+
// Strip domain from username if present (chris@stacksjs.com -> chris)
|
|
365
|
+
const cleanUsername = username.includes('@') ? username.split('@')[0] : username
|
|
366
|
+
|
|
367
|
+
const user = this.config.users[cleanUsername]
|
|
368
|
+
|
|
369
|
+
if (user && user.password === password) {
|
|
370
|
+
session.authenticated = true
|
|
371
|
+
session.username = cleanUsername
|
|
372
|
+
session.email = user.email
|
|
373
|
+
this.send(session, '235 Authentication successful')
|
|
374
|
+
console.log(`SMTP AUTH success: ${cleanUsername}`)
|
|
375
|
+
} else {
|
|
376
|
+
this.send(session, '535 Authentication failed')
|
|
377
|
+
console.log(`SMTP AUTH failed: ${username}`)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Handle MAIL FROM command
|
|
383
|
+
*/
|
|
384
|
+
private async handleMailFrom(session: SmtpSession, args: string): Promise<void> {
|
|
385
|
+
if (!session.authenticated) {
|
|
386
|
+
this.send(session, '530 Authentication required')
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Parse: FROM:<address>
|
|
391
|
+
const match = args.match(/FROM:\s*<([^>]*)>/i)
|
|
392
|
+
if (!match) {
|
|
393
|
+
this.send(session, '501 Syntax error in MAIL FROM')
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const fromAddress = match[1]
|
|
398
|
+
|
|
399
|
+
// Verify the sender is allowed (must be from their domain)
|
|
400
|
+
if (!fromAddress.endsWith(`@${this.config.domain}`)) {
|
|
401
|
+
this.send(session, `553 Sender address must be from @${this.config.domain}`)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
session.mailFrom = fromAddress
|
|
406
|
+
session.state = 'mail'
|
|
407
|
+
this.send(session, '250 OK')
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Handle RCPT TO command
|
|
412
|
+
*/
|
|
413
|
+
private async handleRcptTo(session: SmtpSession, args: string): Promise<void> {
|
|
414
|
+
if (!session.authenticated) {
|
|
415
|
+
this.send(session, '530 Authentication required')
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!session.mailFrom) {
|
|
420
|
+
this.send(session, '503 MAIL FROM required first')
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Parse: TO:<address>
|
|
425
|
+
const match = args.match(/TO:\s*<([^>]*)>/i)
|
|
426
|
+
if (!match) {
|
|
427
|
+
this.send(session, '501 Syntax error in RCPT TO')
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
session.rcptTo.push(match[1])
|
|
432
|
+
session.state = 'rcpt'
|
|
433
|
+
this.send(session, '250 OK')
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Handle DATA command
|
|
438
|
+
*/
|
|
439
|
+
private async handleData(session: SmtpSession): Promise<void> {
|
|
440
|
+
if (!session.authenticated) {
|
|
441
|
+
this.send(session, '530 Authentication required')
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (session.rcptTo.length === 0) {
|
|
446
|
+
this.send(session, '503 RCPT TO required first')
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
session.state = 'data'
|
|
451
|
+
session.dataBuffer = ''
|
|
452
|
+
this.send(session, '354 Start mail input; end with <CRLF>.<CRLF>')
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Handle a line during DATA phase
|
|
457
|
+
*/
|
|
458
|
+
private async handleDataLine(session: SmtpSession, line: string): Promise<void> {
|
|
459
|
+
if (line === '.') {
|
|
460
|
+
// End of data
|
|
461
|
+
await this.sendEmail(session)
|
|
462
|
+
} else {
|
|
463
|
+
// Handle dot-stuffing (lines starting with . have the dot removed)
|
|
464
|
+
const actualLine = line.startsWith('.') ? line.slice(1) : line
|
|
465
|
+
session.dataBuffer += actualLine + '\r\n'
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Send the email via SES
|
|
471
|
+
*/
|
|
472
|
+
private async sendEmail(session: SmtpSession): Promise<void> {
|
|
473
|
+
try {
|
|
474
|
+
const rawEmail = session.dataBuffer
|
|
475
|
+
|
|
476
|
+
// Send via SES using SendRawEmail
|
|
477
|
+
const result = await this.ses.sendRawEmail({
|
|
478
|
+
source: session.mailFrom!,
|
|
479
|
+
destinations: session.rcptTo,
|
|
480
|
+
rawMessage: rawEmail,
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
console.log(`SMTP: Email sent via SES, MessageId: ${result.MessageId}`)
|
|
484
|
+
|
|
485
|
+
// Store in Sent folder if S3 is configured (non-blocking, don't fail if this errors)
|
|
486
|
+
if (this.s3 && this.config.sentBucket) {
|
|
487
|
+
try {
|
|
488
|
+
const sentKey = `${this.config.sentPrefix || 'sent/'}${session.email}/${Date.now()}-${result.MessageId}`
|
|
489
|
+
await this.s3.putObject({
|
|
490
|
+
bucket: this.config.sentBucket,
|
|
491
|
+
key: sentKey,
|
|
492
|
+
body: rawEmail,
|
|
493
|
+
contentType: 'message/rfc822',
|
|
494
|
+
})
|
|
495
|
+
console.log(`SMTP: Email stored in S3: ${sentKey}`)
|
|
496
|
+
} catch (s3Err: any) {
|
|
497
|
+
// Log but don't fail - the email was already sent successfully
|
|
498
|
+
console.error(`SMTP: Failed to store sent email in S3 (email was sent successfully):`, s3Err.message)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
this.send(session, `250 OK Message accepted, ID: ${result.MessageId}`)
|
|
503
|
+
|
|
504
|
+
// Reset for next message
|
|
505
|
+
this.resetSession(session)
|
|
506
|
+
} catch (err: any) {
|
|
507
|
+
console.error('SMTP: Failed to send email:', err.message)
|
|
508
|
+
this.send(session, `451 Failed to send: ${err.message}`)
|
|
509
|
+
this.resetSession(session)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Reset session state for next message
|
|
515
|
+
*/
|
|
516
|
+
private resetSession(session: SmtpSession): void {
|
|
517
|
+
session.state = 'ready'
|
|
518
|
+
session.mailFrom = undefined
|
|
519
|
+
session.rcptTo = []
|
|
520
|
+
session.dataBuffer = ''
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Start an SMTP server with the given configuration
|
|
526
|
+
*/
|
|
527
|
+
export async function startSmtpServer(config: SmtpServerConfig): Promise<SmtpServer> {
|
|
528
|
+
const server = new SmtpServer(config)
|
|
529
|
+
await server.start()
|
|
530
|
+
return server
|
|
531
|
+
}
|