@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
package/src/aws/email.ts
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Email Module
|
|
3
|
+
* High-level email operations for both serverless and server deployments
|
|
4
|
+
*
|
|
5
|
+
* This module provides:
|
|
6
|
+
* - Email sending via SES
|
|
7
|
+
* - Email receiving setup (receipt rules, S3 storage)
|
|
8
|
+
* - Domain verification and DKIM setup
|
|
9
|
+
* - SMTP credential management for client email apps
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { SESClient } from './ses'
|
|
13
|
+
import { S3Client } from './s3'
|
|
14
|
+
import { IAMClient } from './iam'
|
|
15
|
+
import { Route53Client } from './route53'
|
|
16
|
+
|
|
17
|
+
export interface EmailConfig {
|
|
18
|
+
domain: string
|
|
19
|
+
region?: string
|
|
20
|
+
mailboxes?: string[]
|
|
21
|
+
storage?: {
|
|
22
|
+
bucket?: string
|
|
23
|
+
prefix?: string
|
|
24
|
+
retentionDays?: number
|
|
25
|
+
}
|
|
26
|
+
smtp?: {
|
|
27
|
+
enabled?: boolean
|
|
28
|
+
username?: string
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EmailSetupResult {
|
|
33
|
+
domainVerified: boolean
|
|
34
|
+
dkimStatus: string
|
|
35
|
+
dkimTokens?: string[]
|
|
36
|
+
mailFromStatus?: string
|
|
37
|
+
receiptRuleSet?: string
|
|
38
|
+
storageBucket?: string
|
|
39
|
+
smtpCredentials?: {
|
|
40
|
+
username: string
|
|
41
|
+
server: string
|
|
42
|
+
port: number
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SendEmailOptions {
|
|
47
|
+
from?: string
|
|
48
|
+
fromName?: string
|
|
49
|
+
to: string | string[]
|
|
50
|
+
cc?: string | string[]
|
|
51
|
+
bcc?: string | string[]
|
|
52
|
+
subject: string
|
|
53
|
+
text?: string
|
|
54
|
+
html?: string
|
|
55
|
+
replyTo?: string | string[]
|
|
56
|
+
attachments?: Array<{
|
|
57
|
+
filename: string
|
|
58
|
+
content: string // Base64 encoded
|
|
59
|
+
contentType?: string
|
|
60
|
+
}>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface EmailDeploymentConfig {
|
|
64
|
+
domain: string
|
|
65
|
+
accountId: string
|
|
66
|
+
region?: string
|
|
67
|
+
appName: string
|
|
68
|
+
environment: string
|
|
69
|
+
mailboxes?: string[]
|
|
70
|
+
catchAll?: boolean
|
|
71
|
+
storage?: {
|
|
72
|
+
bucketName?: string
|
|
73
|
+
prefix?: string
|
|
74
|
+
}
|
|
75
|
+
notifications?: {
|
|
76
|
+
bounces?: boolean
|
|
77
|
+
complaints?: boolean
|
|
78
|
+
newEmail?: boolean
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* High-level Email client for serverless and server deployments
|
|
84
|
+
*/
|
|
85
|
+
export class EmailClient {
|
|
86
|
+
private ses: SESClient
|
|
87
|
+
private s3: S3Client
|
|
88
|
+
private iam: IAMClient
|
|
89
|
+
private route53: Route53Client
|
|
90
|
+
private region: string
|
|
91
|
+
private domain?: string
|
|
92
|
+
private defaultFrom?: string
|
|
93
|
+
|
|
94
|
+
constructor(options: {
|
|
95
|
+
region?: string
|
|
96
|
+
domain?: string
|
|
97
|
+
defaultFrom?: string
|
|
98
|
+
} = {}) {
|
|
99
|
+
this.region = options.region || 'us-east-1'
|
|
100
|
+
this.domain = options.domain
|
|
101
|
+
this.defaultFrom = options.defaultFrom
|
|
102
|
+
this.ses = new SESClient(this.region)
|
|
103
|
+
this.s3 = new S3Client(this.region)
|
|
104
|
+
this.iam = new IAMClient(this.region)
|
|
105
|
+
this.route53 = new Route53Client(this.region)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============================================
|
|
109
|
+
// Email Sending
|
|
110
|
+
// ============================================
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Send an email
|
|
114
|
+
*/
|
|
115
|
+
async send(options: SendEmailOptions): Promise<{ messageId: string }> {
|
|
116
|
+
const from = options.from || this.defaultFrom
|
|
117
|
+
if (!from) {
|
|
118
|
+
throw new Error('From address is required. Set defaultFrom in constructor or provide from in options.')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fromAddress = options.fromName
|
|
122
|
+
? `${options.fromName} <${from}>`
|
|
123
|
+
: from
|
|
124
|
+
|
|
125
|
+
const toAddresses = Array.isArray(options.to) ? options.to : [options.to]
|
|
126
|
+
|
|
127
|
+
// Handle simple email (no attachments)
|
|
128
|
+
if (!options.attachments || options.attachments.length === 0) {
|
|
129
|
+
const result = await this.ses.sendSimpleEmail({
|
|
130
|
+
from: fromAddress,
|
|
131
|
+
to: toAddresses,
|
|
132
|
+
subject: options.subject,
|
|
133
|
+
text: options.text,
|
|
134
|
+
html: options.html,
|
|
135
|
+
replyTo: options.replyTo,
|
|
136
|
+
})
|
|
137
|
+
return { messageId: result.MessageId || '' }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Handle email with attachments using raw email
|
|
141
|
+
const rawEmail = this.buildRawEmail(options, fromAddress, toAddresses)
|
|
142
|
+
const result = await this.ses.sendEmail({
|
|
143
|
+
FromEmailAddress: fromAddress,
|
|
144
|
+
Destination: {
|
|
145
|
+
ToAddresses: toAddresses,
|
|
146
|
+
CcAddresses: options.cc ? (Array.isArray(options.cc) ? options.cc : [options.cc]) : undefined,
|
|
147
|
+
BccAddresses: options.bcc ? (Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : undefined,
|
|
148
|
+
},
|
|
149
|
+
Content: {
|
|
150
|
+
Raw: {
|
|
151
|
+
Data: Buffer.from(rawEmail).toString('base64'),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
return { messageId: result.MessageId || '' }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Send a templated email
|
|
160
|
+
*/
|
|
161
|
+
async sendTemplate(options: {
|
|
162
|
+
from?: string
|
|
163
|
+
fromName?: string
|
|
164
|
+
to: string | string[]
|
|
165
|
+
templateName: string
|
|
166
|
+
templateData: Record<string, any>
|
|
167
|
+
replyTo?: string | string[]
|
|
168
|
+
}): Promise<{ messageId: string }> {
|
|
169
|
+
const from = options.from || this.defaultFrom
|
|
170
|
+
if (!from) {
|
|
171
|
+
throw new Error('From address is required')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fromAddress = options.fromName
|
|
175
|
+
? `${options.fromName} <${from}>`
|
|
176
|
+
: from
|
|
177
|
+
|
|
178
|
+
const result = await this.ses.sendTemplatedEmail({
|
|
179
|
+
from: fromAddress,
|
|
180
|
+
to: options.to,
|
|
181
|
+
templateName: options.templateName,
|
|
182
|
+
templateData: options.templateData,
|
|
183
|
+
replyTo: options.replyTo,
|
|
184
|
+
})
|
|
185
|
+
return { messageId: result.MessageId || '' }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Send bulk emails using a template
|
|
190
|
+
*/
|
|
191
|
+
async sendBulk(options: {
|
|
192
|
+
from?: string
|
|
193
|
+
templateName: string
|
|
194
|
+
defaultTemplateData: Record<string, any>
|
|
195
|
+
recipients: Array<{
|
|
196
|
+
to: string | string[]
|
|
197
|
+
templateData?: Record<string, any>
|
|
198
|
+
}>
|
|
199
|
+
}): Promise<{ results: Array<{ status: string; messageId?: string; error?: string }> }> {
|
|
200
|
+
const from = options.from || this.defaultFrom
|
|
201
|
+
if (!from) {
|
|
202
|
+
throw new Error('From address is required')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const entries = options.recipients.map(r => ({
|
|
206
|
+
Destination: {
|
|
207
|
+
ToAddresses: Array.isArray(r.to) ? r.to : [r.to],
|
|
208
|
+
},
|
|
209
|
+
ReplacementEmailContent: r.templateData ? {
|
|
210
|
+
ReplacementTemplate: {
|
|
211
|
+
ReplacementTemplateData: JSON.stringify(r.templateData),
|
|
212
|
+
},
|
|
213
|
+
} : undefined,
|
|
214
|
+
}))
|
|
215
|
+
|
|
216
|
+
const result = await this.ses.sendBulkEmail({
|
|
217
|
+
FromEmailAddress: from,
|
|
218
|
+
BulkEmailEntries: entries,
|
|
219
|
+
DefaultContent: {
|
|
220
|
+
Template: {
|
|
221
|
+
TemplateName: options.templateName,
|
|
222
|
+
TemplateData: JSON.stringify(options.defaultTemplateData),
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
results: result.BulkEmailEntryResults?.map(r => ({
|
|
229
|
+
status: r.Status || 'UNKNOWN',
|
|
230
|
+
messageId: r.MessageId,
|
|
231
|
+
error: r.Error,
|
|
232
|
+
})) || [],
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ============================================
|
|
237
|
+
// Domain Setup and Verification
|
|
238
|
+
// ============================================
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Set up email for a domain (creates identity, DKIM, etc.)
|
|
242
|
+
*/
|
|
243
|
+
async setupDomain(domain: string): Promise<EmailSetupResult> {
|
|
244
|
+
// Create or get the email identity
|
|
245
|
+
let identity
|
|
246
|
+
try {
|
|
247
|
+
identity = await this.ses.getEmailIdentity(domain)
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Create new identity
|
|
251
|
+
const createResult = await this.ses.createEmailIdentity({ EmailIdentity: domain })
|
|
252
|
+
identity = {
|
|
253
|
+
VerificationStatus: createResult.DkimAttributes?.Status,
|
|
254
|
+
DkimAttributes: createResult.DkimAttributes,
|
|
255
|
+
SendingEnabled: createResult.VerifiedForSendingStatus,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Enable DKIM signing
|
|
260
|
+
try {
|
|
261
|
+
await this.ses.putEmailIdentityDkimAttributes({
|
|
262
|
+
EmailIdentity: domain,
|
|
263
|
+
SigningEnabled: true,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Might already be enabled
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Set up MAIL FROM domain
|
|
271
|
+
try {
|
|
272
|
+
await this.ses.putEmailIdentityMailFromAttributes(domain, {
|
|
273
|
+
MailFromDomain: `mail.${domain}`,
|
|
274
|
+
BehaviorOnMxFailure: 'USE_DEFAULT_VALUE',
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Might already be configured
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Get DKIM tokens for DNS setup
|
|
282
|
+
const dkimRecords = await this.ses.getDkimRecords(domain)
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
domainVerified: identity.SendingEnabled === true,
|
|
286
|
+
dkimStatus: identity.DkimAttributes?.Status || 'UNKNOWN',
|
|
287
|
+
dkimTokens: identity.DkimAttributes?.Tokens,
|
|
288
|
+
mailFromStatus: identity.MailFromAttributes?.MailFromDomainStatus,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get DNS records needed for email verification
|
|
294
|
+
*/
|
|
295
|
+
async getDnsRecords(domain: string): Promise<Array<{
|
|
296
|
+
type: string
|
|
297
|
+
name: string
|
|
298
|
+
value: string
|
|
299
|
+
priority?: number
|
|
300
|
+
ttl?: number
|
|
301
|
+
}>> {
|
|
302
|
+
const records: Array<{
|
|
303
|
+
type: string
|
|
304
|
+
name: string
|
|
305
|
+
value: string
|
|
306
|
+
priority?: number
|
|
307
|
+
ttl?: number
|
|
308
|
+
}> = []
|
|
309
|
+
|
|
310
|
+
// Get DKIM records
|
|
311
|
+
const dkimRecords = await this.ses.getDkimRecords(domain)
|
|
312
|
+
for (const record of dkimRecords) {
|
|
313
|
+
records.push({
|
|
314
|
+
type: record.type,
|
|
315
|
+
name: record.name,
|
|
316
|
+
value: record.value,
|
|
317
|
+
ttl: 1800,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// MX record for receiving (inbound via SES)
|
|
322
|
+
records.push({
|
|
323
|
+
type: 'MX',
|
|
324
|
+
name: domain,
|
|
325
|
+
value: `inbound-smtp.${this.region}.amazonaws.com`,
|
|
326
|
+
priority: 10,
|
|
327
|
+
ttl: 3600,
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
// MX record for MAIL FROM domain
|
|
331
|
+
records.push({
|
|
332
|
+
type: 'MX',
|
|
333
|
+
name: `mail.${domain}`,
|
|
334
|
+
value: `feedback-smtp.${this.region}.amazonses.com`,
|
|
335
|
+
priority: 10,
|
|
336
|
+
ttl: 3600,
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// SPF record for MAIL FROM
|
|
340
|
+
records.push({
|
|
341
|
+
type: 'TXT',
|
|
342
|
+
name: `mail.${domain}`,
|
|
343
|
+
value: 'v=spf1 include:amazonses.com ~all',
|
|
344
|
+
ttl: 3600,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// DMARC record
|
|
348
|
+
records.push({
|
|
349
|
+
type: 'TXT',
|
|
350
|
+
name: `_dmarc.${domain}`,
|
|
351
|
+
value: `v=DMARC1;p=quarantine;pct=25;rua=mailto:dmarcreports@${domain}`,
|
|
352
|
+
ttl: 3600,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
return records
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Check if domain is fully verified and ready to send
|
|
360
|
+
*/
|
|
361
|
+
async isDomainReady(domain: string): Promise<boolean> {
|
|
362
|
+
try {
|
|
363
|
+
const identity = await this.ses.getEmailIdentity(domain)
|
|
364
|
+
return identity.SendingEnabled === true
|
|
365
|
+
&& identity.DkimAttributes?.Status === 'SUCCESS'
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return false
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ============================================
|
|
373
|
+
// Email Receiving Setup
|
|
374
|
+
// ============================================
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Set up email receiving for a domain
|
|
378
|
+
*/
|
|
379
|
+
async setupReceiving(config: {
|
|
380
|
+
domain: string
|
|
381
|
+
ruleSetName: string
|
|
382
|
+
ruleName: string
|
|
383
|
+
bucketName: string
|
|
384
|
+
prefix?: string
|
|
385
|
+
accountId: string
|
|
386
|
+
recipients?: string[] // If not provided, catches all domain emails
|
|
387
|
+
scanEnabled?: boolean
|
|
388
|
+
lambdaArn?: string
|
|
389
|
+
}): Promise<void> {
|
|
390
|
+
// Ensure bucket policy allows SES to write
|
|
391
|
+
const policy = {
|
|
392
|
+
Version: '2012-10-17',
|
|
393
|
+
Statement: [
|
|
394
|
+
{
|
|
395
|
+
Sid: 'AllowSESPuts',
|
|
396
|
+
Effect: 'Allow',
|
|
397
|
+
Principal: { Service: 'ses.amazonaws.com' },
|
|
398
|
+
Action: 's3:PutObject',
|
|
399
|
+
Resource: `arn:aws:s3:::${config.bucketName}/*`,
|
|
400
|
+
Condition: {
|
|
401
|
+
StringEquals: { 'AWS:SourceAccount': config.accountId },
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
await this.s3.putBucketPolicy(config.bucketName, policy)
|
|
408
|
+
|
|
409
|
+
// Create receipt rule set if it doesn't exist
|
|
410
|
+
try {
|
|
411
|
+
await this.ses.createReceiptRuleSet(config.ruleSetName)
|
|
412
|
+
}
|
|
413
|
+
catch (e: any) {
|
|
414
|
+
if (!e.message?.includes('already exists') && e.code !== 'AlreadyExists') {
|
|
415
|
+
throw e
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Build actions
|
|
420
|
+
const actions: Array<{
|
|
421
|
+
S3Action?: { BucketName: string; ObjectKeyPrefix?: string }
|
|
422
|
+
LambdaAction?: { FunctionArn: string; InvocationType?: 'Event' | 'RequestResponse' }
|
|
423
|
+
}> = [
|
|
424
|
+
{
|
|
425
|
+
S3Action: {
|
|
426
|
+
BucketName: config.bucketName,
|
|
427
|
+
ObjectKeyPrefix: config.prefix || 'inbox/',
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
// Add Lambda action if provided
|
|
433
|
+
if (config.lambdaArn) {
|
|
434
|
+
actions.push({
|
|
435
|
+
LambdaAction: {
|
|
436
|
+
FunctionArn: config.lambdaArn,
|
|
437
|
+
InvocationType: 'Event',
|
|
438
|
+
},
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Create receipt rule
|
|
443
|
+
try {
|
|
444
|
+
await this.ses.createReceiptRule({
|
|
445
|
+
RuleSetName: config.ruleSetName,
|
|
446
|
+
Rule: {
|
|
447
|
+
Name: config.ruleName,
|
|
448
|
+
Enabled: true,
|
|
449
|
+
TlsPolicy: 'Optional',
|
|
450
|
+
Recipients: config.recipients || [config.domain],
|
|
451
|
+
ScanEnabled: config.scanEnabled !== false,
|
|
452
|
+
Actions: actions,
|
|
453
|
+
},
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
catch (e: any) {
|
|
457
|
+
// Rule might already exist - try to update it
|
|
458
|
+
if (e.message?.includes('already exists')) {
|
|
459
|
+
// Delete and recreate
|
|
460
|
+
await this.ses.deleteReceiptRule(config.ruleSetName, config.ruleName)
|
|
461
|
+
await this.ses.createReceiptRule({
|
|
462
|
+
RuleSetName: config.ruleSetName,
|
|
463
|
+
Rule: {
|
|
464
|
+
Name: config.ruleName,
|
|
465
|
+
Enabled: true,
|
|
466
|
+
TlsPolicy: 'Optional',
|
|
467
|
+
Recipients: config.recipients || [config.domain],
|
|
468
|
+
ScanEnabled: config.scanEnabled !== false,
|
|
469
|
+
Actions: actions,
|
|
470
|
+
},
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
throw e
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Set as active rule set
|
|
479
|
+
await this.ses.setActiveReceiptRuleSet(config.ruleSetName)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get incoming emails from S3 bucket
|
|
484
|
+
*/
|
|
485
|
+
async getIncomingEmails(options: {
|
|
486
|
+
bucket: string
|
|
487
|
+
prefix?: string
|
|
488
|
+
maxResults?: number
|
|
489
|
+
}): Promise<Array<{ key: string; lastModified: string; size: number }>> {
|
|
490
|
+
const objects = await this.s3.list({
|
|
491
|
+
bucket: options.bucket,
|
|
492
|
+
prefix: options.prefix || 'incoming/',
|
|
493
|
+
maxKeys: options.maxResults || 100,
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
return objects.map(obj => ({
|
|
497
|
+
key: obj.Key || '',
|
|
498
|
+
lastModified: obj.LastModified || '',
|
|
499
|
+
size: obj.Size || 0,
|
|
500
|
+
}))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Read an email from S3
|
|
505
|
+
*/
|
|
506
|
+
async readEmail(options: {
|
|
507
|
+
bucket: string
|
|
508
|
+
key: string
|
|
509
|
+
}): Promise<string> {
|
|
510
|
+
return await this.s3.getObject(options.bucket, options.key)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ============================================
|
|
514
|
+
// SMTP Credentials (for client email apps)
|
|
515
|
+
// ============================================
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Create SMTP credentials for sending via email clients
|
|
519
|
+
* Note: These use IAM users and SES SMTP interface
|
|
520
|
+
*/
|
|
521
|
+
async createSmtpCredentials(options: {
|
|
522
|
+
username: string
|
|
523
|
+
domain: string
|
|
524
|
+
}): Promise<{
|
|
525
|
+
username: string
|
|
526
|
+
password: string
|
|
527
|
+
server: string
|
|
528
|
+
port: number
|
|
529
|
+
}> {
|
|
530
|
+
// Create IAM user for SMTP using direct API calls
|
|
531
|
+
const iamUsername = `ses-smtp-${options.username.replace(/[^a-zA-Z0-9]/g, '-')}`
|
|
532
|
+
const { AWSClient } = await import('./client')
|
|
533
|
+
const client = new AWSClient()
|
|
534
|
+
|
|
535
|
+
// Helper to build form-encoded body
|
|
536
|
+
const buildBody = (action: string, params: Record<string, string>): string => {
|
|
537
|
+
const allParams = { Action: action, Version: '2010-05-08', ...params }
|
|
538
|
+
return Object.entries(allParams)
|
|
539
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
540
|
+
.join('&')
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Create IAM user
|
|
544
|
+
try {
|
|
545
|
+
await client.request({
|
|
546
|
+
service: 'iam',
|
|
547
|
+
region: 'us-east-1', // IAM is global but uses us-east-1
|
|
548
|
+
method: 'POST',
|
|
549
|
+
path: '/',
|
|
550
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
551
|
+
body: buildBody('CreateUser', { UserName: iamUsername }),
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
catch (e: any) {
|
|
555
|
+
if (!e.message?.includes('EntityAlreadyExists')) {
|
|
556
|
+
throw e
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Attach SES sending policy
|
|
561
|
+
const policy = {
|
|
562
|
+
Version: '2012-10-17',
|
|
563
|
+
Statement: [
|
|
564
|
+
{
|
|
565
|
+
Effect: 'Allow',
|
|
566
|
+
Action: 'ses:SendRawEmail',
|
|
567
|
+
Resource: '*',
|
|
568
|
+
Condition: {
|
|
569
|
+
StringLike: {
|
|
570
|
+
'ses:FromAddress': `*@${options.domain}`,
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const policyName = `ses-smtp-policy-${options.domain.replace(/\./g, '-')}`
|
|
578
|
+
try {
|
|
579
|
+
await client.request({
|
|
580
|
+
service: 'iam',
|
|
581
|
+
region: 'us-east-1',
|
|
582
|
+
method: 'POST',
|
|
583
|
+
path: '/',
|
|
584
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
585
|
+
body: buildBody('PutUserPolicy', {
|
|
586
|
+
UserName: iamUsername,
|
|
587
|
+
PolicyName: policyName,
|
|
588
|
+
PolicyDocument: JSON.stringify(policy),
|
|
589
|
+
}),
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
// Policy might already exist
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Create access key
|
|
597
|
+
const keyResponse: any = await client.request({
|
|
598
|
+
service: 'iam',
|
|
599
|
+
region: 'us-east-1',
|
|
600
|
+
method: 'POST',
|
|
601
|
+
path: '/',
|
|
602
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
603
|
+
body: buildBody('CreateAccessKey', { UserName: iamUsername }),
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
const accessKey = keyResponse?.CreateAccessKeyResult?.AccessKey
|
|
607
|
+
|
|
608
|
+
if (!accessKey?.AccessKeyId || !accessKey?.SecretAccessKey) {
|
|
609
|
+
throw new Error('Failed to create access key')
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Convert secret key to SMTP password
|
|
613
|
+
// AWS SES SMTP passwords are derived from the secret access key
|
|
614
|
+
const smtpPassword = this.deriveSmtpPassword(accessKey.SecretAccessKey)
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
username: accessKey.AccessKeyId,
|
|
618
|
+
password: smtpPassword,
|
|
619
|
+
server: `email-smtp.${this.region}.amazonaws.com`,
|
|
620
|
+
port: 587,
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Derive SMTP password from AWS secret access key
|
|
626
|
+
* Based on AWS documentation for SES SMTP credentials
|
|
627
|
+
*/
|
|
628
|
+
private deriveSmtpPassword(secretAccessKey: string): string {
|
|
629
|
+
const crypto = require('node:crypto')
|
|
630
|
+
|
|
631
|
+
// AWS SES SMTP password derivation algorithm
|
|
632
|
+
const message = 'SendRawEmail'
|
|
633
|
+
const versionInBytes = Buffer.from([0x04])
|
|
634
|
+
|
|
635
|
+
// Sign the message
|
|
636
|
+
let signature = crypto
|
|
637
|
+
.createHmac('sha256', `AWS4${secretAccessKey}`)
|
|
638
|
+
.update('11111111')
|
|
639
|
+
.digest()
|
|
640
|
+
|
|
641
|
+
signature = crypto
|
|
642
|
+
.createHmac('sha256', signature)
|
|
643
|
+
.update(this.region)
|
|
644
|
+
.digest()
|
|
645
|
+
|
|
646
|
+
signature = crypto
|
|
647
|
+
.createHmac('sha256', signature)
|
|
648
|
+
.update('ses')
|
|
649
|
+
.digest()
|
|
650
|
+
|
|
651
|
+
signature = crypto
|
|
652
|
+
.createHmac('sha256', signature)
|
|
653
|
+
.update('aws4_request')
|
|
654
|
+
.digest()
|
|
655
|
+
|
|
656
|
+
signature = crypto
|
|
657
|
+
.createHmac('sha256', signature)
|
|
658
|
+
.update(message)
|
|
659
|
+
.digest()
|
|
660
|
+
|
|
661
|
+
// Prepend version byte and encode as base64
|
|
662
|
+
const signatureWithVersion = Buffer.concat([versionInBytes, signature])
|
|
663
|
+
return signatureWithVersion.toString('base64')
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ============================================
|
|
667
|
+
// Email Templates
|
|
668
|
+
// ============================================
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Create an email template
|
|
672
|
+
*/
|
|
673
|
+
async createTemplate(options: {
|
|
674
|
+
name: string
|
|
675
|
+
subject: string
|
|
676
|
+
text?: string
|
|
677
|
+
html?: string
|
|
678
|
+
}): Promise<void> {
|
|
679
|
+
await this.ses.createEmailTemplate({
|
|
680
|
+
TemplateName: options.name,
|
|
681
|
+
TemplateContent: {
|
|
682
|
+
Subject: options.subject,
|
|
683
|
+
Text: options.text,
|
|
684
|
+
Html: options.html,
|
|
685
|
+
},
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Get an email template
|
|
691
|
+
*/
|
|
692
|
+
async getTemplate(name: string): Promise<{
|
|
693
|
+
name: string
|
|
694
|
+
subject?: string
|
|
695
|
+
text?: string
|
|
696
|
+
html?: string
|
|
697
|
+
} | null> {
|
|
698
|
+
try {
|
|
699
|
+
const result = await this.ses.getEmailTemplate(name)
|
|
700
|
+
return {
|
|
701
|
+
name: result.TemplateName || name,
|
|
702
|
+
subject: result.TemplateContent?.Subject,
|
|
703
|
+
text: result.TemplateContent?.Text,
|
|
704
|
+
html: result.TemplateContent?.Html,
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return null
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Delete an email template
|
|
714
|
+
*/
|
|
715
|
+
async deleteTemplate(name: string): Promise<void> {
|
|
716
|
+
await this.ses.deleteEmailTemplate(name)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* List all email templates
|
|
721
|
+
*/
|
|
722
|
+
async listTemplates(): Promise<Array<{ name: string; createdAt?: string }>> {
|
|
723
|
+
const result = await this.ses.listEmailTemplates()
|
|
724
|
+
return result.TemplatesMetadata?.map(t => ({
|
|
725
|
+
name: t.TemplateName || '',
|
|
726
|
+
createdAt: t.CreatedTimestamp,
|
|
727
|
+
})) || []
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ============================================
|
|
731
|
+
// Deployment Automation
|
|
732
|
+
// ============================================
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Deploy full email infrastructure for an application
|
|
736
|
+
*/
|
|
737
|
+
async deploy(config: EmailDeploymentConfig): Promise<{
|
|
738
|
+
success: boolean
|
|
739
|
+
domainVerified: boolean
|
|
740
|
+
dkimStatus: string
|
|
741
|
+
receiptRuleSet: string
|
|
742
|
+
storageBucket: string
|
|
743
|
+
dnsRecords: Array<{ type: string; name: string; value: string }>
|
|
744
|
+
}> {
|
|
745
|
+
const ruleSetName = `${config.appName}-${config.environment}-email-rules`
|
|
746
|
+
const ruleName = `${config.appName}-inbound-email`
|
|
747
|
+
const bucketName = config.storage?.bucketName || `${config.appName}-${config.environment}-email`
|
|
748
|
+
|
|
749
|
+
// 1. Set up domain identity
|
|
750
|
+
const domainSetup = await this.setupDomain(config.domain)
|
|
751
|
+
|
|
752
|
+
// 2. Create S3 bucket for email storage if it doesn't exist
|
|
753
|
+
const buckets = await this.s3.listBuckets()
|
|
754
|
+
const bucketExists = buckets.Buckets?.some(b => b.Name === bucketName)
|
|
755
|
+
if (!bucketExists) {
|
|
756
|
+
await this.s3.createBucket(bucketName)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// 3. Set up email receiving
|
|
760
|
+
await this.setupReceiving({
|
|
761
|
+
domain: config.domain,
|
|
762
|
+
ruleSetName,
|
|
763
|
+
ruleName,
|
|
764
|
+
bucketName,
|
|
765
|
+
prefix: config.storage?.prefix || 'inbox/',
|
|
766
|
+
accountId: config.accountId,
|
|
767
|
+
recipients: config.catchAll ? [config.domain] : config.mailboxes,
|
|
768
|
+
scanEnabled: true,
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
// 4. Get DNS records for verification
|
|
772
|
+
const dnsRecords = await this.getDnsRecords(config.domain)
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
success: true,
|
|
776
|
+
domainVerified: domainSetup.domainVerified,
|
|
777
|
+
dkimStatus: domainSetup.dkimStatus,
|
|
778
|
+
receiptRuleSet: ruleSetName,
|
|
779
|
+
storageBucket: bucketName,
|
|
780
|
+
dnsRecords,
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Undeploy email infrastructure
|
|
786
|
+
*/
|
|
787
|
+
async undeploy(config: {
|
|
788
|
+
appName: string
|
|
789
|
+
environment: string
|
|
790
|
+
domain: string
|
|
791
|
+
deleteBucket?: boolean
|
|
792
|
+
}): Promise<void> {
|
|
793
|
+
const ruleSetName = `${config.appName}-${config.environment}-email-rules`
|
|
794
|
+
const ruleName = `${config.appName}-inbound-email`
|
|
795
|
+
const bucketName = `${config.appName}-${config.environment}-email`
|
|
796
|
+
|
|
797
|
+
// 1. Delete receipt rule
|
|
798
|
+
try {
|
|
799
|
+
await this.ses.deleteReceiptRule(ruleSetName, ruleName)
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
// Rule might not exist
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 2. Delete receipt rule set
|
|
806
|
+
try {
|
|
807
|
+
await this.ses.deleteReceiptRuleSet(ruleSetName)
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
// Rule set might not exist or might be active
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// 3. Optionally delete the email identity
|
|
814
|
+
// Note: Usually you want to keep this to maintain domain reputation
|
|
815
|
+
// await this.ses.deleteEmailIdentity(config.domain)
|
|
816
|
+
|
|
817
|
+
// 4. Optionally delete the S3 bucket
|
|
818
|
+
if (config.deleteBucket) {
|
|
819
|
+
try {
|
|
820
|
+
await this.s3.emptyAndDeleteBucket(bucketName)
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
// Bucket might not exist
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ============================================
|
|
829
|
+
// Statistics and Monitoring
|
|
830
|
+
// ============================================
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Get sending statistics
|
|
834
|
+
*/
|
|
835
|
+
async getSendingStats(): Promise<{
|
|
836
|
+
sentLast24Hours: number
|
|
837
|
+
maxSendRate: number
|
|
838
|
+
max24HourSend: number
|
|
839
|
+
}> {
|
|
840
|
+
const quota = await this.ses.getSendQuota()
|
|
841
|
+
return {
|
|
842
|
+
sentLast24Hours: quota.SentLast24Hours || 0,
|
|
843
|
+
maxSendRate: quota.MaxSendRate || 0,
|
|
844
|
+
max24HourSend: quota.Max24HourSend || 0,
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ============================================
|
|
849
|
+
// Private Helpers
|
|
850
|
+
// ============================================
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Build a raw MIME email with attachments
|
|
854
|
+
*/
|
|
855
|
+
private buildRawEmail(
|
|
856
|
+
options: SendEmailOptions,
|
|
857
|
+
from: string,
|
|
858
|
+
to: string[],
|
|
859
|
+
): string {
|
|
860
|
+
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`
|
|
861
|
+
|
|
862
|
+
let email = ''
|
|
863
|
+
email += `From: ${from}\r\n`
|
|
864
|
+
email += `To: ${to.join(', ')}\r\n`
|
|
865
|
+
|
|
866
|
+
if (options.cc) {
|
|
867
|
+
const ccList = Array.isArray(options.cc) ? options.cc : [options.cc]
|
|
868
|
+
email += `Cc: ${ccList.join(', ')}\r\n`
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
email += `Subject: ${options.subject}\r\n`
|
|
872
|
+
email += `MIME-Version: 1.0\r\n`
|
|
873
|
+
email += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`
|
|
874
|
+
email += '\r\n'
|
|
875
|
+
|
|
876
|
+
// Text/HTML part
|
|
877
|
+
if (options.text || options.html) {
|
|
878
|
+
email += `--${boundary}\r\n`
|
|
879
|
+
|
|
880
|
+
if (options.html && options.text) {
|
|
881
|
+
const altBoundary = `----=_Alt_${Date.now()}`
|
|
882
|
+
email += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n`
|
|
883
|
+
email += '\r\n'
|
|
884
|
+
|
|
885
|
+
email += `--${altBoundary}\r\n`
|
|
886
|
+
email += 'Content-Type: text/plain; charset="UTF-8"\r\n'
|
|
887
|
+
email += '\r\n'
|
|
888
|
+
email += `${options.text}\r\n`
|
|
889
|
+
|
|
890
|
+
email += `--${altBoundary}\r\n`
|
|
891
|
+
email += 'Content-Type: text/html; charset="UTF-8"\r\n'
|
|
892
|
+
email += '\r\n'
|
|
893
|
+
email += `${options.html}\r\n`
|
|
894
|
+
|
|
895
|
+
email += `--${altBoundary}--\r\n`
|
|
896
|
+
}
|
|
897
|
+
else if (options.html) {
|
|
898
|
+
email += 'Content-Type: text/html; charset="UTF-8"\r\n'
|
|
899
|
+
email += '\r\n'
|
|
900
|
+
email += `${options.html}\r\n`
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
email += 'Content-Type: text/plain; charset="UTF-8"\r\n'
|
|
904
|
+
email += '\r\n'
|
|
905
|
+
email += `${options.text}\r\n`
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Attachments
|
|
910
|
+
if (options.attachments) {
|
|
911
|
+
for (const attachment of options.attachments) {
|
|
912
|
+
email += `--${boundary}\r\n`
|
|
913
|
+
email += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`
|
|
914
|
+
email += 'Content-Transfer-Encoding: base64\r\n'
|
|
915
|
+
email += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`
|
|
916
|
+
email += '\r\n'
|
|
917
|
+
email += `${attachment.content}\r\n`
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
email += `--${boundary}--\r\n`
|
|
922
|
+
|
|
923
|
+
return email
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Export a default instance for convenience
|
|
928
|
+
export const email: EmailClient = new EmailClient()
|