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.
@@ -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
+ }