@stacksjs/ts-cloud-core 0.1.1
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/LICENSE.md +21 -0
- package/README.md +321 -0
- package/package.json +31 -0
- package/src/advanced-features.test.ts +465 -0
- package/src/aws/cloudformation.ts +421 -0
- package/src/aws/cloudfront.ts +158 -0
- package/src/aws/credentials.test.ts +132 -0
- package/src/aws/credentials.ts +545 -0
- package/src/aws/index.ts +87 -0
- package/src/aws/s3.test.ts +188 -0
- package/src/aws/s3.ts +1088 -0
- package/src/aws/signature.test.ts +670 -0
- package/src/aws/signature.ts +1155 -0
- package/src/backup/disaster-recovery.test.ts +726 -0
- package/src/backup/disaster-recovery.ts +500 -0
- package/src/backup/index.ts +34 -0
- package/src/backup/manager.test.ts +498 -0
- package/src/backup/manager.ts +432 -0
- package/src/cicd/circleci.ts +430 -0
- package/src/cicd/github-actions.ts +424 -0
- package/src/cicd/gitlab-ci.ts +255 -0
- package/src/cicd/index.ts +8 -0
- package/src/cli/history.ts +396 -0
- package/src/cli/index.ts +10 -0
- package/src/cli/progress.ts +458 -0
- package/src/cli/repl.ts +454 -0
- package/src/cli/suggestions.ts +327 -0
- package/src/cli/table.test.ts +319 -0
- package/src/cli/table.ts +332 -0
- package/src/cloudformation/builder.test.ts +327 -0
- package/src/cloudformation/builder.ts +378 -0
- package/src/cloudformation/builders/api-gateway.ts +449 -0
- package/src/cloudformation/builders/cache.ts +334 -0
- package/src/cloudformation/builders/cdn.ts +278 -0
- package/src/cloudformation/builders/compute.ts +485 -0
- package/src/cloudformation/builders/database.ts +392 -0
- package/src/cloudformation/builders/functions.ts +343 -0
- package/src/cloudformation/builders/messaging.ts +140 -0
- package/src/cloudformation/builders/monitoring.ts +300 -0
- package/src/cloudformation/builders/network.ts +264 -0
- package/src/cloudformation/builders/queue.ts +147 -0
- package/src/cloudformation/builders/security.ts +399 -0
- package/src/cloudformation/builders/storage.ts +285 -0
- package/src/cloudformation/index.ts +30 -0
- package/src/cloudformation/types.ts +173 -0
- package/src/compliance/aws-config.ts +543 -0
- package/src/compliance/cloudtrail.ts +376 -0
- package/src/compliance/compliance.test.ts +423 -0
- package/src/compliance/guardduty.ts +446 -0
- package/src/compliance/index.ts +66 -0
- package/src/compliance/security-hub.ts +456 -0
- package/src/containers/build-optimization.ts +416 -0
- package/src/containers/containers.test.ts +508 -0
- package/src/containers/image-scanning.ts +360 -0
- package/src/containers/index.ts +9 -0
- package/src/containers/registry.ts +293 -0
- package/src/containers/service-mesh.ts +520 -0
- package/src/database/database.test.ts +762 -0
- package/src/database/index.ts +9 -0
- package/src/database/migrations.ts +444 -0
- package/src/database/performance.ts +528 -0
- package/src/database/replicas.ts +534 -0
- package/src/database/users.ts +494 -0
- package/src/dependency-graph.ts +143 -0
- package/src/deployment/ab-testing.ts +582 -0
- package/src/deployment/blue-green.ts +452 -0
- package/src/deployment/canary.ts +500 -0
- package/src/deployment/deployment.test.ts +526 -0
- package/src/deployment/index.ts +61 -0
- package/src/deployment/progressive.ts +62 -0
- package/src/dns/dns.test.ts +641 -0
- package/src/dns/dnssec.ts +315 -0
- package/src/dns/index.ts +8 -0
- package/src/dns/resolver.ts +496 -0
- package/src/dns/routing.ts +593 -0
- package/src/email/advanced/analytics.ts +445 -0
- package/src/email/advanced/index.ts +11 -0
- package/src/email/advanced/rules.ts +465 -0
- package/src/email/advanced/scheduling.ts +352 -0
- package/src/email/advanced/search.ts +412 -0
- package/src/email/advanced/shared-mailboxes.ts +404 -0
- package/src/email/advanced/templates.ts +455 -0
- package/src/email/advanced/threading.ts +281 -0
- package/src/email/analytics.ts +467 -0
- package/src/email/bounce-handling.ts +425 -0
- package/src/email/email.test.ts +431 -0
- package/src/email/handlers/__tests__/inbound.test.ts +38 -0
- package/src/email/handlers/__tests__/outbound.test.ts +37 -0
- package/src/email/handlers/converter.ts +227 -0
- package/src/email/handlers/feedback.ts +228 -0
- package/src/email/handlers/inbound.ts +169 -0
- package/src/email/handlers/outbound.ts +178 -0
- package/src/email/index.ts +15 -0
- package/src/email/reputation.ts +303 -0
- package/src/email/templates.ts +352 -0
- package/src/errors/index.test.ts +434 -0
- package/src/errors/index.ts +416 -0
- package/src/health-checks/index.ts +40 -0
- package/src/index.ts +360 -0
- package/src/intrinsic-functions.ts +118 -0
- package/src/lambda/concurrency.ts +330 -0
- package/src/lambda/destinations.ts +345 -0
- package/src/lambda/dlq.ts +425 -0
- package/src/lambda/index.ts +11 -0
- package/src/lambda/lambda.test.ts +840 -0
- package/src/lambda/layers.ts +263 -0
- package/src/lambda/versions.ts +376 -0
- package/src/lambda/vpc.ts +399 -0
- package/src/local/config.ts +114 -0
- package/src/local/index.ts +6 -0
- package/src/local/mock-aws.ts +351 -0
- package/src/modules/ai.ts +340 -0
- package/src/modules/api.ts +478 -0
- package/src/modules/auth.ts +805 -0
- package/src/modules/cache.ts +417 -0
- package/src/modules/cdn.ts +1062 -0
- package/src/modules/communication.ts +1094 -0
- package/src/modules/compute.ts +3348 -0
- package/src/modules/database.ts +554 -0
- package/src/modules/deployment.ts +1079 -0
- package/src/modules/dns.ts +337 -0
- package/src/modules/email.ts +1538 -0
- package/src/modules/filesystem.ts +515 -0
- package/src/modules/index.ts +32 -0
- package/src/modules/messaging.ts +486 -0
- package/src/modules/monitoring.ts +2086 -0
- package/src/modules/network.ts +664 -0
- package/src/modules/parameter-store.ts +325 -0
- package/src/modules/permissions.ts +1081 -0
- package/src/modules/phone.ts +494 -0
- package/src/modules/queue.ts +1260 -0
- package/src/modules/redirects.ts +464 -0
- package/src/modules/registry.ts +699 -0
- package/src/modules/search.ts +401 -0
- package/src/modules/secrets.ts +416 -0
- package/src/modules/security.ts +731 -0
- package/src/modules/sms.ts +389 -0
- package/src/modules/storage.ts +1120 -0
- package/src/modules/workflow.ts +680 -0
- package/src/multi-account/config.ts +521 -0
- package/src/multi-account/index.ts +7 -0
- package/src/multi-account/manager.ts +427 -0
- package/src/multi-region/cross-region.ts +410 -0
- package/src/multi-region/index.ts +8 -0
- package/src/multi-region/manager.ts +483 -0
- package/src/multi-region/regions.ts +435 -0
- package/src/network-security/index.ts +48 -0
- package/src/observability/index.ts +9 -0
- package/src/observability/logs.ts +522 -0
- package/src/observability/metrics.ts +460 -0
- package/src/observability/observability.test.ts +782 -0
- package/src/observability/synthetics.ts +568 -0
- package/src/observability/xray.ts +358 -0
- package/src/phone/advanced/analytics.ts +349 -0
- package/src/phone/advanced/callbacks.ts +428 -0
- package/src/phone/advanced/index.ts +8 -0
- package/src/phone/advanced/ivr-builder.ts +504 -0
- package/src/phone/advanced/recording.ts +310 -0
- package/src/phone/handlers/__tests__/incoming-call.test.ts +40 -0
- package/src/phone/handlers/incoming-call.ts +117 -0
- package/src/phone/handlers/missed-call.ts +116 -0
- package/src/phone/handlers/voicemail.ts +179 -0
- package/src/phone/index.ts +9 -0
- package/src/presets/api-backend.ts +134 -0
- package/src/presets/data-pipeline.ts +204 -0
- package/src/presets/extend.test.ts +295 -0
- package/src/presets/extend.ts +297 -0
- package/src/presets/fullstack-app.ts +144 -0
- package/src/presets/index.ts +27 -0
- package/src/presets/jamstack.ts +135 -0
- package/src/presets/microservices.ts +167 -0
- package/src/presets/ml-api.ts +208 -0
- package/src/presets/nodejs-server.ts +104 -0
- package/src/presets/nodejs-serverless.ts +114 -0
- package/src/presets/realtime-app.ts +184 -0
- package/src/presets/static-site.ts +64 -0
- package/src/presets/traditional-web-app.ts +339 -0
- package/src/presets/wordpress.ts +138 -0
- package/src/preview/github.test.ts +249 -0
- package/src/preview/github.ts +297 -0
- package/src/preview/index.ts +37 -0
- package/src/preview/manager.test.ts +440 -0
- package/src/preview/manager.ts +326 -0
- package/src/preview/notifications.test.ts +582 -0
- package/src/preview/notifications.ts +341 -0
- package/src/queue/batch-processing.ts +402 -0
- package/src/queue/dlq-monitoring.ts +402 -0
- package/src/queue/fifo.ts +342 -0
- package/src/queue/index.ts +9 -0
- package/src/queue/management.ts +428 -0
- package/src/queue/queue.test.ts +429 -0
- package/src/resource-mgmt/index.ts +39 -0
- package/src/resource-naming.ts +62 -0
- package/src/s3/index.ts +523 -0
- package/src/schema/cloud-config.schema.json +554 -0
- package/src/schema/index.ts +68 -0
- package/src/security/certificate-manager.ts +492 -0
- package/src/security/index.ts +9 -0
- package/src/security/scanning.ts +545 -0
- package/src/security/secrets-manager.ts +476 -0
- package/src/security/secrets-rotation.ts +456 -0
- package/src/security/security.test.ts +738 -0
- package/src/sms/advanced/ab-testing.ts +389 -0
- package/src/sms/advanced/analytics.ts +336 -0
- package/src/sms/advanced/campaigns.ts +523 -0
- package/src/sms/advanced/chatbot.ts +224 -0
- package/src/sms/advanced/index.ts +10 -0
- package/src/sms/advanced/link-tracking.ts +248 -0
- package/src/sms/advanced/mms.ts +308 -0
- package/src/sms/handlers/__tests__/send.test.ts +40 -0
- package/src/sms/handlers/delivery-status.ts +133 -0
- package/src/sms/handlers/receive.ts +162 -0
- package/src/sms/handlers/send.ts +174 -0
- package/src/sms/index.ts +9 -0
- package/src/stack-diff.ts +389 -0
- package/src/static-site/index.ts +85 -0
- package/src/template-builder.ts +110 -0
- package/src/template-validator.ts +574 -0
- package/src/utils/cache.ts +291 -0
- package/src/utils/diff.ts +269 -0
- package/src/utils/hash.ts +227 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/parallel.ts +294 -0
- package/src/validators/credentials.test.ts +274 -0
- package/src/validators/credentials.ts +233 -0
- package/src/validators/quotas.test.ts +434 -0
- package/src/validators/quotas.ts +217 -0
- package/test/ai.test.ts +327 -0
- package/test/api.test.ts +511 -0
- package/test/auth.test.ts +632 -0
- package/test/cache.test.ts +406 -0
- package/test/cdn.test.ts +247 -0
- package/test/compute.test.ts +861 -0
- package/test/database.test.ts +523 -0
- package/test/deployment.test.ts +499 -0
- package/test/dns.test.ts +270 -0
- package/test/email.test.ts +439 -0
- package/test/filesystem.test.ts +382 -0
- package/test/integration.test.ts +350 -0
- package/test/messaging.test.ts +514 -0
- package/test/monitoring.test.ts +634 -0
- package/test/network.test.ts +425 -0
- package/test/permissions.test.ts +488 -0
- package/test/queue.test.ts +484 -0
- package/test/registry.test.ts +306 -0
- package/test/security.test.ts +462 -0
- package/test/storage.test.ts +463 -0
- package/test/template-validator.test.ts +559 -0
- package/test/workflow.test.ts +592 -0
- package/tsconfig.json +16 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,1538 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IAMPolicy,
|
|
3
|
+
IAMRole,
|
|
4
|
+
LambdaFunction,
|
|
5
|
+
LambdaPermission,
|
|
6
|
+
Route53RecordSet,
|
|
7
|
+
S3Bucket,
|
|
8
|
+
SESConfigurationSet,
|
|
9
|
+
SESEmailIdentity,
|
|
10
|
+
SESReceiptRule,
|
|
11
|
+
SESReceiptRuleSet,
|
|
12
|
+
} from '@stacksjs/ts-cloud-aws-types'
|
|
13
|
+
import type { EnvironmentType } from '@stacksjs/ts-cloud-types'
|
|
14
|
+
import { Fn } from '../intrinsic-functions'
|
|
15
|
+
import { generateLogicalId, generateResourceName } from '../resource-naming'
|
|
16
|
+
|
|
17
|
+
export interface EmailIdentityOptions {
|
|
18
|
+
domain: string
|
|
19
|
+
slug: string
|
|
20
|
+
environment: EnvironmentType
|
|
21
|
+
enableDkim?: boolean
|
|
22
|
+
dkimKeyLength?: 'RSA_1024_BIT' | 'RSA_2048_BIT'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConfigurationSetOptions {
|
|
26
|
+
slug: string
|
|
27
|
+
environment: EnvironmentType
|
|
28
|
+
name?: string
|
|
29
|
+
reputationMetrics?: boolean
|
|
30
|
+
sendingEnabled?: boolean
|
|
31
|
+
suppressBounces?: boolean
|
|
32
|
+
suppressComplaints?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ReceiptRuleSetOptions {
|
|
36
|
+
slug: string
|
|
37
|
+
environment: EnvironmentType
|
|
38
|
+
name?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReceiptRuleOptions {
|
|
42
|
+
slug: string
|
|
43
|
+
environment: EnvironmentType
|
|
44
|
+
ruleSetName: string
|
|
45
|
+
recipients?: string[]
|
|
46
|
+
enabled?: boolean
|
|
47
|
+
scanEnabled?: boolean
|
|
48
|
+
tlsPolicy?: 'Optional' | 'Require'
|
|
49
|
+
s3Action?: {
|
|
50
|
+
bucketName: string
|
|
51
|
+
prefix?: string
|
|
52
|
+
kmsKeyArn?: string
|
|
53
|
+
}
|
|
54
|
+
lambdaAction?: {
|
|
55
|
+
functionArn: string
|
|
56
|
+
invocationType?: 'Event' | 'RequestResponse'
|
|
57
|
+
}
|
|
58
|
+
snsAction?: {
|
|
59
|
+
topicArn: string
|
|
60
|
+
encoding?: 'UTF-8' | 'Base64'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Email Module - SES (Simple Email Service)
|
|
66
|
+
* Provides clean API for email sending, receiving, and domain verification
|
|
67
|
+
*/
|
|
68
|
+
export class Email {
|
|
69
|
+
/**
|
|
70
|
+
* Verify a domain for sending emails
|
|
71
|
+
*/
|
|
72
|
+
static verifyDomain(options: EmailIdentityOptions): {
|
|
73
|
+
emailIdentity: SESEmailIdentity
|
|
74
|
+
logicalId: string
|
|
75
|
+
} {
|
|
76
|
+
const {
|
|
77
|
+
domain,
|
|
78
|
+
slug,
|
|
79
|
+
environment,
|
|
80
|
+
enableDkim = true,
|
|
81
|
+
dkimKeyLength = 'RSA_2048_BIT',
|
|
82
|
+
} = options
|
|
83
|
+
|
|
84
|
+
const resourceName = generateResourceName({
|
|
85
|
+
slug,
|
|
86
|
+
environment,
|
|
87
|
+
resourceType: 'ses-identity',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const logicalId = generateLogicalId(`${resourceName}-${domain.replace(/\./g, '')}`)
|
|
91
|
+
|
|
92
|
+
const emailIdentity: SESEmailIdentity = {
|
|
93
|
+
Type: 'AWS::SES::EmailIdentity',
|
|
94
|
+
Properties: {
|
|
95
|
+
EmailIdentity: domain,
|
|
96
|
+
FeedbackAttributes: {
|
|
97
|
+
EmailForwardingEnabled: true,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (enableDkim) {
|
|
103
|
+
emailIdentity.Properties.DkimSigningAttributes = {
|
|
104
|
+
NextSigningKeyLength: dkimKeyLength,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { emailIdentity, logicalId }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create DNS records for DKIM verification
|
|
113
|
+
* Returns Route53 RecordSets for DKIM tokens
|
|
114
|
+
*/
|
|
115
|
+
static createDkimRecords(
|
|
116
|
+
domain: string,
|
|
117
|
+
dkimTokens: string[],
|
|
118
|
+
hostedZoneId: string,
|
|
119
|
+
): Array<{ record: Route53RecordSet, logicalId: string }> {
|
|
120
|
+
const records: Array<{ record: Route53RecordSet, logicalId: string }> = []
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < dkimTokens.length; i++) {
|
|
123
|
+
const token = dkimTokens[i]
|
|
124
|
+
const logicalId = generateLogicalId(`dkim-${domain.replace(/\./g, '')}-${i + 1}`)
|
|
125
|
+
|
|
126
|
+
const record: Route53RecordSet = {
|
|
127
|
+
Type: 'AWS::Route53::RecordSet',
|
|
128
|
+
Properties: {
|
|
129
|
+
HostedZoneId: hostedZoneId,
|
|
130
|
+
Name: `${token}._domainkey.${domain}`,
|
|
131
|
+
Type: 'CNAME',
|
|
132
|
+
TTL: 1800,
|
|
133
|
+
ResourceRecords: [`${token}.dkim.amazonses.com`],
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
records.push({ record, logicalId })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return records
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create SES Configuration Set
|
|
145
|
+
*/
|
|
146
|
+
static createConfigurationSet(options: ConfigurationSetOptions): {
|
|
147
|
+
configurationSet: SESConfigurationSet
|
|
148
|
+
logicalId: string
|
|
149
|
+
} {
|
|
150
|
+
const {
|
|
151
|
+
slug,
|
|
152
|
+
environment,
|
|
153
|
+
name,
|
|
154
|
+
reputationMetrics = true,
|
|
155
|
+
sendingEnabled = true,
|
|
156
|
+
suppressBounces = true,
|
|
157
|
+
suppressComplaints = true,
|
|
158
|
+
} = options
|
|
159
|
+
|
|
160
|
+
const resourceName = name || generateResourceName({
|
|
161
|
+
slug,
|
|
162
|
+
environment,
|
|
163
|
+
resourceType: 'ses-config',
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const logicalId = generateLogicalId(resourceName)
|
|
167
|
+
|
|
168
|
+
const suppressedReasons: ('BOUNCE' | 'COMPLAINT')[] = []
|
|
169
|
+
if (suppressBounces)
|
|
170
|
+
suppressedReasons.push('BOUNCE')
|
|
171
|
+
if (suppressComplaints)
|
|
172
|
+
suppressedReasons.push('COMPLAINT')
|
|
173
|
+
|
|
174
|
+
const configurationSet: SESConfigurationSet = {
|
|
175
|
+
Type: 'AWS::SES::ConfigurationSet',
|
|
176
|
+
Properties: {
|
|
177
|
+
Name: resourceName,
|
|
178
|
+
ReputationOptions: {
|
|
179
|
+
ReputationMetricsEnabled: reputationMetrics,
|
|
180
|
+
},
|
|
181
|
+
SendingOptions: {
|
|
182
|
+
SendingEnabled: sendingEnabled,
|
|
183
|
+
},
|
|
184
|
+
SuppressionOptions: {
|
|
185
|
+
SuppressedReasons: suppressedReasons,
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { configurationSet, logicalId }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create Receipt Rule Set for inbound email
|
|
195
|
+
*/
|
|
196
|
+
static createReceiptRuleSet(options: ReceiptRuleSetOptions): {
|
|
197
|
+
ruleSet: SESReceiptRuleSet
|
|
198
|
+
logicalId: string
|
|
199
|
+
} {
|
|
200
|
+
const { slug, environment, name } = options
|
|
201
|
+
|
|
202
|
+
const resourceName = name || generateResourceName({
|
|
203
|
+
slug,
|
|
204
|
+
environment,
|
|
205
|
+
resourceType: 'ses-ruleset',
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const logicalId = generateLogicalId(resourceName)
|
|
209
|
+
|
|
210
|
+
const ruleSet: SESReceiptRuleSet = {
|
|
211
|
+
Type: 'AWS::SES::ReceiptRuleSet',
|
|
212
|
+
Properties: {
|
|
213
|
+
RuleSetName: resourceName,
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { ruleSet, logicalId }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create Receipt Rule for processing inbound emails
|
|
222
|
+
*/
|
|
223
|
+
static createReceiptRule(
|
|
224
|
+
ruleSetLogicalId: string,
|
|
225
|
+
options: ReceiptRuleOptions,
|
|
226
|
+
): {
|
|
227
|
+
receiptRule: SESReceiptRule
|
|
228
|
+
logicalId: string
|
|
229
|
+
} {
|
|
230
|
+
const {
|
|
231
|
+
slug,
|
|
232
|
+
environment,
|
|
233
|
+
ruleSetName,
|
|
234
|
+
recipients,
|
|
235
|
+
enabled = true,
|
|
236
|
+
scanEnabled = true,
|
|
237
|
+
tlsPolicy = 'Require',
|
|
238
|
+
s3Action,
|
|
239
|
+
lambdaAction,
|
|
240
|
+
snsAction,
|
|
241
|
+
} = options
|
|
242
|
+
|
|
243
|
+
const resourceName = generateResourceName({
|
|
244
|
+
slug,
|
|
245
|
+
environment,
|
|
246
|
+
resourceType: 'ses-rule',
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const logicalId = generateLogicalId(resourceName)
|
|
250
|
+
|
|
251
|
+
const actions: SESReceiptRule['Properties']['Rule']['Actions'] = []
|
|
252
|
+
|
|
253
|
+
if (s3Action) {
|
|
254
|
+
actions.push({
|
|
255
|
+
S3Action: {
|
|
256
|
+
BucketName: s3Action.bucketName,
|
|
257
|
+
ObjectKeyPrefix: s3Action.prefix,
|
|
258
|
+
KmsKeyArn: s3Action.kmsKeyArn,
|
|
259
|
+
},
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (lambdaAction) {
|
|
264
|
+
actions.push({
|
|
265
|
+
LambdaAction: {
|
|
266
|
+
FunctionArn: lambdaAction.functionArn,
|
|
267
|
+
InvocationType: lambdaAction.invocationType || 'Event',
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (snsAction) {
|
|
273
|
+
actions.push({
|
|
274
|
+
SNSAction: {
|
|
275
|
+
TopicArn: snsAction.topicArn,
|
|
276
|
+
Encoding: snsAction.encoding || 'UTF-8',
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const receiptRule: SESReceiptRule = {
|
|
282
|
+
Type: 'AWS::SES::ReceiptRule',
|
|
283
|
+
Properties: {
|
|
284
|
+
RuleSetName: ruleSetName,
|
|
285
|
+
Rule: {
|
|
286
|
+
Name: resourceName,
|
|
287
|
+
Enabled: enabled,
|
|
288
|
+
ScanEnabled: scanEnabled,
|
|
289
|
+
TlsPolicy: tlsPolicy,
|
|
290
|
+
Recipients: recipients,
|
|
291
|
+
Actions: actions.length > 0 ? actions : undefined,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { receiptRule, logicalId }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Create MX record for receiving emails
|
|
301
|
+
*/
|
|
302
|
+
static createMxRecord(
|
|
303
|
+
domain: string,
|
|
304
|
+
hostedZoneId: string,
|
|
305
|
+
region: string,
|
|
306
|
+
): {
|
|
307
|
+
record: Route53RecordSet
|
|
308
|
+
logicalId: string
|
|
309
|
+
} {
|
|
310
|
+
const logicalId = generateLogicalId(`mx-${domain.replace(/\./g, '')}`)
|
|
311
|
+
|
|
312
|
+
const record: Route53RecordSet = {
|
|
313
|
+
Type: 'AWS::Route53::RecordSet',
|
|
314
|
+
Properties: {
|
|
315
|
+
HostedZoneId: hostedZoneId,
|
|
316
|
+
Name: domain,
|
|
317
|
+
Type: 'MX',
|
|
318
|
+
TTL: 300,
|
|
319
|
+
ResourceRecords: [`10 inbound-smtp.${region}.amazonaws.com`],
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { record, logicalId }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Create verification TXT record
|
|
328
|
+
*/
|
|
329
|
+
static createVerificationRecord(
|
|
330
|
+
domain: string,
|
|
331
|
+
verificationToken: string,
|
|
332
|
+
hostedZoneId: string,
|
|
333
|
+
): {
|
|
334
|
+
record: Route53RecordSet
|
|
335
|
+
logicalId: string
|
|
336
|
+
} {
|
|
337
|
+
const logicalId = generateLogicalId(`verification-${domain.replace(/\./g, '')}`)
|
|
338
|
+
|
|
339
|
+
const record: Route53RecordSet = {
|
|
340
|
+
Type: 'AWS::Route53::RecordSet',
|
|
341
|
+
Properties: {
|
|
342
|
+
HostedZoneId: hostedZoneId,
|
|
343
|
+
Name: `_amazonses.${domain}`,
|
|
344
|
+
Type: 'TXT',
|
|
345
|
+
TTL: 1800,
|
|
346
|
+
ResourceRecords: [`"${verificationToken}"`],
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { record, logicalId }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Get SES SMTP credentials information
|
|
355
|
+
*/
|
|
356
|
+
static getSmtpEndpoint(region: string): string {
|
|
357
|
+
return `email-smtp.${region}.amazonaws.com`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get SES SMTP port options
|
|
362
|
+
*/
|
|
363
|
+
static readonly SmtpPorts = {
|
|
364
|
+
TLS: 587, // STARTTLS
|
|
365
|
+
SSL: 465, // SSL/TLS
|
|
366
|
+
Unencrypted: 25, // Not recommended
|
|
367
|
+
} as const
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Create SPF record for email authentication
|
|
371
|
+
*/
|
|
372
|
+
static createSpfRecord(
|
|
373
|
+
domain: string,
|
|
374
|
+
hostedZoneId: string,
|
|
375
|
+
options?: {
|
|
376
|
+
includeDomains?: string[]
|
|
377
|
+
softFail?: boolean
|
|
378
|
+
},
|
|
379
|
+
): {
|
|
380
|
+
record: Route53RecordSet
|
|
381
|
+
logicalId: string
|
|
382
|
+
} {
|
|
383
|
+
const { includeDomains = [], softFail = false } = options || {}
|
|
384
|
+
const logicalId = generateLogicalId(`spf-${domain.replace(/\./g, '')}`)
|
|
385
|
+
|
|
386
|
+
// Build SPF record
|
|
387
|
+
let spfValue = 'v=spf1 include:amazonses.com'
|
|
388
|
+
|
|
389
|
+
for (const include of includeDomains) {
|
|
390
|
+
spfValue += ` include:${include}`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
spfValue += softFail ? ' ~all' : ' -all'
|
|
394
|
+
|
|
395
|
+
const record: Route53RecordSet = {
|
|
396
|
+
Type: 'AWS::Route53::RecordSet',
|
|
397
|
+
Properties: {
|
|
398
|
+
HostedZoneId: hostedZoneId,
|
|
399
|
+
Name: domain,
|
|
400
|
+
Type: 'TXT',
|
|
401
|
+
TTL: 300,
|
|
402
|
+
ResourceRecords: [`"${spfValue}"`],
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { record, logicalId }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Create DMARC record for email authentication
|
|
411
|
+
*/
|
|
412
|
+
static createDmarcRecord(
|
|
413
|
+
domain: string,
|
|
414
|
+
hostedZoneId: string,
|
|
415
|
+
options?: {
|
|
416
|
+
policy?: 'none' | 'quarantine' | 'reject'
|
|
417
|
+
subdomainPolicy?: 'none' | 'quarantine' | 'reject'
|
|
418
|
+
percentage?: number
|
|
419
|
+
reportingEmail?: string
|
|
420
|
+
forensicEmail?: string
|
|
421
|
+
},
|
|
422
|
+
): {
|
|
423
|
+
record: Route53RecordSet
|
|
424
|
+
logicalId: string
|
|
425
|
+
} {
|
|
426
|
+
const {
|
|
427
|
+
policy = 'none',
|
|
428
|
+
subdomainPolicy,
|
|
429
|
+
percentage = 100,
|
|
430
|
+
reportingEmail,
|
|
431
|
+
forensicEmail,
|
|
432
|
+
} = options || {}
|
|
433
|
+
|
|
434
|
+
const logicalId = generateLogicalId(`dmarc-${domain.replace(/\./g, '')}`)
|
|
435
|
+
|
|
436
|
+
// Build DMARC record
|
|
437
|
+
let dmarcValue = `v=DMARC1; p=${policy}; pct=${percentage}`
|
|
438
|
+
|
|
439
|
+
if (subdomainPolicy) {
|
|
440
|
+
dmarcValue += `; sp=${subdomainPolicy}`
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (reportingEmail) {
|
|
444
|
+
dmarcValue += `; rua=mailto:${reportingEmail}`
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (forensicEmail) {
|
|
448
|
+
dmarcValue += `; ruf=mailto:${forensicEmail}`
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const record: Route53RecordSet = {
|
|
452
|
+
Type: 'AWS::Route53::RecordSet',
|
|
453
|
+
Properties: {
|
|
454
|
+
HostedZoneId: hostedZoneId,
|
|
455
|
+
Name: `_dmarc.${domain}`,
|
|
456
|
+
Type: 'TXT',
|
|
457
|
+
TTL: 300,
|
|
458
|
+
ResourceRecords: [`"${dmarcValue}"`],
|
|
459
|
+
},
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { record, logicalId }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Create complete inbound email setup
|
|
467
|
+
* Includes receipt rule set, rule, and S3 storage
|
|
468
|
+
*/
|
|
469
|
+
static createInboundEmailSetup(options: {
|
|
470
|
+
slug: string
|
|
471
|
+
environment: EnvironmentType
|
|
472
|
+
domain: string
|
|
473
|
+
s3BucketName: string
|
|
474
|
+
s3KeyPrefix?: string
|
|
475
|
+
region: string
|
|
476
|
+
hostedZoneId: string
|
|
477
|
+
lambdaFunctionArn?: string
|
|
478
|
+
snsTopicArn?: string
|
|
479
|
+
}): {
|
|
480
|
+
resources: Record<string, any>
|
|
481
|
+
outputs: {
|
|
482
|
+
ruleSetLogicalId: string
|
|
483
|
+
ruleLogicalId: string
|
|
484
|
+
mxRecordLogicalId: string
|
|
485
|
+
}
|
|
486
|
+
} {
|
|
487
|
+
const {
|
|
488
|
+
slug,
|
|
489
|
+
environment,
|
|
490
|
+
domain,
|
|
491
|
+
s3BucketName,
|
|
492
|
+
s3KeyPrefix = 'inbound/',
|
|
493
|
+
region,
|
|
494
|
+
hostedZoneId,
|
|
495
|
+
lambdaFunctionArn,
|
|
496
|
+
snsTopicArn,
|
|
497
|
+
} = options
|
|
498
|
+
|
|
499
|
+
const resources: Record<string, any> = {}
|
|
500
|
+
|
|
501
|
+
// Create receipt rule set
|
|
502
|
+
const { ruleSet, logicalId: ruleSetLogicalId } = Email.createReceiptRuleSet({
|
|
503
|
+
slug,
|
|
504
|
+
environment,
|
|
505
|
+
})
|
|
506
|
+
resources[ruleSetLogicalId] = ruleSet
|
|
507
|
+
|
|
508
|
+
// Create receipt rule
|
|
509
|
+
const { receiptRule, logicalId: ruleLogicalId } = Email.createReceiptRule(
|
|
510
|
+
ruleSetLogicalId,
|
|
511
|
+
{
|
|
512
|
+
slug,
|
|
513
|
+
environment,
|
|
514
|
+
ruleSetName: ruleSet.Properties!.RuleSetName || `${slug}-${environment}-receipt-rule-set`,
|
|
515
|
+
recipients: [domain, `@${domain}`],
|
|
516
|
+
s3Action: {
|
|
517
|
+
bucketName: s3BucketName,
|
|
518
|
+
prefix: s3KeyPrefix,
|
|
519
|
+
},
|
|
520
|
+
lambdaAction: lambdaFunctionArn ? {
|
|
521
|
+
functionArn: lambdaFunctionArn,
|
|
522
|
+
invocationType: 'Event',
|
|
523
|
+
} : undefined,
|
|
524
|
+
snsAction: snsTopicArn ? {
|
|
525
|
+
topicArn: snsTopicArn,
|
|
526
|
+
} : undefined,
|
|
527
|
+
},
|
|
528
|
+
)
|
|
529
|
+
resources[ruleLogicalId] = receiptRule
|
|
530
|
+
|
|
531
|
+
// Create MX record
|
|
532
|
+
const { record: mxRecord, logicalId: mxRecordLogicalId } = Email.createMxRecord(
|
|
533
|
+
domain,
|
|
534
|
+
hostedZoneId,
|
|
535
|
+
region,
|
|
536
|
+
)
|
|
537
|
+
resources[mxRecordLogicalId] = mxRecord
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
resources,
|
|
541
|
+
outputs: {
|
|
542
|
+
ruleSetLogicalId,
|
|
543
|
+
ruleLogicalId,
|
|
544
|
+
mxRecordLogicalId,
|
|
545
|
+
},
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Create complete email domain setup
|
|
551
|
+
* Includes domain verification, DKIM, SPF, DMARC, and optionally inbound email
|
|
552
|
+
*/
|
|
553
|
+
static createCompleteDomainSetup(options: {
|
|
554
|
+
slug: string
|
|
555
|
+
environment: EnvironmentType
|
|
556
|
+
domain: string
|
|
557
|
+
hostedZoneId: string
|
|
558
|
+
region: string
|
|
559
|
+
enableInbound?: boolean
|
|
560
|
+
inboundS3Bucket?: string
|
|
561
|
+
dmarcReportingEmail?: string
|
|
562
|
+
}): {
|
|
563
|
+
resources: Record<string, any>
|
|
564
|
+
outputs: {
|
|
565
|
+
identityLogicalId: string
|
|
566
|
+
configSetLogicalId: string
|
|
567
|
+
}
|
|
568
|
+
} {
|
|
569
|
+
const {
|
|
570
|
+
slug,
|
|
571
|
+
environment,
|
|
572
|
+
domain,
|
|
573
|
+
hostedZoneId,
|
|
574
|
+
region,
|
|
575
|
+
enableInbound = false,
|
|
576
|
+
inboundS3Bucket,
|
|
577
|
+
dmarcReportingEmail,
|
|
578
|
+
} = options
|
|
579
|
+
|
|
580
|
+
const resources: Record<string, any> = {}
|
|
581
|
+
|
|
582
|
+
// Create email identity (domain verification)
|
|
583
|
+
const { emailIdentity, logicalId: identityLogicalId } = Email.verifyDomain({
|
|
584
|
+
domain,
|
|
585
|
+
slug,
|
|
586
|
+
environment,
|
|
587
|
+
})
|
|
588
|
+
resources[identityLogicalId] = emailIdentity
|
|
589
|
+
|
|
590
|
+
// Create configuration set
|
|
591
|
+
const { configurationSet, logicalId: configSetLogicalId } = Email.createConfigurationSet({
|
|
592
|
+
slug,
|
|
593
|
+
environment,
|
|
594
|
+
})
|
|
595
|
+
resources[configSetLogicalId] = configurationSet
|
|
596
|
+
|
|
597
|
+
// Create SPF record
|
|
598
|
+
const { record: spfRecord, logicalId: spfLogicalId } = Email.createSpfRecord(
|
|
599
|
+
domain,
|
|
600
|
+
hostedZoneId,
|
|
601
|
+
)
|
|
602
|
+
resources[spfLogicalId] = spfRecord
|
|
603
|
+
|
|
604
|
+
// Create DMARC record
|
|
605
|
+
const { record: dmarcRecord, logicalId: dmarcLogicalId } = Email.createDmarcRecord(
|
|
606
|
+
domain,
|
|
607
|
+
hostedZoneId,
|
|
608
|
+
{
|
|
609
|
+
policy: 'none', // Start with monitoring
|
|
610
|
+
reportingEmail: dmarcReportingEmail || `dmarc-reports@${domain}`,
|
|
611
|
+
},
|
|
612
|
+
)
|
|
613
|
+
resources[dmarcLogicalId] = dmarcRecord
|
|
614
|
+
|
|
615
|
+
// Create inbound email setup if enabled
|
|
616
|
+
if (enableInbound && inboundS3Bucket) {
|
|
617
|
+
const inboundSetup = Email.createInboundEmailSetup({
|
|
618
|
+
slug,
|
|
619
|
+
environment,
|
|
620
|
+
domain,
|
|
621
|
+
s3BucketName: inboundS3Bucket,
|
|
622
|
+
region,
|
|
623
|
+
hostedZoneId,
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
Object.assign(resources, inboundSetup.resources)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
resources,
|
|
631
|
+
outputs: {
|
|
632
|
+
identityLogicalId,
|
|
633
|
+
configSetLogicalId,
|
|
634
|
+
},
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* SES inbound SMTP endpoints by region
|
|
640
|
+
*/
|
|
641
|
+
static readonly InboundSmtpEndpoints: Record<string, string> = {
|
|
642
|
+
'us-east-1': 'inbound-smtp.us-east-1.amazonaws.com',
|
|
643
|
+
'us-west-2': 'inbound-smtp.us-west-2.amazonaws.com',
|
|
644
|
+
'eu-west-1': 'inbound-smtp.eu-west-1.amazonaws.com',
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Check if region supports SES inbound email
|
|
649
|
+
*/
|
|
650
|
+
static supportsInboundEmail(region: string): boolean {
|
|
651
|
+
return region in Email.InboundSmtpEndpoints
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Create IAM role for email Lambda functions
|
|
656
|
+
*/
|
|
657
|
+
static createEmailLambdaRole(options: {
|
|
658
|
+
slug: string
|
|
659
|
+
environment: EnvironmentType
|
|
660
|
+
s3BucketArn: string
|
|
661
|
+
sesIdentityArn?: string
|
|
662
|
+
}): {
|
|
663
|
+
role: IAMRole
|
|
664
|
+
policy: IAMPolicy
|
|
665
|
+
roleLogicalId: string
|
|
666
|
+
policyLogicalId: string
|
|
667
|
+
} {
|
|
668
|
+
const { slug, environment, s3BucketArn, sesIdentityArn } = options
|
|
669
|
+
|
|
670
|
+
const resourceName = generateResourceName({
|
|
671
|
+
slug,
|
|
672
|
+
environment,
|
|
673
|
+
resourceType: 'email-lambda-role',
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
const roleLogicalId = generateLogicalId(resourceName)
|
|
677
|
+
const policyLogicalId = generateLogicalId(`${resourceName}-policy`)
|
|
678
|
+
|
|
679
|
+
const role: IAMRole = {
|
|
680
|
+
Type: 'AWS::IAM::Role',
|
|
681
|
+
Properties: {
|
|
682
|
+
RoleName: resourceName,
|
|
683
|
+
AssumeRolePolicyDocument: {
|
|
684
|
+
Version: '2012-10-17',
|
|
685
|
+
Statement: [
|
|
686
|
+
{
|
|
687
|
+
Effect: 'Allow',
|
|
688
|
+
Principal: {
|
|
689
|
+
Service: 'lambda.amazonaws.com',
|
|
690
|
+
},
|
|
691
|
+
Action: 'sts:AssumeRole',
|
|
692
|
+
},
|
|
693
|
+
],
|
|
694
|
+
},
|
|
695
|
+
ManagedPolicyArns: [
|
|
696
|
+
'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
|
|
697
|
+
],
|
|
698
|
+
Tags: [
|
|
699
|
+
{ Key: 'Name', Value: resourceName },
|
|
700
|
+
{ Key: 'Environment', Value: environment },
|
|
701
|
+
],
|
|
702
|
+
},
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const policyStatements: any[] = [
|
|
706
|
+
// S3 permissions for reading/writing emails
|
|
707
|
+
{
|
|
708
|
+
Effect: 'Allow',
|
|
709
|
+
Action: [
|
|
710
|
+
's3:GetObject',
|
|
711
|
+
's3:PutObject',
|
|
712
|
+
's3:DeleteObject',
|
|
713
|
+
's3:ListBucket',
|
|
714
|
+
],
|
|
715
|
+
Resource: [
|
|
716
|
+
s3BucketArn,
|
|
717
|
+
`${s3BucketArn}/*`,
|
|
718
|
+
],
|
|
719
|
+
},
|
|
720
|
+
]
|
|
721
|
+
|
|
722
|
+
// SES permissions for sending emails
|
|
723
|
+
if (sesIdentityArn) {
|
|
724
|
+
policyStatements.push({
|
|
725
|
+
Effect: 'Allow',
|
|
726
|
+
Action: [
|
|
727
|
+
'ses:SendEmail',
|
|
728
|
+
'ses:SendRawEmail',
|
|
729
|
+
],
|
|
730
|
+
Resource: sesIdentityArn,
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const policy: IAMPolicy = {
|
|
735
|
+
Type: 'AWS::IAM::Policy',
|
|
736
|
+
Properties: {
|
|
737
|
+
PolicyName: `${resourceName}-policy`,
|
|
738
|
+
PolicyDocument: {
|
|
739
|
+
Version: '2012-10-17',
|
|
740
|
+
Statement: policyStatements,
|
|
741
|
+
},
|
|
742
|
+
Roles: [Fn.Ref(roleLogicalId) as unknown as string],
|
|
743
|
+
},
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
role,
|
|
748
|
+
policy,
|
|
749
|
+
roleLogicalId,
|
|
750
|
+
policyLogicalId,
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Create Lambda function for outbound email (JSON to raw email conversion)
|
|
756
|
+
* Converts JSON email payloads to raw MIME format and sends via SES
|
|
757
|
+
*/
|
|
758
|
+
static createOutboundEmailLambda(options: {
|
|
759
|
+
slug: string
|
|
760
|
+
environment: EnvironmentType
|
|
761
|
+
roleArn: string
|
|
762
|
+
domain: string
|
|
763
|
+
configurationSetName?: string
|
|
764
|
+
timeout?: number
|
|
765
|
+
memorySize?: number
|
|
766
|
+
}): {
|
|
767
|
+
function: LambdaFunction
|
|
768
|
+
logicalId: string
|
|
769
|
+
} {
|
|
770
|
+
const {
|
|
771
|
+
slug,
|
|
772
|
+
environment,
|
|
773
|
+
roleArn,
|
|
774
|
+
domain,
|
|
775
|
+
configurationSetName,
|
|
776
|
+
timeout = 30,
|
|
777
|
+
memorySize = 256,
|
|
778
|
+
} = options
|
|
779
|
+
|
|
780
|
+
const resourceName = generateResourceName({
|
|
781
|
+
slug,
|
|
782
|
+
environment,
|
|
783
|
+
resourceType: 'outbound-email',
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
const logicalId = generateLogicalId(resourceName)
|
|
787
|
+
|
|
788
|
+
const lambdaFunction: LambdaFunction = {
|
|
789
|
+
Type: 'AWS::Lambda::Function',
|
|
790
|
+
Properties: {
|
|
791
|
+
FunctionName: resourceName,
|
|
792
|
+
Runtime: 'nodejs20.x',
|
|
793
|
+
Handler: 'index.handler',
|
|
794
|
+
Role: roleArn,
|
|
795
|
+
Timeout: timeout,
|
|
796
|
+
MemorySize: memorySize,
|
|
797
|
+
Environment: {
|
|
798
|
+
Variables: {
|
|
799
|
+
DOMAIN: domain,
|
|
800
|
+
CONFIGURATION_SET: configurationSetName || '',
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
Code: {
|
|
804
|
+
ZipFile: Email.LambdaCode.outboundEmail,
|
|
805
|
+
},
|
|
806
|
+
Tags: [
|
|
807
|
+
{ Key: 'Name', Value: resourceName },
|
|
808
|
+
{ Key: 'Environment', Value: environment },
|
|
809
|
+
{ Key: 'Purpose', Value: 'OutboundEmail' },
|
|
810
|
+
],
|
|
811
|
+
},
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return { function: lambdaFunction, logicalId }
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Create Lambda function for inbound email processing
|
|
819
|
+
* Organizes emails by From/To addresses and extracts metadata
|
|
820
|
+
*/
|
|
821
|
+
static createInboundEmailLambda(options: {
|
|
822
|
+
slug: string
|
|
823
|
+
environment: EnvironmentType
|
|
824
|
+
roleArn: string
|
|
825
|
+
s3BucketName: string
|
|
826
|
+
organizedPrefix?: string
|
|
827
|
+
timeout?: number
|
|
828
|
+
memorySize?: number
|
|
829
|
+
}): {
|
|
830
|
+
function: LambdaFunction
|
|
831
|
+
permission: LambdaPermission
|
|
832
|
+
logicalId: string
|
|
833
|
+
permissionLogicalId: string
|
|
834
|
+
} {
|
|
835
|
+
const {
|
|
836
|
+
slug,
|
|
837
|
+
environment,
|
|
838
|
+
roleArn,
|
|
839
|
+
s3BucketName,
|
|
840
|
+
organizedPrefix = 'organized/',
|
|
841
|
+
timeout = 60,
|
|
842
|
+
memorySize = 512,
|
|
843
|
+
} = options
|
|
844
|
+
|
|
845
|
+
const resourceName = generateResourceName({
|
|
846
|
+
slug,
|
|
847
|
+
environment,
|
|
848
|
+
resourceType: 'inbound-email',
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
const logicalId = generateLogicalId(resourceName)
|
|
852
|
+
const permissionLogicalId = generateLogicalId(`${resourceName}-permission`)
|
|
853
|
+
|
|
854
|
+
const lambdaFunction: LambdaFunction = {
|
|
855
|
+
Type: 'AWS::Lambda::Function',
|
|
856
|
+
Properties: {
|
|
857
|
+
FunctionName: resourceName,
|
|
858
|
+
Runtime: 'nodejs20.x',
|
|
859
|
+
Handler: 'index.handler',
|
|
860
|
+
Role: roleArn,
|
|
861
|
+
Timeout: timeout,
|
|
862
|
+
MemorySize: memorySize,
|
|
863
|
+
Environment: {
|
|
864
|
+
Variables: {
|
|
865
|
+
S3_BUCKET: s3BucketName,
|
|
866
|
+
ORGANIZED_PREFIX: organizedPrefix,
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
Code: {
|
|
870
|
+
ZipFile: Email.LambdaCode.inboundEmail,
|
|
871
|
+
},
|
|
872
|
+
Tags: [
|
|
873
|
+
{ Key: 'Name', Value: resourceName },
|
|
874
|
+
{ Key: 'Environment', Value: environment },
|
|
875
|
+
{ Key: 'Purpose', Value: 'InboundEmail' },
|
|
876
|
+
],
|
|
877
|
+
},
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Permission for SES to invoke Lambda
|
|
881
|
+
const permission: LambdaPermission = {
|
|
882
|
+
Type: 'AWS::Lambda::Permission',
|
|
883
|
+
Properties: {
|
|
884
|
+
FunctionName: Fn.Ref(logicalId) as unknown as string,
|
|
885
|
+
Action: 'lambda:InvokeFunction',
|
|
886
|
+
Principal: 'ses.amazonaws.com',
|
|
887
|
+
SourceAccount: Fn.Ref('AWS::AccountId') as unknown as string,
|
|
888
|
+
},
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
function: lambdaFunction,
|
|
893
|
+
permission,
|
|
894
|
+
logicalId,
|
|
895
|
+
permissionLogicalId,
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Create Lambda function for email conversion
|
|
901
|
+
* Converts raw MIME emails to HTML/text format
|
|
902
|
+
*/
|
|
903
|
+
static createEmailConversionLambda(options: {
|
|
904
|
+
slug: string
|
|
905
|
+
environment: EnvironmentType
|
|
906
|
+
roleArn: string
|
|
907
|
+
s3BucketName: string
|
|
908
|
+
convertedPrefix?: string
|
|
909
|
+
timeout?: number
|
|
910
|
+
memorySize?: number
|
|
911
|
+
}): {
|
|
912
|
+
function: LambdaFunction
|
|
913
|
+
logicalId: string
|
|
914
|
+
} {
|
|
915
|
+
const {
|
|
916
|
+
slug,
|
|
917
|
+
environment,
|
|
918
|
+
roleArn,
|
|
919
|
+
s3BucketName,
|
|
920
|
+
convertedPrefix = 'converted/',
|
|
921
|
+
timeout = 60,
|
|
922
|
+
memorySize = 512,
|
|
923
|
+
} = options
|
|
924
|
+
|
|
925
|
+
const resourceName = generateResourceName({
|
|
926
|
+
slug,
|
|
927
|
+
environment,
|
|
928
|
+
resourceType: 'email-conversion',
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
const logicalId = generateLogicalId(resourceName)
|
|
932
|
+
|
|
933
|
+
const lambdaFunction: LambdaFunction = {
|
|
934
|
+
Type: 'AWS::Lambda::Function',
|
|
935
|
+
Properties: {
|
|
936
|
+
FunctionName: resourceName,
|
|
937
|
+
Runtime: 'nodejs20.x',
|
|
938
|
+
Handler: 'index.handler',
|
|
939
|
+
Role: roleArn,
|
|
940
|
+
Timeout: timeout,
|
|
941
|
+
MemorySize: memorySize,
|
|
942
|
+
Environment: {
|
|
943
|
+
Variables: {
|
|
944
|
+
S3_BUCKET: s3BucketName,
|
|
945
|
+
CONVERTED_PREFIX: convertedPrefix,
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
Code: {
|
|
949
|
+
ZipFile: Email.LambdaCode.emailConversion,
|
|
950
|
+
},
|
|
951
|
+
Tags: [
|
|
952
|
+
{ Key: 'Name', Value: resourceName },
|
|
953
|
+
{ Key: 'Environment', Value: environment },
|
|
954
|
+
{ Key: 'Purpose', Value: 'EmailConversion' },
|
|
955
|
+
],
|
|
956
|
+
},
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return { function: lambdaFunction, logicalId }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Create S3 bucket notification configuration for email processing
|
|
964
|
+
*/
|
|
965
|
+
static createEmailBucketNotification(options: {
|
|
966
|
+
bucketLogicalId: string
|
|
967
|
+
lambdaArn: string
|
|
968
|
+
prefix?: string
|
|
969
|
+
suffix?: string
|
|
970
|
+
events?: string[]
|
|
971
|
+
}): {
|
|
972
|
+
notificationConfiguration: NonNullable<NonNullable<S3Bucket['Properties']>['NotificationConfiguration']>
|
|
973
|
+
} {
|
|
974
|
+
const {
|
|
975
|
+
lambdaArn,
|
|
976
|
+
prefix = 'inbound/',
|
|
977
|
+
suffix,
|
|
978
|
+
events = ['s3:ObjectCreated:*'],
|
|
979
|
+
} = options
|
|
980
|
+
|
|
981
|
+
const filter: any = {}
|
|
982
|
+
if (prefix || suffix) {
|
|
983
|
+
filter.S3Key = {
|
|
984
|
+
Rules: [],
|
|
985
|
+
}
|
|
986
|
+
if (prefix) {
|
|
987
|
+
filter.S3Key.Rules.push({ Name: 'prefix', Value: prefix })
|
|
988
|
+
}
|
|
989
|
+
if (suffix) {
|
|
990
|
+
filter.S3Key.Rules.push({ Name: 'suffix', Value: suffix })
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const notificationConfiguration = {
|
|
995
|
+
LambdaConfigurations: [
|
|
996
|
+
{
|
|
997
|
+
Event: events[0],
|
|
998
|
+
Function: lambdaArn,
|
|
999
|
+
Filter: Object.keys(filter).length > 0 ? filter : undefined,
|
|
1000
|
+
},
|
|
1001
|
+
],
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return { notificationConfiguration }
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Create Lambda permission for S3 to invoke email processing Lambda
|
|
1009
|
+
*/
|
|
1010
|
+
static createS3LambdaPermission(options: {
|
|
1011
|
+
slug: string
|
|
1012
|
+
environment: EnvironmentType
|
|
1013
|
+
lambdaLogicalId: string
|
|
1014
|
+
s3BucketArn: string
|
|
1015
|
+
}): {
|
|
1016
|
+
permission: LambdaPermission
|
|
1017
|
+
logicalId: string
|
|
1018
|
+
} {
|
|
1019
|
+
const { slug, environment, lambdaLogicalId, s3BucketArn } = options
|
|
1020
|
+
|
|
1021
|
+
const resourceName = generateResourceName({
|
|
1022
|
+
slug,
|
|
1023
|
+
environment,
|
|
1024
|
+
resourceType: 's3-lambda-permission',
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
const logicalId = generateLogicalId(resourceName)
|
|
1028
|
+
|
|
1029
|
+
const permission: LambdaPermission = {
|
|
1030
|
+
Type: 'AWS::Lambda::Permission',
|
|
1031
|
+
Properties: {
|
|
1032
|
+
FunctionName: Fn.Ref(lambdaLogicalId) as unknown as string,
|
|
1033
|
+
Action: 'lambda:InvokeFunction',
|
|
1034
|
+
Principal: 's3.amazonaws.com',
|
|
1035
|
+
SourceArn: s3BucketArn,
|
|
1036
|
+
},
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return { permission, logicalId }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Create complete email processing stack
|
|
1044
|
+
* Includes all Lambda functions, IAM roles, and S3 notifications
|
|
1045
|
+
*/
|
|
1046
|
+
static createEmailProcessingStack(options: {
|
|
1047
|
+
slug: string
|
|
1048
|
+
environment: EnvironmentType
|
|
1049
|
+
domain: string
|
|
1050
|
+
s3BucketName: string
|
|
1051
|
+
s3BucketArn: string
|
|
1052
|
+
configurationSetName?: string
|
|
1053
|
+
enableInbound?: boolean
|
|
1054
|
+
enableConversion?: boolean
|
|
1055
|
+
}): {
|
|
1056
|
+
resources: Record<string, any>
|
|
1057
|
+
outputs: {
|
|
1058
|
+
roleLogicalId: string
|
|
1059
|
+
outboundLambdaLogicalId: string
|
|
1060
|
+
inboundLambdaLogicalId?: string
|
|
1061
|
+
conversionLambdaLogicalId?: string
|
|
1062
|
+
}
|
|
1063
|
+
} {
|
|
1064
|
+
const {
|
|
1065
|
+
slug,
|
|
1066
|
+
environment,
|
|
1067
|
+
domain,
|
|
1068
|
+
s3BucketName,
|
|
1069
|
+
s3BucketArn,
|
|
1070
|
+
configurationSetName,
|
|
1071
|
+
enableInbound = true,
|
|
1072
|
+
enableConversion = true,
|
|
1073
|
+
} = options
|
|
1074
|
+
|
|
1075
|
+
const resources: Record<string, any> = {}
|
|
1076
|
+
|
|
1077
|
+
// Create IAM role
|
|
1078
|
+
const { role, policy, roleLogicalId, policyLogicalId } = Email.createEmailLambdaRole({
|
|
1079
|
+
slug,
|
|
1080
|
+
environment,
|
|
1081
|
+
s3BucketArn,
|
|
1082
|
+
sesIdentityArn: `arn:aws:ses:*:*:identity/${domain}`,
|
|
1083
|
+
})
|
|
1084
|
+
resources[roleLogicalId] = role
|
|
1085
|
+
resources[policyLogicalId] = policy
|
|
1086
|
+
|
|
1087
|
+
// Create outbound email Lambda
|
|
1088
|
+
const { function: outboundLambda, logicalId: outboundLambdaLogicalId } = Email.createOutboundEmailLambda({
|
|
1089
|
+
slug,
|
|
1090
|
+
environment,
|
|
1091
|
+
roleArn: Fn.GetAtt(roleLogicalId, 'Arn') as unknown as string,
|
|
1092
|
+
domain,
|
|
1093
|
+
configurationSetName,
|
|
1094
|
+
})
|
|
1095
|
+
resources[outboundLambdaLogicalId] = outboundLambda
|
|
1096
|
+
|
|
1097
|
+
const outputs: any = {
|
|
1098
|
+
roleLogicalId,
|
|
1099
|
+
outboundLambdaLogicalId,
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Create inbound email Lambda if enabled
|
|
1103
|
+
if (enableInbound) {
|
|
1104
|
+
const {
|
|
1105
|
+
function: inboundLambda,
|
|
1106
|
+
permission: sesPermission,
|
|
1107
|
+
logicalId: inboundLambdaLogicalId,
|
|
1108
|
+
permissionLogicalId: sesPermissionLogicalId,
|
|
1109
|
+
} = Email.createInboundEmailLambda({
|
|
1110
|
+
slug,
|
|
1111
|
+
environment,
|
|
1112
|
+
roleArn: Fn.GetAtt(roleLogicalId, 'Arn') as unknown as string,
|
|
1113
|
+
s3BucketName,
|
|
1114
|
+
})
|
|
1115
|
+
resources[inboundLambdaLogicalId] = inboundLambda
|
|
1116
|
+
resources[sesPermissionLogicalId] = sesPermission
|
|
1117
|
+
|
|
1118
|
+
// S3 permission for inbound Lambda
|
|
1119
|
+
const { permission: s3Permission, logicalId: s3PermissionLogicalId } = Email.createS3LambdaPermission({
|
|
1120
|
+
slug,
|
|
1121
|
+
environment,
|
|
1122
|
+
lambdaLogicalId: inboundLambdaLogicalId,
|
|
1123
|
+
s3BucketArn,
|
|
1124
|
+
})
|
|
1125
|
+
resources[s3PermissionLogicalId] = s3Permission
|
|
1126
|
+
|
|
1127
|
+
outputs.inboundLambdaLogicalId = inboundLambdaLogicalId
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Create conversion Lambda if enabled
|
|
1131
|
+
if (enableConversion) {
|
|
1132
|
+
const { function: conversionLambda, logicalId: conversionLambdaLogicalId } = Email.createEmailConversionLambda({
|
|
1133
|
+
slug,
|
|
1134
|
+
environment,
|
|
1135
|
+
roleArn: Fn.GetAtt(roleLogicalId, 'Arn') as unknown as string,
|
|
1136
|
+
s3BucketName,
|
|
1137
|
+
})
|
|
1138
|
+
resources[conversionLambdaLogicalId] = conversionLambda
|
|
1139
|
+
|
|
1140
|
+
outputs.conversionLambdaLogicalId = conversionLambdaLogicalId
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return { resources, outputs }
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Lambda function code for email processing
|
|
1148
|
+
*/
|
|
1149
|
+
static readonly LambdaCode = {
|
|
1150
|
+
/**
|
|
1151
|
+
* Outbound email Lambda - JSON to raw email conversion
|
|
1152
|
+
*/
|
|
1153
|
+
outboundEmail: `
|
|
1154
|
+
const { SESClient, SendRawEmailCommand } = require('@aws-sdk/client-ses');
|
|
1155
|
+
const ses = new SESClient({});
|
|
1156
|
+
|
|
1157
|
+
exports.handler = async (event) => {
|
|
1158
|
+
console.log('Processing outbound email:', JSON.stringify(event));
|
|
1159
|
+
|
|
1160
|
+
const {
|
|
1161
|
+
to,
|
|
1162
|
+
from,
|
|
1163
|
+
subject,
|
|
1164
|
+
html,
|
|
1165
|
+
text,
|
|
1166
|
+
cc,
|
|
1167
|
+
bcc,
|
|
1168
|
+
replyTo,
|
|
1169
|
+
attachments = []
|
|
1170
|
+
} = event;
|
|
1171
|
+
|
|
1172
|
+
const domain = process.env.DOMAIN;
|
|
1173
|
+
const configSet = process.env.CONFIGURATION_SET;
|
|
1174
|
+
|
|
1175
|
+
// Build MIME message
|
|
1176
|
+
const boundary = 'NextPart_' + Date.now().toString(16);
|
|
1177
|
+
const fromAddress = from || \`noreply@\${domain}\`;
|
|
1178
|
+
|
|
1179
|
+
let rawEmail = '';
|
|
1180
|
+
rawEmail += \`From: \${fromAddress}\\r\\n\`;
|
|
1181
|
+
rawEmail += \`To: \${Array.isArray(to) ? to.join(', ') : to}\\r\\n\`;
|
|
1182
|
+
if (cc) rawEmail += \`Cc: \${Array.isArray(cc) ? cc.join(', ') : cc}\\r\\n\`;
|
|
1183
|
+
if (bcc) rawEmail += \`Bcc: \${Array.isArray(bcc) ? bcc.join(', ') : bcc}\\r\\n\`;
|
|
1184
|
+
if (replyTo) rawEmail += \`Reply-To: \${replyTo}\\r\\n\`;
|
|
1185
|
+
rawEmail += \`Subject: \${subject}\\r\\n\`;
|
|
1186
|
+
rawEmail += 'MIME-Version: 1.0\\r\\n';
|
|
1187
|
+
rawEmail += \`Content-Type: multipart/mixed; boundary="\${boundary}"\\r\\n\\r\\n\`;
|
|
1188
|
+
|
|
1189
|
+
// Text/HTML content
|
|
1190
|
+
rawEmail += \`--\${boundary}\\r\\n\`;
|
|
1191
|
+
rawEmail += 'Content-Type: multipart/alternative; boundary="alt_boundary"\\r\\n\\r\\n';
|
|
1192
|
+
|
|
1193
|
+
if (text) {
|
|
1194
|
+
rawEmail += '--alt_boundary\\r\\n';
|
|
1195
|
+
rawEmail += 'Content-Type: text/plain; charset=UTF-8\\r\\n\\r\\n';
|
|
1196
|
+
rawEmail += text + '\\r\\n\\r\\n';
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
if (html) {
|
|
1200
|
+
rawEmail += '--alt_boundary\\r\\n';
|
|
1201
|
+
rawEmail += 'Content-Type: text/html; charset=UTF-8\\r\\n\\r\\n';
|
|
1202
|
+
rawEmail += html + '\\r\\n\\r\\n';
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
rawEmail += '--alt_boundary--\\r\\n';
|
|
1206
|
+
|
|
1207
|
+
// Attachments
|
|
1208
|
+
for (const attachment of attachments) {
|
|
1209
|
+
rawEmail += \`--\${boundary}\\r\\n\`;
|
|
1210
|
+
rawEmail += \`Content-Type: \${attachment.contentType || 'application/octet-stream'}; name="\${attachment.filename}"\\r\\n\`;
|
|
1211
|
+
rawEmail += 'Content-Transfer-Encoding: base64\\r\\n';
|
|
1212
|
+
rawEmail += \`Content-Disposition: attachment; filename="\${attachment.filename}"\\r\\n\\r\\n\`;
|
|
1213
|
+
rawEmail += attachment.content + '\\r\\n';
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
rawEmail += \`--\${boundary}--\\r\\n\`;
|
|
1217
|
+
|
|
1218
|
+
const params = {
|
|
1219
|
+
RawMessage: { Data: Buffer.from(rawEmail) },
|
|
1220
|
+
Source: fromAddress,
|
|
1221
|
+
Destinations: [
|
|
1222
|
+
...(Array.isArray(to) ? to : [to]),
|
|
1223
|
+
...(cc ? (Array.isArray(cc) ? cc : [cc]) : []),
|
|
1224
|
+
...(bcc ? (Array.isArray(bcc) ? bcc : [bcc]) : [])
|
|
1225
|
+
]
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
if (configSet) {
|
|
1229
|
+
params.ConfigurationSetName = configSet;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const result = await ses.send(new SendRawEmailCommand(params));
|
|
1233
|
+
|
|
1234
|
+
return {
|
|
1235
|
+
statusCode: 200,
|
|
1236
|
+
body: JSON.stringify({ messageId: result.MessageId })
|
|
1237
|
+
};
|
|
1238
|
+
};
|
|
1239
|
+
`,
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Inbound email Lambda - Email organization by From/To
|
|
1243
|
+
*/
|
|
1244
|
+
inboundEmail: `
|
|
1245
|
+
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
1246
|
+
const s3 = new S3Client({});
|
|
1247
|
+
|
|
1248
|
+
exports.handler = async (event) => {
|
|
1249
|
+
console.log('Processing inbound email:', JSON.stringify(event));
|
|
1250
|
+
|
|
1251
|
+
const bucket = process.env.S3_BUCKET;
|
|
1252
|
+
const organizedPrefix = process.env.ORGANIZED_PREFIX || 'organized/';
|
|
1253
|
+
|
|
1254
|
+
// Handle SES notification
|
|
1255
|
+
let records = [];
|
|
1256
|
+
if (event.Records) {
|
|
1257
|
+
// S3 notification
|
|
1258
|
+
records = event.Records;
|
|
1259
|
+
} else if (event.mail) {
|
|
1260
|
+
// Direct SES notification
|
|
1261
|
+
records = [{ ses: { mail: event.mail } }];
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
for (const record of records) {
|
|
1265
|
+
let mailData;
|
|
1266
|
+
let objectKey;
|
|
1267
|
+
|
|
1268
|
+
if (record.s3) {
|
|
1269
|
+
// S3 event - read the email from S3
|
|
1270
|
+
objectKey = decodeURIComponent(record.s3.object.key.replace(/\\+/g, ' '));
|
|
1271
|
+
const response = await s3.send(new GetObjectCommand({
|
|
1272
|
+
Bucket: bucket,
|
|
1273
|
+
Key: objectKey
|
|
1274
|
+
}));
|
|
1275
|
+
const rawEmail = await response.Body.transformToString();
|
|
1276
|
+
mailData = parseEmailHeaders(rawEmail);
|
|
1277
|
+
} else if (record.ses) {
|
|
1278
|
+
mailData = record.ses.mail;
|
|
1279
|
+
objectKey = record.ses.mail.messageId;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (!mailData) continue;
|
|
1283
|
+
|
|
1284
|
+
// Extract sender and recipients
|
|
1285
|
+
const from = extractEmail(mailData.commonHeaders?.from?.[0] || mailData.source || 'unknown');
|
|
1286
|
+
const to = mailData.commonHeaders?.to || mailData.destination || [];
|
|
1287
|
+
const subject = mailData.commonHeaders?.subject || 'No Subject';
|
|
1288
|
+
const date = mailData.timestamp || new Date().toISOString();
|
|
1289
|
+
|
|
1290
|
+
// Create organized paths
|
|
1291
|
+
const dateFolder = date.slice(0, 10).replace(/-/g, '/');
|
|
1292
|
+
|
|
1293
|
+
// Organize by recipient
|
|
1294
|
+
for (const recipient of to) {
|
|
1295
|
+
const recipientEmail = extractEmail(recipient);
|
|
1296
|
+
const organizedKey = \`\${organizedPrefix}by-recipient/\${recipientEmail}/\${dateFolder}/\${sanitizeFilename(subject)}_\${objectKey}\`;
|
|
1297
|
+
|
|
1298
|
+
await copyOrCreateMetadata(bucket, objectKey, organizedKey, {
|
|
1299
|
+
from,
|
|
1300
|
+
to: recipientEmail,
|
|
1301
|
+
subject,
|
|
1302
|
+
date
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Organize by sender
|
|
1307
|
+
const senderKey = \`\${organizedPrefix}by-sender/\${from}/\${dateFolder}/\${sanitizeFilename(subject)}_\${objectKey}\`;
|
|
1308
|
+
await copyOrCreateMetadata(bucket, objectKey, senderKey, {
|
|
1309
|
+
from,
|
|
1310
|
+
to: to.join(', '),
|
|
1311
|
+
subject,
|
|
1312
|
+
date
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
console.log(\`Organized email from \${from} to \${to.join(', ')}: \${subject}\`);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
return { statusCode: 200, body: 'Emails organized successfully' };
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
function parseEmailHeaders(rawEmail) {
|
|
1322
|
+
const headers = {};
|
|
1323
|
+
const lines = rawEmail.split('\\r\\n');
|
|
1324
|
+
|
|
1325
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1326
|
+
const line = lines[i];
|
|
1327
|
+
if (line === '') break; // Headers end at empty line
|
|
1328
|
+
|
|
1329
|
+
const match = line.match(/^([^:]+):\\s*(.*)$/);
|
|
1330
|
+
if (match) {
|
|
1331
|
+
const [, key, value] = match;
|
|
1332
|
+
headers[key.toLowerCase()] = value;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
return {
|
|
1337
|
+
commonHeaders: {
|
|
1338
|
+
from: headers.from ? [headers.from] : [],
|
|
1339
|
+
to: headers.to ? headers.to.split(',').map(s => s.trim()) : [],
|
|
1340
|
+
subject: headers.subject
|
|
1341
|
+
},
|
|
1342
|
+
timestamp: headers.date
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function extractEmail(str) {
|
|
1347
|
+
const match = str.match(/<([^>]+)>/);
|
|
1348
|
+
return (match ? match[1] : str).toLowerCase().trim();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function sanitizeFilename(str) {
|
|
1352
|
+
return str.replace(/[^a-zA-Z0-9-_]/g, '_').slice(0, 50);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
async function copyOrCreateMetadata(bucket, sourceKey, destKey, metadata) {
|
|
1356
|
+
try {
|
|
1357
|
+
await s3.send(new PutObjectCommand({
|
|
1358
|
+
Bucket: bucket,
|
|
1359
|
+
Key: destKey + '.json',
|
|
1360
|
+
Body: JSON.stringify({ ...metadata, sourceKey }, null, 2),
|
|
1361
|
+
ContentType: 'application/json'
|
|
1362
|
+
}));
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
console.error('Error creating metadata:', error);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
`,
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Email conversion Lambda - Raw to HTML/text
|
|
1371
|
+
*/
|
|
1372
|
+
emailConversion: `
|
|
1373
|
+
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
1374
|
+
const s3 = new S3Client({});
|
|
1375
|
+
|
|
1376
|
+
exports.handler = async (event) => {
|
|
1377
|
+
console.log('Processing email conversion:', JSON.stringify(event));
|
|
1378
|
+
|
|
1379
|
+
const bucket = process.env.S3_BUCKET;
|
|
1380
|
+
const convertedPrefix = process.env.CONVERTED_PREFIX || 'converted/';
|
|
1381
|
+
|
|
1382
|
+
for (const record of event.Records || []) {
|
|
1383
|
+
const objectKey = decodeURIComponent(record.s3.object.key.replace(/\\+/g, ' '));
|
|
1384
|
+
|
|
1385
|
+
// Get the raw email
|
|
1386
|
+
const response = await s3.send(new GetObjectCommand({
|
|
1387
|
+
Bucket: bucket,
|
|
1388
|
+
Key: objectKey
|
|
1389
|
+
}));
|
|
1390
|
+
|
|
1391
|
+
const rawEmail = await response.Body.transformToString();
|
|
1392
|
+
const parsed = parseEmail(rawEmail);
|
|
1393
|
+
|
|
1394
|
+
// Save HTML version if exists
|
|
1395
|
+
if (parsed.html) {
|
|
1396
|
+
await s3.send(new PutObjectCommand({
|
|
1397
|
+
Bucket: bucket,
|
|
1398
|
+
Key: \`\${convertedPrefix}\${objectKey}.html\`,
|
|
1399
|
+
Body: parsed.html,
|
|
1400
|
+
ContentType: 'text/html'
|
|
1401
|
+
}));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Save text version if exists
|
|
1405
|
+
if (parsed.text) {
|
|
1406
|
+
await s3.send(new PutObjectCommand({
|
|
1407
|
+
Bucket: bucket,
|
|
1408
|
+
Key: \`\${convertedPrefix}\${objectKey}.txt\`,
|
|
1409
|
+
Body: parsed.text,
|
|
1410
|
+
ContentType: 'text/plain'
|
|
1411
|
+
}));
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Save metadata
|
|
1415
|
+
await s3.send(new PutObjectCommand({
|
|
1416
|
+
Bucket: bucket,
|
|
1417
|
+
Key: \`\${convertedPrefix}\${objectKey}.json\`,
|
|
1418
|
+
Body: JSON.stringify({
|
|
1419
|
+
from: parsed.headers.from,
|
|
1420
|
+
to: parsed.headers.to,
|
|
1421
|
+
subject: parsed.headers.subject,
|
|
1422
|
+
date: parsed.headers.date,
|
|
1423
|
+
attachments: parsed.attachments.map(a => ({
|
|
1424
|
+
filename: a.filename,
|
|
1425
|
+
contentType: a.contentType,
|
|
1426
|
+
size: a.size
|
|
1427
|
+
}))
|
|
1428
|
+
}, null, 2),
|
|
1429
|
+
ContentType: 'application/json'
|
|
1430
|
+
}));
|
|
1431
|
+
|
|
1432
|
+
// Save attachments
|
|
1433
|
+
for (const attachment of parsed.attachments) {
|
|
1434
|
+
await s3.send(new PutObjectCommand({
|
|
1435
|
+
Bucket: bucket,
|
|
1436
|
+
Key: \`\${convertedPrefix}\${objectKey}/attachments/\${attachment.filename}\`,
|
|
1437
|
+
Body: Buffer.from(attachment.content, 'base64'),
|
|
1438
|
+
ContentType: attachment.contentType
|
|
1439
|
+
}));
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
console.log(\`Converted email: \${objectKey}\`);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
return { statusCode: 200, body: 'Emails converted successfully' };
|
|
1446
|
+
};
|
|
1447
|
+
|
|
1448
|
+
function parseEmail(rawEmail) {
|
|
1449
|
+
const result = {
|
|
1450
|
+
headers: {},
|
|
1451
|
+
text: '',
|
|
1452
|
+
html: '',
|
|
1453
|
+
attachments: []
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
// Split headers and body
|
|
1457
|
+
const parts = rawEmail.split('\\r\\n\\r\\n');
|
|
1458
|
+
const headerSection = parts[0];
|
|
1459
|
+
const body = parts.slice(1).join('\\r\\n\\r\\n');
|
|
1460
|
+
|
|
1461
|
+
// Parse headers
|
|
1462
|
+
const headerLines = headerSection.split('\\r\\n');
|
|
1463
|
+
let currentHeader = '';
|
|
1464
|
+
let currentValue = '';
|
|
1465
|
+
|
|
1466
|
+
for (const line of headerLines) {
|
|
1467
|
+
if (line.match(/^\\s/)) {
|
|
1468
|
+
currentValue += ' ' + line.trim();
|
|
1469
|
+
} else {
|
|
1470
|
+
if (currentHeader) {
|
|
1471
|
+
result.headers[currentHeader.toLowerCase()] = currentValue;
|
|
1472
|
+
}
|
|
1473
|
+
const match = line.match(/^([^:]+):\\s*(.*)$/);
|
|
1474
|
+
if (match) {
|
|
1475
|
+
currentHeader = match[1];
|
|
1476
|
+
currentValue = match[2];
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (currentHeader) {
|
|
1481
|
+
result.headers[currentHeader.toLowerCase()] = currentValue;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Parse body based on content type
|
|
1485
|
+
const contentType = result.headers['content-type'] || 'text/plain';
|
|
1486
|
+
|
|
1487
|
+
if (contentType.includes('multipart')) {
|
|
1488
|
+
const boundaryMatch = contentType.match(/boundary="?([^";\\s]+)"?/);
|
|
1489
|
+
if (boundaryMatch) {
|
|
1490
|
+
const boundary = boundaryMatch[1];
|
|
1491
|
+
const bodyParts = body.split('--' + boundary);
|
|
1492
|
+
|
|
1493
|
+
for (const part of bodyParts) {
|
|
1494
|
+
if (part.trim() === '' || part.trim() === '--') continue;
|
|
1495
|
+
|
|
1496
|
+
const [partHeaders, ...partBody] = part.split('\\r\\n\\r\\n');
|
|
1497
|
+
const partContent = partBody.join('\\r\\n\\r\\n');
|
|
1498
|
+
const partContentType = partHeaders.match(/Content-Type:\\s*([^;\\r\\n]+)/i)?.[1] || '';
|
|
1499
|
+
|
|
1500
|
+
if (partContentType.includes('text/plain')) {
|
|
1501
|
+
result.text = decodeContent(partContent, partHeaders);
|
|
1502
|
+
} else if (partContentType.includes('text/html')) {
|
|
1503
|
+
result.html = decodeContent(partContent, partHeaders);
|
|
1504
|
+
} else if (partHeaders.toLowerCase().includes('content-disposition: attachment')) {
|
|
1505
|
+
const filenameMatch = partHeaders.match(/filename="?([^"\\r\\n]+)"?/i);
|
|
1506
|
+
result.attachments.push({
|
|
1507
|
+
filename: filenameMatch?.[1] || 'attachment',
|
|
1508
|
+
contentType: partContentType,
|
|
1509
|
+
content: partContent.trim(),
|
|
1510
|
+
size: partContent.length
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
} else if (contentType.includes('text/html')) {
|
|
1516
|
+
result.html = body;
|
|
1517
|
+
} else {
|
|
1518
|
+
result.text = body;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
return result;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function decodeContent(content, headers) {
|
|
1525
|
+
const encoding = headers.match(/Content-Transfer-Encoding:\\s*([^\\r\\n]+)/i)?.[1]?.toLowerCase();
|
|
1526
|
+
|
|
1527
|
+
if (encoding === 'base64') {
|
|
1528
|
+
return Buffer.from(content.replace(/\\s/g, ''), 'base64').toString('utf-8');
|
|
1529
|
+
} else if (encoding === 'quoted-printable') {
|
|
1530
|
+
return content.replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
1531
|
+
.replace(/=\\r?\\n/g, '');
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
return content;
|
|
1535
|
+
}
|
|
1536
|
+
`,
|
|
1537
|
+
}
|
|
1538
|
+
}
|