4ward 0.1.0
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/Cargo.lock +4321 -0
- package/Cargo.toml +8 -0
- package/LICENSE +201 -0
- package/README.md +119 -0
- package/bin/run.js +40 -0
- package/crates/4ward-cli/Cargo.toml +29 -0
- package/crates/4ward-cli/src/commands/alias.rs +55 -0
- package/crates/4ward-cli/src/commands/deploy.rs +267 -0
- package/crates/4ward-cli/src/commands/dns.rs +60 -0
- package/crates/4ward-cli/src/commands/init.rs +38 -0
- package/crates/4ward-cli/src/commands/keys.rs +113 -0
- package/crates/4ward-cli/src/commands/mod.rs +14 -0
- package/crates/4ward-cli/src/commands/request_prod.rs +38 -0
- package/crates/4ward-cli/src/commands/status.rs +21 -0
- package/crates/4ward-cli/src/main.rs +45 -0
- package/crates/4ward-cli/templates/template.yaml +388 -0
- package/crates/4ward-core/Cargo.toml +15 -0
- package/crates/4ward-core/src/config.rs +261 -0
- package/crates/4ward-core/src/dns.rs +126 -0
- package/crates/4ward-core/src/lib.rs +5 -0
- package/docs/assets/arch.svg +44 -0
- package/docs/assets/banner-lockup.jpg +0 -0
- package/docs/assets/icon.jpg +0 -0
- package/lambdas/api/Cargo.toml +22 -0
- package/lambdas/api/src/main.rs +360 -0
- package/lambdas/forwarder/Cargo.toml +29 -0
- package/lambdas/forwarder/src/arc.rs +258 -0
- package/lambdas/forwarder/src/main.rs +369 -0
- package/package.json +50 -0
- package/scripts/install-binary.js +30 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
AWSTemplateFormatVersion: "2010-09-09"
|
|
2
|
+
Description: "4ward - zero-idle-cost serverless email engine (SES inbound SRS forwarder + transactional API)"
|
|
3
|
+
|
|
4
|
+
Parameters:
|
|
5
|
+
ProjectName:
|
|
6
|
+
Type: String
|
|
7
|
+
Default: 4ward
|
|
8
|
+
Description: Project / stack prefix
|
|
9
|
+
DomainName:
|
|
10
|
+
Type: String
|
|
11
|
+
Description: Primary verified domain (e.g. example.com)
|
|
12
|
+
RelayDomain:
|
|
13
|
+
Type: String
|
|
14
|
+
Description: Relay From domain (defaults to DomainName)
|
|
15
|
+
Default: ""
|
|
16
|
+
AliasMapJson:
|
|
17
|
+
Type: String
|
|
18
|
+
Default: "{}"
|
|
19
|
+
Description: JSON map alias@domain -> [destinations]
|
|
20
|
+
CatchAll:
|
|
21
|
+
Type: String
|
|
22
|
+
Default: ""
|
|
23
|
+
Description: Optional fallback destination
|
|
24
|
+
BannerEnabled:
|
|
25
|
+
Type: String
|
|
26
|
+
Default: "true"
|
|
27
|
+
AllowedValues: ["true", "false"]
|
|
28
|
+
ApiEnabled:
|
|
29
|
+
Type: String
|
|
30
|
+
Default: "true"
|
|
31
|
+
AllowedValues: ["true", "false"]
|
|
32
|
+
AllowedDomains:
|
|
33
|
+
Type: String
|
|
34
|
+
Default: ""
|
|
35
|
+
Description: Comma-separated verified From domains for POST /v1/emails
|
|
36
|
+
ApiKeysPrefix:
|
|
37
|
+
Type: String
|
|
38
|
+
Default: /4ward/api-keys/
|
|
39
|
+
RetentionDays:
|
|
40
|
+
Type: Number
|
|
41
|
+
Default: 1
|
|
42
|
+
RateLimit:
|
|
43
|
+
Type: Number
|
|
44
|
+
Default: 100
|
|
45
|
+
Description: HTTP API steady-state requests/second (stage throttling)
|
|
46
|
+
Burst:
|
|
47
|
+
Type: Number
|
|
48
|
+
Default: 200
|
|
49
|
+
Description: HTTP API burst limit (stage throttling)
|
|
50
|
+
ArcSelector:
|
|
51
|
+
Type: String
|
|
52
|
+
Default: ""
|
|
53
|
+
Description: ARC seal selector (empty = no ARC sealing)
|
|
54
|
+
ArcKeySsm:
|
|
55
|
+
Type: String
|
|
56
|
+
Default: ""
|
|
57
|
+
Description: SSM SecureString parameter holding the ARC RSA private PEM (empty = no ARC sealing)
|
|
58
|
+
ForwarderS3Key:
|
|
59
|
+
Type: String
|
|
60
|
+
Default: bootstrap.zip
|
|
61
|
+
Description: S3 key for forwarder lambda zip (same bucket as template or use local packaging)
|
|
62
|
+
ApiS3Key:
|
|
63
|
+
Type: String
|
|
64
|
+
Default: bootstrap.zip
|
|
65
|
+
LambdaCodeBucket:
|
|
66
|
+
Type: String
|
|
67
|
+
Default: ""
|
|
68
|
+
Description: S3 bucket holding lambda zips; leave empty to skip S3 code (inline placeholder)
|
|
69
|
+
|
|
70
|
+
Conditions:
|
|
71
|
+
HasCodeBucket: !Not [!Equals [!Ref LambdaCodeBucket, ""]]
|
|
72
|
+
HasCatchAll: !Not [!Equals [!Ref CatchAll, ""]]
|
|
73
|
+
ApiOn: !Equals [!Ref ApiEnabled, "true"]
|
|
74
|
+
HasRelayDomain: !Not [!Equals [!Ref RelayDomain, ""]]
|
|
75
|
+
HasArc: !Not [!Equals [!Ref ArcKeySsm, ""]]
|
|
76
|
+
|
|
77
|
+
Resources:
|
|
78
|
+
RawBucket:
|
|
79
|
+
Type: AWS::S3::Bucket
|
|
80
|
+
DependsOn: S3InvokePermission
|
|
81
|
+
Properties:
|
|
82
|
+
BucketName: !Sub "${ProjectName}-raw-${AWS::AccountId}-${AWS::Region}"
|
|
83
|
+
BucketEncryption:
|
|
84
|
+
ServerSideEncryptionConfiguration:
|
|
85
|
+
- ServerSideEncryptionByDefault:
|
|
86
|
+
SSEAlgorithm: AES256
|
|
87
|
+
LifecycleConfiguration:
|
|
88
|
+
Rules:
|
|
89
|
+
- Id: ExpireRawAfter24h
|
|
90
|
+
Status: Enabled
|
|
91
|
+
ExpirationInDays: !Ref RetentionDays
|
|
92
|
+
PublicAccessBlockConfiguration:
|
|
93
|
+
BlockPublicAcls: true
|
|
94
|
+
BlockPublicPolicy: true
|
|
95
|
+
IgnorePublicAcls: true
|
|
96
|
+
RestrictPublicBuckets: true
|
|
97
|
+
NotificationConfiguration:
|
|
98
|
+
LambdaConfigurations:
|
|
99
|
+
- Event: s3:ObjectCreated:*
|
|
100
|
+
Function: !GetAtt ForwarderFunction.Arn
|
|
101
|
+
Filter:
|
|
102
|
+
S3KeyFilter:
|
|
103
|
+
Rules:
|
|
104
|
+
- Name: prefix
|
|
105
|
+
Value: inbound/
|
|
106
|
+
|
|
107
|
+
RawBucketPolicy:
|
|
108
|
+
Type: AWS::S3::BucketPolicy
|
|
109
|
+
Properties:
|
|
110
|
+
Bucket: !Sub "${ProjectName}-raw-${AWS::AccountId}-${AWS::Region}"
|
|
111
|
+
PolicyDocument:
|
|
112
|
+
Version: "2012-10-17"
|
|
113
|
+
Statement:
|
|
114
|
+
- Sid: AllowSESInboundWrite
|
|
115
|
+
Effect: Allow
|
|
116
|
+
Principal:
|
|
117
|
+
Service: ses.amazonaws.com
|
|
118
|
+
Action: s3:PutObject
|
|
119
|
+
Resource: !Sub "arn:aws:s3:::${ProjectName}-raw-${AWS::AccountId}-${AWS::Region}/*"
|
|
120
|
+
|
|
121
|
+
EmailIdentity:
|
|
122
|
+
Type: AWS::SES::EmailIdentity
|
|
123
|
+
Properties:
|
|
124
|
+
EmailIdentity: !Ref DomainName
|
|
125
|
+
DkimSigningAttributes:
|
|
126
|
+
NextSigningKeyLength: RSA_2048_BIT
|
|
127
|
+
DkimAttributes:
|
|
128
|
+
SigningEnabled: true
|
|
129
|
+
|
|
130
|
+
ForwarderRole:
|
|
131
|
+
Type: AWS::IAM::Role
|
|
132
|
+
Properties:
|
|
133
|
+
AssumeRolePolicyDocument:
|
|
134
|
+
Version: "2012-10-17"
|
|
135
|
+
Statement:
|
|
136
|
+
- Effect: Allow
|
|
137
|
+
Principal:
|
|
138
|
+
Service: lambda.amazonaws.com
|
|
139
|
+
Action: sts:AssumeRole
|
|
140
|
+
ManagedPolicyArns:
|
|
141
|
+
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
|
|
142
|
+
Policies:
|
|
143
|
+
- PolicyName: ForwarderAccess
|
|
144
|
+
PolicyDocument:
|
|
145
|
+
Version: "2012-10-17"
|
|
146
|
+
Statement:
|
|
147
|
+
- Effect: Allow
|
|
148
|
+
Action: [s3:GetObject]
|
|
149
|
+
Resource: !Sub "arn:aws:s3:::${ProjectName}-raw-${AWS::AccountId}-${AWS::Region}/*"
|
|
150
|
+
- Effect: Allow
|
|
151
|
+
Action: [ses:SendEmail, ses:SendRawEmail]
|
|
152
|
+
Resource: "*"
|
|
153
|
+
|
|
154
|
+
ApiRole:
|
|
155
|
+
Type: AWS::IAM::Role
|
|
156
|
+
Properties:
|
|
157
|
+
AssumeRolePolicyDocument:
|
|
158
|
+
Version: "2012-10-17"
|
|
159
|
+
Statement:
|
|
160
|
+
- Effect: Allow
|
|
161
|
+
Principal:
|
|
162
|
+
Service: lambda.amazonaws.com
|
|
163
|
+
Action: sts:AssumeRole
|
|
164
|
+
ManagedPolicyArns:
|
|
165
|
+
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
|
|
166
|
+
Policies:
|
|
167
|
+
- PolicyName: ApiAccess
|
|
168
|
+
PolicyDocument:
|
|
169
|
+
Version: "2012-10-17"
|
|
170
|
+
Statement:
|
|
171
|
+
- Effect: Allow
|
|
172
|
+
Action: [ses:SendEmail, ses:SendRawEmail]
|
|
173
|
+
Resource: "*"
|
|
174
|
+
- Effect: Allow
|
|
175
|
+
Action: [ssm:DescribeParameters, ssm:GetParameter]
|
|
176
|
+
Resource: "*"
|
|
177
|
+
|
|
178
|
+
ForwarderFunction:
|
|
179
|
+
Type: AWS::Lambda::Function
|
|
180
|
+
Properties:
|
|
181
|
+
FunctionName: !Sub "${ProjectName}-forwarder"
|
|
182
|
+
Architectures: [arm64]
|
|
183
|
+
Runtime: provided.al2023
|
|
184
|
+
Handler: bootstrap
|
|
185
|
+
MemorySize: 512
|
|
186
|
+
Timeout: 30
|
|
187
|
+
Role: !GetAtt ForwarderRole.Arn
|
|
188
|
+
Code:
|
|
189
|
+
S3Bucket: !If [HasCodeBucket, !Ref LambdaCodeBucket, !Ref AWS::NoValue]
|
|
190
|
+
S3Key: !If [HasCodeBucket, !Ref ForwarderS3Key, !Ref AWS::NoValue]
|
|
191
|
+
ZipFile: !If
|
|
192
|
+
- HasCodeBucket
|
|
193
|
+
- !Ref AWS::NoValue
|
|
194
|
+
- |
|
|
195
|
+
#!/bin/sh
|
|
196
|
+
echo 4ward-forwarder-placeholder
|
|
197
|
+
Environment:
|
|
198
|
+
Variables:
|
|
199
|
+
RELAY_DOMAIN: !If [HasRelayDomain, !Ref RelayDomain, !Ref DomainName]
|
|
200
|
+
ALIAS_MAP_JSON: !Ref AliasMapJson
|
|
201
|
+
CATCH_ALL: !Ref CatchAll
|
|
202
|
+
BANNER_ENABLED: !Ref BannerEnabled
|
|
203
|
+
CONFIG_SET: !Ref MailConfigSet
|
|
204
|
+
ARC_SELECTOR: !If [HasArc, !Ref ArcSelector, !Ref "AWS::NoValue"]
|
|
205
|
+
ARC_PRIVATE_KEY: !If
|
|
206
|
+
- HasArc
|
|
207
|
+
- !Sub "{{resolve:ssm-secure:${ArcKeySsm}:1}}"
|
|
208
|
+
- !Ref "AWS::NoValue"
|
|
209
|
+
RUST_LOG: info
|
|
210
|
+
|
|
211
|
+
ApiFunction:
|
|
212
|
+
Type: AWS::Lambda::Function
|
|
213
|
+
Condition: ApiOn
|
|
214
|
+
Properties:
|
|
215
|
+
FunctionName: !Sub "${ProjectName}-api"
|
|
216
|
+
Architectures: [arm64]
|
|
217
|
+
Runtime: provided.al2023
|
|
218
|
+
Handler: bootstrap
|
|
219
|
+
MemorySize: 512
|
|
220
|
+
Timeout: 30
|
|
221
|
+
Role: !GetAtt ApiRole.Arn
|
|
222
|
+
Code:
|
|
223
|
+
S3Bucket: !If [HasCodeBucket, !Ref LambdaCodeBucket, !Ref AWS::NoValue]
|
|
224
|
+
S3Key: !If [HasCodeBucket, !Ref ApiS3Key, !Ref AWS::NoValue]
|
|
225
|
+
ZipFile: !If
|
|
226
|
+
- HasCodeBucket
|
|
227
|
+
- !Ref AWS::NoValue
|
|
228
|
+
- |
|
|
229
|
+
#!/bin/sh
|
|
230
|
+
echo 4ward-api-placeholder
|
|
231
|
+
Environment:
|
|
232
|
+
Variables:
|
|
233
|
+
ALLOWED_DOMAINS: !Ref AllowedDomains
|
|
234
|
+
API_KEYS_PREFIX: !Ref ApiKeysPrefix
|
|
235
|
+
CONFIG_SET: !Ref MailConfigSet
|
|
236
|
+
RUST_LOG: info
|
|
237
|
+
|
|
238
|
+
S3InvokePermission:
|
|
239
|
+
Type: AWS::Lambda::Permission
|
|
240
|
+
Properties:
|
|
241
|
+
FunctionName: !GetAtt ForwarderFunction.Arn
|
|
242
|
+
Action: lambda:InvokeFunction
|
|
243
|
+
Principal: s3.amazonaws.com
|
|
244
|
+
SourceArn: !Sub "arn:aws:s3:::${ProjectName}-raw-${AWS::AccountId}-${AWS::Region}"
|
|
245
|
+
|
|
246
|
+
ReceiptRuleSet:
|
|
247
|
+
Type: AWS::SES::ReceiptRuleSet
|
|
248
|
+
Properties:
|
|
249
|
+
Name: !Sub "${ProjectName}-inbound"
|
|
250
|
+
|
|
251
|
+
ReceiptRule:
|
|
252
|
+
Type: AWS::SES::ReceiptRule
|
|
253
|
+
Properties:
|
|
254
|
+
RuleSetName: !Ref ReceiptRuleSet
|
|
255
|
+
Rule:
|
|
256
|
+
Name: !Sub "${ProjectName}-forward"
|
|
257
|
+
Enabled: true
|
|
258
|
+
ScanEnabled: true
|
|
259
|
+
Recipients:
|
|
260
|
+
- !Ref DomainName
|
|
261
|
+
Actions:
|
|
262
|
+
# S3 only: the S3 ObjectCreated event triggers the forwarder.
|
|
263
|
+
# (A LambdaAction here would double-invoke with a non-S3 event.)
|
|
264
|
+
- S3Action:
|
|
265
|
+
BucketName: !Ref RawBucket
|
|
266
|
+
ObjectKeyPrefix: inbound/
|
|
267
|
+
|
|
268
|
+
HttpApi:
|
|
269
|
+
Type: AWS::ApiGatewayV2::Api
|
|
270
|
+
Condition: ApiOn
|
|
271
|
+
Properties:
|
|
272
|
+
Name: !Sub "${ProjectName}-api"
|
|
273
|
+
ProtocolType: HTTP
|
|
274
|
+
CorsConfiguration:
|
|
275
|
+
AllowOrigins: ["*"]
|
|
276
|
+
AllowMethods: [POST, OPTIONS]
|
|
277
|
+
AllowHeaders: [authorization, content-type]
|
|
278
|
+
|
|
279
|
+
HttpStage:
|
|
280
|
+
Type: AWS::ApiGatewayV2::Stage
|
|
281
|
+
Condition: ApiOn
|
|
282
|
+
Properties:
|
|
283
|
+
ApiId: !Ref HttpApi
|
|
284
|
+
StageName: $default
|
|
285
|
+
AutoDeploy: true
|
|
286
|
+
DefaultRouteSettings:
|
|
287
|
+
ThrottlingBurstLimit: !Ref Burst
|
|
288
|
+
ThrottlingRateLimit: !Ref RateLimit
|
|
289
|
+
|
|
290
|
+
HttpIntegration:
|
|
291
|
+
Type: AWS::ApiGatewayV2::Integration
|
|
292
|
+
Condition: ApiOn
|
|
293
|
+
Properties:
|
|
294
|
+
ApiId: !Ref HttpApi
|
|
295
|
+
IntegrationType: AWS_PROXY
|
|
296
|
+
IntegrationUri: !GetAtt ApiFunction.Arn
|
|
297
|
+
PayloadFormatVersion: "2.0"
|
|
298
|
+
|
|
299
|
+
HttpRoute:
|
|
300
|
+
Type: AWS::ApiGatewayV2::Route
|
|
301
|
+
Condition: ApiOn
|
|
302
|
+
Properties:
|
|
303
|
+
ApiId: !Ref HttpApi
|
|
304
|
+
RouteKey: POST /v1/emails
|
|
305
|
+
Target: !Sub "integrations/${HttpIntegration}"
|
|
306
|
+
|
|
307
|
+
ApiInvokePermission:
|
|
308
|
+
Type: AWS::Lambda::Permission
|
|
309
|
+
Condition: ApiOn
|
|
310
|
+
Properties:
|
|
311
|
+
FunctionName: !Ref ApiFunction
|
|
312
|
+
Action: lambda:InvokeFunction
|
|
313
|
+
Principal: apigateway.amazonaws.com
|
|
314
|
+
SourceArn: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*/*"
|
|
315
|
+
|
|
316
|
+
BounceTopic:
|
|
317
|
+
Type: AWS::SNS::Topic
|
|
318
|
+
Properties:
|
|
319
|
+
TopicName: !Sub "${ProjectName}-bounces"
|
|
320
|
+
|
|
321
|
+
ComplaintTopic:
|
|
322
|
+
Type: AWS::SNS::Topic
|
|
323
|
+
Properties:
|
|
324
|
+
TopicName: !Sub "${ProjectName}-complaints"
|
|
325
|
+
|
|
326
|
+
MailConfigSet:
|
|
327
|
+
Type: AWS::SES::ConfigurationSet
|
|
328
|
+
Properties:
|
|
329
|
+
Name: !Sub "${ProjectName}-mail"
|
|
330
|
+
|
|
331
|
+
BounceEventDestination:
|
|
332
|
+
Type: AWS::SES::ConfigurationSetEventDestination
|
|
333
|
+
Properties:
|
|
334
|
+
ConfigurationSetName: !Ref MailConfigSet
|
|
335
|
+
EventDestination:
|
|
336
|
+
Name: bounce-to-sns
|
|
337
|
+
Enabled: true
|
|
338
|
+
MatchingEventTypes: [bounce]
|
|
339
|
+
SnsDestination:
|
|
340
|
+
TopicARN: !Ref BounceTopic
|
|
341
|
+
|
|
342
|
+
ComplaintEventDestination:
|
|
343
|
+
Type: AWS::SES::ConfigurationSetEventDestination
|
|
344
|
+
Properties:
|
|
345
|
+
ConfigurationSetName: !Ref MailConfigSet
|
|
346
|
+
EventDestination:
|
|
347
|
+
Name: complaint-to-sns
|
|
348
|
+
Enabled: true
|
|
349
|
+
MatchingEventTypes: [complaint]
|
|
350
|
+
SnsDestination:
|
|
351
|
+
TopicARN: !Ref ComplaintTopic
|
|
352
|
+
|
|
353
|
+
BounceAlarm:
|
|
354
|
+
Type: AWS::CloudWatch::Alarm
|
|
355
|
+
Properties:
|
|
356
|
+
AlarmName: !Sub "${ProjectName}-bounce-rate"
|
|
357
|
+
Namespace: AWS/SES
|
|
358
|
+
MetricName: Reputation.BounceRate
|
|
359
|
+
Statistic: Average
|
|
360
|
+
Period: 300
|
|
361
|
+
EvaluationPeriods: 1
|
|
362
|
+
Threshold: 0.05
|
|
363
|
+
ComparisonOperator: GreaterThanThreshold
|
|
364
|
+
Dimensions: []
|
|
365
|
+
|
|
366
|
+
ComplaintAlarm:
|
|
367
|
+
Type: AWS::CloudWatch::Alarm
|
|
368
|
+
Properties:
|
|
369
|
+
AlarmName: !Sub "${ProjectName}-complaint-rate"
|
|
370
|
+
Namespace: AWS/SES
|
|
371
|
+
MetricName: Reputation.ComplaintRate
|
|
372
|
+
Statistic: Average
|
|
373
|
+
Period: 300
|
|
374
|
+
EvaluationPeriods: 1
|
|
375
|
+
Threshold: 0.001
|
|
376
|
+
ComparisonOperator: GreaterThanThreshold
|
|
377
|
+
Dimensions: []
|
|
378
|
+
|
|
379
|
+
Outputs:
|
|
380
|
+
RawBucketName:
|
|
381
|
+
Value: !Ref RawBucket
|
|
382
|
+
ForwarderName:
|
|
383
|
+
Value: !Ref ForwarderFunction
|
|
384
|
+
ApiEndpoint:
|
|
385
|
+
Condition: ApiOn
|
|
386
|
+
Value: !Sub "https://${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}"
|
|
387
|
+
DkimNote:
|
|
388
|
+
Value: Fetch DKIM CNAME tokens via SES console or `4ward dns` after deploy.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "fourward-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "Domain logic, models and configuration schemas for 4ward"
|
|
6
|
+
|
|
7
|
+
[lib]
|
|
8
|
+
name = "fourward_core"
|
|
9
|
+
path = "src/lib.rs"
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
serde = { version = "1", features = ["derive"] }
|
|
13
|
+
serde_json = "1"
|
|
14
|
+
thiserror = "2"
|
|
15
|
+
validator = { version = "0.20", features = ["derive"] }
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use std::collections::HashMap;
|
|
3
|
+
use thiserror::Error;
|
|
4
|
+
|
|
5
|
+
fn default_profile() -> String {
|
|
6
|
+
"default".to_string()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
fn default_true() -> bool {
|
|
10
|
+
true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
fn default_retention() -> u32 {
|
|
14
|
+
1
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn default_sender_format() -> String {
|
|
18
|
+
"{name} (via {alias}) <relay@{domain}>".to_string()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[derive(Debug, Error)]
|
|
22
|
+
pub enum ConfigError {
|
|
23
|
+
#[error("failed to parse config: {0}")]
|
|
24
|
+
Parse(#[from] serde_json::Error),
|
|
25
|
+
#[error("validation error: {0}")]
|
|
26
|
+
Validation(String),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
30
|
+
pub struct FourwardConfig {
|
|
31
|
+
#[serde(default = "default_version")]
|
|
32
|
+
pub version: String,
|
|
33
|
+
pub project: String,
|
|
34
|
+
pub aws: AwsConfig,
|
|
35
|
+
pub api: ApiConfig,
|
|
36
|
+
#[serde(default)]
|
|
37
|
+
pub settings: EngineSettings,
|
|
38
|
+
pub domains: Vec<DomainConfig>,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fn default_version() -> String {
|
|
42
|
+
"1".to_string()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
46
|
+
pub struct AwsConfig {
|
|
47
|
+
pub region: String,
|
|
48
|
+
#[serde(default = "default_profile")]
|
|
49
|
+
pub profile: String,
|
|
50
|
+
pub stack_name: String,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
54
|
+
pub struct ApiConfig {
|
|
55
|
+
#[serde(default = "default_true")]
|
|
56
|
+
pub enabled: bool,
|
|
57
|
+
#[serde(default)]
|
|
58
|
+
pub cors: Vec<String>,
|
|
59
|
+
#[serde(default)]
|
|
60
|
+
pub rate_limit: RateLimitConfig,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
|
64
|
+
pub struct RateLimitConfig {
|
|
65
|
+
#[serde(default = "default_rps")]
|
|
66
|
+
pub requests_per_second: u32,
|
|
67
|
+
#[serde(default = "default_burst")]
|
|
68
|
+
pub burst: u32,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn default_rps() -> u32 {
|
|
72
|
+
100
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fn default_burst() -> u32 {
|
|
76
|
+
200
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
80
|
+
pub struct EngineSettings {
|
|
81
|
+
#[serde(default = "default_true")]
|
|
82
|
+
pub banner_enabled: bool,
|
|
83
|
+
#[serde(default = "default_retention")]
|
|
84
|
+
pub retention_days: u32,
|
|
85
|
+
#[serde(default = "default_sender_format")]
|
|
86
|
+
pub sender_format: String,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
impl Default for EngineSettings {
|
|
90
|
+
fn default() -> Self {
|
|
91
|
+
Self {
|
|
92
|
+
banner_enabled: true,
|
|
93
|
+
retention_days: 1,
|
|
94
|
+
sender_format: default_sender_format(),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
|
|
100
|
+
#[serde(rename_all = "lowercase")]
|
|
101
|
+
pub enum DnsProvider {
|
|
102
|
+
#[default]
|
|
103
|
+
Route53,
|
|
104
|
+
External,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
108
|
+
pub struct DomainConfig {
|
|
109
|
+
pub domain: String,
|
|
110
|
+
#[serde(default)]
|
|
111
|
+
pub dns_provider: DnsProvider,
|
|
112
|
+
pub catch_all: Option<String>,
|
|
113
|
+
#[serde(default)]
|
|
114
|
+
pub routes: HashMap<String, Vec<String>>,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
impl FourwardConfig {
|
|
118
|
+
pub fn from_json(s: &str) -> Result<Self, ConfigError> {
|
|
119
|
+
let cfg: Self = serde_json::from_str(s)?;
|
|
120
|
+
cfg.validate()?;
|
|
121
|
+
Ok(cfg)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
pub fn from_file_contents(s: &str) -> Result<Self, ConfigError> {
|
|
125
|
+
Self::from_json(s)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
pub fn validate(&self) -> Result<(), ConfigError> {
|
|
129
|
+
if self.project.trim().is_empty() {
|
|
130
|
+
return Err(ConfigError::Validation("project must not be empty".into()));
|
|
131
|
+
}
|
|
132
|
+
if self.domains.is_empty() {
|
|
133
|
+
return Err(ConfigError::Validation(
|
|
134
|
+
"at least one domain is required".into(),
|
|
135
|
+
));
|
|
136
|
+
}
|
|
137
|
+
for d in &self.domains {
|
|
138
|
+
if !d.domain.contains('.') {
|
|
139
|
+
return Err(ConfigError::Validation(format!(
|
|
140
|
+
"invalid domain: {}",
|
|
141
|
+
d.domain
|
|
142
|
+
)));
|
|
143
|
+
}
|
|
144
|
+
for (alias, dests) in &d.routes {
|
|
145
|
+
if alias.trim().is_empty() {
|
|
146
|
+
return Err(ConfigError::Validation("alias must not be empty".into()));
|
|
147
|
+
}
|
|
148
|
+
if dests.is_empty() {
|
|
149
|
+
return Err(ConfigError::Validation(format!(
|
|
150
|
+
"alias '{alias}' must have at least one destination"
|
|
151
|
+
)));
|
|
152
|
+
}
|
|
153
|
+
for dest in dests {
|
|
154
|
+
if !dest.contains('@') {
|
|
155
|
+
return Err(ConfigError::Validation(format!(
|
|
156
|
+
"invalid destination email: {dest}"
|
|
157
|
+
)));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if let Some(catch) = &d.catch_all {
|
|
162
|
+
if !catch.contains('@') {
|
|
163
|
+
return Err(ConfigError::Validation(format!(
|
|
164
|
+
"invalid catch_all email: {catch}"
|
|
165
|
+
)));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
Ok(())
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Resolve destinations for `alias@domain`. Falls back to catch-all.
|
|
173
|
+
pub fn resolve(&self, alias: &str, domain: &str) -> Vec<String> {
|
|
174
|
+
for d in &self.domains {
|
|
175
|
+
if d.domain.eq_ignore_ascii_case(domain) {
|
|
176
|
+
if let Some(dests) = d.routes.get(alias) {
|
|
177
|
+
return dests.clone();
|
|
178
|
+
}
|
|
179
|
+
// case-insensitive alias lookup
|
|
180
|
+
for (k, v) in &d.routes {
|
|
181
|
+
if k.eq_ignore_ascii_case(alias) {
|
|
182
|
+
return v.clone();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if let Some(catch) = &d.catch_all {
|
|
186
|
+
return vec![catch.clone()];
|
|
187
|
+
}
|
|
188
|
+
return vec![];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
vec![]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Render the `From:` display address for a relayed message.
|
|
195
|
+
pub fn render_sender(&self, sender_name: &str, alias: &str, domain: &str) -> String {
|
|
196
|
+
let name = if sender_name.trim().is_empty() {
|
|
197
|
+
alias.to_string()
|
|
198
|
+
} else {
|
|
199
|
+
sender_name.to_string()
|
|
200
|
+
};
|
|
201
|
+
self.settings
|
|
202
|
+
.sender_format
|
|
203
|
+
.replace("{name}", &name)
|
|
204
|
+
.replace("{alias}", alias)
|
|
205
|
+
.replace("{domain}", domain)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#[cfg(test)]
|
|
210
|
+
mod tests {
|
|
211
|
+
use super::*;
|
|
212
|
+
|
|
213
|
+
fn sample() -> &'static str {
|
|
214
|
+
r#"{
|
|
215
|
+
"version": "1",
|
|
216
|
+
"project": "demo",
|
|
217
|
+
"aws": {"region": "us-east-1", "profile": "default", "stack_name": "fourward-demo"},
|
|
218
|
+
"api": {"enabled": true, "cors": ["*"], "rate_limit": {"requests_per_second": 100, "burst": 200}},
|
|
219
|
+
"settings": {"banner_enabled": true, "retention_days": 1, "sender_format": "{name} (via {alias}) <relay@{domain}>"},
|
|
220
|
+
"domains": [
|
|
221
|
+
{"domain": "example.com", "dns_provider": "route53", "catch_all": "me@gmail.com", "routes": {"support": ["a@x.com", "b@x.com"]}}
|
|
222
|
+
]
|
|
223
|
+
}"#
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#[test]
|
|
227
|
+
fn parses_and_validates() {
|
|
228
|
+
let cfg = FourwardConfig::from_json(sample()).unwrap();
|
|
229
|
+
assert_eq!(cfg.project, "demo");
|
|
230
|
+
assert_eq!(cfg.domains.len(), 1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn rejects_empty_domains() {
|
|
235
|
+
let mut cfg = FourwardConfig::from_json(sample()).unwrap();
|
|
236
|
+
cfg.domains.clear();
|
|
237
|
+
assert!(cfg.validate().is_err());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#[test]
|
|
241
|
+
fn resolves_routes_and_catch_all() {
|
|
242
|
+
let cfg = FourwardConfig::from_json(sample()).unwrap();
|
|
243
|
+
assert_eq!(
|
|
244
|
+
cfg.resolve("support", "example.com"),
|
|
245
|
+
vec!["a@x.com".to_string(), "b@x.com".to_string()]
|
|
246
|
+
);
|
|
247
|
+
assert_eq!(
|
|
248
|
+
cfg.resolve("unknown", "example.com"),
|
|
249
|
+
vec!["me@gmail.com".to_string()]
|
|
250
|
+
);
|
|
251
|
+
assert!(cfg.resolve("support", "other.com").is_empty());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#[test]
|
|
255
|
+
fn renders_sender() {
|
|
256
|
+
let cfg = FourwardConfig::from_json(sample()).unwrap();
|
|
257
|
+
let from = cfg.render_sender("Acme", "support", "example.com");
|
|
258
|
+
assert!(from.contains("relay@example.com"));
|
|
259
|
+
assert!(from.contains("via support"));
|
|
260
|
+
}
|
|
261
|
+
}
|