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,126 @@
1
+ //! DNS record generation for SES verification, reception and deliverability.
2
+
3
+ use serde::{Deserialize, Serialize};
4
+
5
+ #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
6
+ pub struct DnsRecord {
7
+ /// e.g. "MX", "TXT", "CNAME"
8
+ pub record_type: String,
9
+ /// Host / name (relative `@` means apex).
10
+ pub name: String,
11
+ /// Record value / rdata.
12
+ pub value: String,
13
+ pub ttl: u32,
14
+ pub purpose: String,
15
+ }
16
+
17
+ /// MX endpoint for SES inbound in a region.
18
+ pub fn inbound_mx(region: &str) -> String {
19
+ format!("10 inbound-smtp.{region}.amazonaws.com")
20
+ }
21
+
22
+ /// SPF value authorizing SES for the domain.
23
+ pub fn spf_value() -> String {
24
+ "v=spf1 include:amazonses.com ~all".to_string()
25
+ }
26
+
27
+ /// DMARC record value (reporting-only default).
28
+ pub fn dmarc_value(rua: &str) -> String {
29
+ format!("v=DMARC1; p=none; rua=mailto:{rua}")
30
+ }
31
+
32
+ /// DKIM CNAME records issued by SES EasyDKIM (tokens resolved at deploy time).
33
+ /// `tokens` are the 3 DkimTokens returned by SES; each maps to
34
+ /// `<token>._domainkey.<domain> CNAME <token>.dkim.amazonses.com`.
35
+ pub fn dkim_cnames(domain: &str, tokens: &[String]) -> Vec<DnsRecord> {
36
+ tokens
37
+ .iter()
38
+ .map(|t| DnsRecord {
39
+ record_type: "CNAME".to_string(),
40
+ name: format!("{t}._domainkey.{domain}"),
41
+ value: format!("{t}.dkim.amazonses.com"),
42
+ ttl: 300,
43
+ purpose: "SES EasyDKIM signing".to_string(),
44
+ })
45
+ .collect()
46
+ }
47
+
48
+ /// Full record set for an external-DNS domain (DKIM tokens optional/placeholder).
49
+ pub fn records_for_domain(domain: &str, region: &str, dkim_tokens: &[String]) -> Vec<DnsRecord> {
50
+ let mut out = vec![
51
+ DnsRecord {
52
+ record_type: "MX".to_string(),
53
+ name: domain.to_string(),
54
+ value: inbound_mx(region),
55
+ ttl: 300,
56
+ purpose: "SES inbound reception".to_string(),
57
+ },
58
+ DnsRecord {
59
+ record_type: "TXT".to_string(),
60
+ name: domain.to_string(),
61
+ value: spf_value(),
62
+ ttl: 300,
63
+ purpose: "SPF authorize SES".to_string(),
64
+ },
65
+ DnsRecord {
66
+ record_type: "TXT".to_string(),
67
+ name: format!("_dmarc.{domain}"),
68
+ value: dmarc_value(&format!("dmarc@{domain}")),
69
+ ttl: 300,
70
+ purpose: "DMARC policy".to_string(),
71
+ },
72
+ ];
73
+ out.extend(dkim_cnames(domain, dkim_tokens));
74
+ out
75
+ }
76
+
77
+ /// Placeholder DKIM tokens for `4ward dns` output before SES issues real ones.
78
+ pub fn placeholder_dkim_tokens() -> Vec<String> {
79
+ vec![
80
+ "token1".to_string(),
81
+ "token2".to_string(),
82
+ "token3".to_string(),
83
+ ]
84
+ }
85
+
86
+ /// ARC seal DNS record: same DKIM TXT format under `{selector}._domainkey`.
87
+ /// `pubkey_b64` is the base64 DER of the RSA public key (`keys arc` prints it).
88
+ pub fn arc_record(selector: &str, domain: &str, pubkey_b64: &str) -> DnsRecord {
89
+ DnsRecord {
90
+ record_type: "TXT".to_string(),
91
+ name: format!("{selector}._domainkey.{domain}"),
92
+ value: format!("v=DKIM1; k=rsa; p={pubkey_b64}"),
93
+ ttl: 300,
94
+ purpose: "4ward ARC seal signing".to_string(),
95
+ }
96
+ }
97
+
98
+ #[cfg(test)]
99
+ mod tests {
100
+ use super::*;
101
+
102
+ #[test]
103
+ fn mx_shape() {
104
+ assert_eq!(
105
+ inbound_mx("us-east-1"),
106
+ "10 inbound-smtp.us-east-1.amazonaws.com"
107
+ );
108
+ }
109
+
110
+ #[test]
111
+ fn spf_authorizes_ses() {
112
+ assert!(spf_value().contains("amazonses.com"));
113
+ }
114
+
115
+ #[test]
116
+ fn full_set_has_mx_spf_dmarc_plus_dkim() {
117
+ let recs = records_for_domain("example.com", "us-east-1", &placeholder_dkim_tokens());
118
+ assert_eq!(recs.len(), 6);
119
+ assert!(recs.iter().any(|r| r.record_type == "MX"));
120
+ assert!(recs.iter().any(|r| r.name.starts_with("_dmarc.")));
121
+ assert_eq!(
122
+ recs.iter().filter(|r| r.record_type == "CNAME").count(),
123
+ 3
124
+ );
125
+ }
126
+ }
@@ -0,0 +1,5 @@
1
+ pub mod config;
2
+ pub mod dns;
3
+
4
+ pub use config::*;
5
+ pub use dns::*;
@@ -0,0 +1,44 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 470">
2
+ <defs>
3
+ <marker id="arr" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
4
+ <path d="M0 0 L10 5 L0 10 z" fill="#FF7700"/>
5
+ </marker>
6
+ </defs>
7
+ <rect width="1280" height="470" fill="#0B0C0E"/>
8
+ <text x="60" y="52" font-family="Menlo,Consolas,monospace" font-size="22" letter-spacing="5" fill="#F5F5F7" opacity="0.6">HOW IT FLOWS</text>
9
+ <!-- inbound lane -->
10
+ <text x="60" y="118" font-family="Menlo,Consolas,monospace" font-size="24" letter-spacing="3" fill="#FF7700">INBOUND</text>
11
+ <g font-family="Arial,Helvetica,sans-serif" font-size="23" fill="#F5F5F7" text-anchor="middle">
12
+ <rect x="60" y="140" width="220" height="66" rx="10" fill="#1E2025"/>
13
+ <text x="170" y="181">SES Inbound</text>
14
+ <rect x="360" y="140" width="220" height="66" rx="10" fill="#1E2025"/>
15
+ <text x="470" y="181">S3 &#183; 24h TTL</text>
16
+ <rect x="660" y="140" width="260" height="66" rx="10" fill="#1E2025"/>
17
+ <text x="790" y="181">SRS Forwarder (arm64)</text>
18
+ <rect x="1000" y="140" width="220" height="66" rx="10" fill="#1E2025"/>
19
+ <text x="1110" y="181">Gmail / Outlook</text>
20
+ </g>
21
+ <g stroke="#FF7700" stroke-width="3">
22
+ <line x1="280" y1="173" x2="352" y2="173" marker-end="url(#arr)"/>
23
+ <line x1="580" y1="173" x2="652" y2="173" marker-end="url(#arr)"/>
24
+ <line x1="920" y1="173" x2="992" y2="173" marker-end="url(#arr)"/>
25
+ </g>
26
+ <!-- outbound lane -->
27
+ <text x="60" y="288" font-family="Menlo,Consolas,monospace" font-size="24" letter-spacing="3" fill="#FF7700">OUTBOUND</text>
28
+ <g font-family="Arial,Helvetica,sans-serif" font-size="23" fill="#F5F5F7" text-anchor="middle">
29
+ <rect x="60" y="310" width="220" height="66" rx="10" fill="#1E2025"/>
30
+ <text x="170" y="351">POST /v1/emails</text>
31
+ <rect x="360" y="310" width="260" height="66" rx="10" fill="#1E2025"/>
32
+ <text x="490" y="351">API Lambda + SSM keys</text>
33
+ <rect x="700" y="310" width="260" height="66" rx="10" fill="#1E2025"/>
34
+ <text x="830" y="351">SESv2 SendEmail</text>
35
+ <rect x="1040" y="310" width="180" height="66" rx="10" fill="#FF7700"/>
36
+ <text x="1130" y="351" fill="#0B0C0E">200 sent</text>
37
+ </g>
38
+ <g stroke="#FF7700" stroke-width="3">
39
+ <line x1="280" y1="343" x2="352" y2="343" marker-end="url(#arr)"/>
40
+ <line x1="620" y1="343" x2="692" y2="343" marker-end="url(#arr)"/>
41
+ <line x1="960" y1="343" x2="1032" y2="343" marker-end="url(#arr)"/>
42
+ </g>
43
+ <text x="60" y="430" font-family="Menlo,Consolas,monospace" font-size="20" fill="#F5F5F7" opacity="0.45">s3 + lambda + apigw + ses + cloudwatch alarms &#183; zero idle cost</text>
44
+ </svg>
Binary file
Binary file
@@ -0,0 +1,22 @@
1
+ [package]
2
+ name = "lambda-api"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [[bin]]
7
+ name = "bootstrap"
8
+ path = "src/main.rs"
9
+
10
+ [dependencies]
11
+ lambda_http = "0.14"
12
+ aws-config = "1"
13
+ aws-sdk-sesv2 = "1"
14
+ aws-sdk-ssm = "1"
15
+ serde = { version = "1", features = ["derive"] }
16
+ serde_json = "1"
17
+ tokio = { version = "1", features = ["full"] }
18
+ base64 = { version = "0.22", features = ["std"] }
19
+ mail-builder = "0.3"
20
+ tracing = "0.1"
21
+ tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
22
+ fourward-core = { path = "../../crates/4ward-core" }
@@ -0,0 +1,360 @@
1
+ use base64::{engine::general_purpose::STANDARD as B64, Engine};
2
+ use lambda_http::{run, service_fn, Body, Error, Request, RequestExt, Response};
3
+ use mail_builder::MessageBuilder;
4
+ use serde::{Deserialize, Serialize};
5
+ use std::collections::HashMap;
6
+ use std::sync::{Mutex, OnceLock};
7
+ use std::time::{Duration, Instant};
8
+
9
+ #[derive(Debug, Deserialize, Clone)]
10
+ pub struct AttachmentIn {
11
+ pub filename: String,
12
+ pub content: String, // base64
13
+ #[serde(default = "default_ctype")]
14
+ pub content_type: String,
15
+ }
16
+
17
+ fn default_ctype() -> String {
18
+ "application/octet-stream".to_string()
19
+ }
20
+
21
+ #[derive(Debug, Deserialize, Clone)]
22
+ pub struct SendRequest {
23
+ pub from: String,
24
+ pub to: Vec<String>,
25
+ #[serde(default)]
26
+ pub cc: Vec<String>,
27
+ #[serde(default)]
28
+ pub bcc: Vec<String>,
29
+ #[serde(default)]
30
+ pub reply_to: Option<String>,
31
+ pub subject: String,
32
+ #[serde(default)]
33
+ pub html: Option<String>,
34
+ #[serde(default)]
35
+ pub text: Option<String>,
36
+ #[serde(default)]
37
+ pub attachments: Vec<AttachmentIn>,
38
+ }
39
+
40
+ #[derive(Debug, Serialize)]
41
+ pub struct SendResponse {
42
+ pub id: String,
43
+ pub status: String,
44
+ }
45
+
46
+ #[derive(Debug, Serialize)]
47
+ struct ErrBody {
48
+ error: String,
49
+ }
50
+
51
+ // 60s in-memory SSM token cache: name -> (value, fetched_at)
52
+ static TOKEN_CACHE: OnceLock<Mutex<HashMap<String, (String, Instant)>>> = OnceLock::new();
53
+ fn cache() -> &'static Mutex<HashMap<String, (String, Instant)>> {
54
+ TOKEN_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
55
+ }
56
+
57
+ pub fn bearer_token(auth: Option<&str>) -> Option<String> {
58
+ let h = auth?;
59
+ let h = h.trim();
60
+ let tok = h.strip_prefix("Bearer ").or_else(|| h.strip_prefix("bearer "))?;
61
+ let tok = tok.trim();
62
+ if tok.is_empty() {
63
+ None
64
+ } else {
65
+ Some(tok.to_string())
66
+ }
67
+ }
68
+
69
+ pub fn from_domain(from: &str) -> Option<String> {
70
+ let addr = if let Some(a) = from.rfind('<') {
71
+ from[a + 1..].trim_end_matches('>').trim().to_string()
72
+ } else {
73
+ from.trim().to_string()
74
+ };
75
+ addr.split_once('@').map(|(_, d)| d.trim().to_lowercase())
76
+ }
77
+
78
+ pub fn allowed_domains() -> Vec<String> {
79
+ std::env::var("ALLOWED_DOMAINS")
80
+ .unwrap_or_default()
81
+ .split(',')
82
+ .map(|s| s.trim().to_lowercase())
83
+ .filter(|s| !s.is_empty())
84
+ .collect()
85
+ }
86
+
87
+ pub fn validate_payload(req: &SendRequest) -> Result<(), String> {
88
+ if req.to.is_empty() {
89
+ return Err("to must contain at least one recipient".into());
90
+ }
91
+ if req.subject.trim().is_empty() {
92
+ return Err("subject is required".into());
93
+ }
94
+ if req.html.is_none() && req.text.is_none() {
95
+ return Err("html or text is required".into());
96
+ }
97
+ let dom = from_domain(&req.from).ok_or("invalid from address")?;
98
+ let allowed = allowed_domains();
99
+ if !allowed.is_empty() && !allowed.iter().any(|d| d == &dom) {
100
+ return Err(format!("from domain '{dom}' is not a verified identity"));
101
+ }
102
+ for a in &req.attachments {
103
+ if a.filename.trim().is_empty() {
104
+ return Err("attachment filename is required".into());
105
+ }
106
+ if B64.decode(a.content.trim()).is_err() {
107
+ return Err(format!("attachment '{}' is not valid base64", a.filename));
108
+ }
109
+ }
110
+ Ok(())
111
+ }
112
+
113
+ /// Build raw MIME for SESv2 SendEmail(Raw).
114
+ pub fn build_raw(req: &SendRequest) -> Result<Vec<u8>, String> {
115
+ let mut b = MessageBuilder::new()
116
+ .from(req.from.as_str())
117
+ .to(req.to.iter().map(|s| s.as_str()).collect::<Vec<_>>())
118
+ .subject(req.subject.as_str());
119
+ if !req.cc.is_empty() {
120
+ b = b.cc(req.cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
121
+ }
122
+ if !req.bcc.is_empty() {
123
+ b = b.bcc(req.bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
124
+ }
125
+ if let Some(r) = req.reply_to.as_deref() {
126
+ b = b.reply_to(r);
127
+ }
128
+ if let Some(t) = req.text.as_deref() {
129
+ b = b.text_body(t);
130
+ }
131
+ if let Some(h) = req.html.as_deref() {
132
+ b = b.html_body(h);
133
+ }
134
+ if req.text.is_none() && req.html.is_none() {
135
+ b = b.text_body("");
136
+ }
137
+ let mut decoded: Vec<(String, String, Vec<u8>)> = Vec::with_capacity(req.attachments.len());
138
+ for a in &req.attachments {
139
+ decoded.push((
140
+ a.content_type.clone(),
141
+ a.filename.clone(),
142
+ B64.decode(a.content.trim()).map_err(|e| e.to_string())?,
143
+ ));
144
+ }
145
+ for (ctype, fname, bytes) in &decoded {
146
+ b = b.attachment(ctype.as_str(), fname.as_str(), bytes.as_slice());
147
+ }
148
+ b.write_to_vec().map_err(|e| e.to_string())
149
+ }
150
+
151
+ async fn ssm_get(name: &str) -> Result<Option<String>, String> {
152
+ // Cache hit (60s TTL).
153
+ if let Ok(map) = cache().lock() {
154
+ if let Some((v, t)) = map.get(name) {
155
+ if t.elapsed() < Duration::from_secs(60) {
156
+ return Ok(Some(v.clone()));
157
+ }
158
+ }
159
+ }
160
+ let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
161
+ let client = aws_sdk_ssm::Client::new(&config);
162
+ let prefix = std::env::var("API_KEYS_PREFIX").unwrap_or("/4ward/api-keys/".into());
163
+ // `name` here is the full token id suffix; we list keys and compare values.
164
+ // Tokens are stored as SecureString values; lookup by listing parameters under prefix.
165
+ let mut next: Option<String> = None;
166
+ loop {
167
+ let mut req = client.describe_parameters().parameter_filters(
168
+ aws_sdk_ssm::types::ParameterStringFilter::builder()
169
+ .key("Name")
170
+ .option("BeginsWith")
171
+ .values(&prefix)
172
+ .build()
173
+ .map_err(|e| e.to_string())?,
174
+ );
175
+ if let Some(t) = next {
176
+ req = req.next_token(t);
177
+ }
178
+ let out = req.send().await.map_err(|e| e.to_string())?;
179
+ let names: Vec<String> = out.parameters().iter().filter_map(|p| p.name().map(|s| s.to_string())).collect();
180
+ for n in names {
181
+ let v = client.get_parameter().name(&n).with_decryption(true).send().await;
182
+ if let Ok(v) = v {
183
+ if let Some(val) = v.parameter().and_then(|p| p.value()) {
184
+ if val == name {
185
+ if let Ok(mut map) = cache().lock() {
186
+ map.insert(name.to_string(), (n.clone(), Instant::now()));
187
+ }
188
+ return Ok(Some(n));
189
+ }
190
+ }
191
+ }
192
+ }
193
+ next = out.next_token().map(|s| s.to_string());
194
+ if next.is_none() {
195
+ break;
196
+ }
197
+ }
198
+ Ok(None)
199
+ }
200
+
201
+ async fn verify_bearer(token: &str) -> Result<bool, String> {
202
+ // Cache hit means previously validated.
203
+ if let Ok(map) = cache().lock() {
204
+ if let Some((_, t)) = map.get(token) {
205
+ if t.elapsed() < Duration::from_secs(60) {
206
+ return Ok(true);
207
+ }
208
+ }
209
+ }
210
+ Ok(ssm_get(token).await?.is_some())
211
+ }
212
+
213
+ fn json_resp(status: u16, body: impl Serialize) -> Result<Response<Body>, Error> {
214
+ let s = serde_json::to_string(&body).unwrap_or("{}".into());
215
+ Ok(Response::builder()
216
+ .status(status)
217
+ .header("content-type", "application/json")
218
+ .body(Body::from(s))
219
+ .unwrap())
220
+ }
221
+
222
+ async fn handler(event: Request) -> Result<Response<Body>, Error> {
223
+ // Only POST /v1/emails
224
+ let path = event.raw_http_path().to_string();
225
+ if event.method() != "POST" || !path.ends_with("/v1/emails") {
226
+ return json_resp(404, ErrBody { error: "not found".into() });
227
+ }
228
+ let auth = event.headers().get("authorization").and_then(|v| v.to_str().ok()).map(|s| s.to_string());
229
+ let token = bearer_token(auth.as_deref());
230
+ let token = match token {
231
+ None => return json_resp(401, ErrBody { error: "missing bearer token".into() }),
232
+ Some(t) => t,
233
+ };
234
+ if !token.starts_with("4w_live_") {
235
+ return json_resp(401, ErrBody { error: "invalid token".into() });
236
+ }
237
+ match verify_bearer(&token).await {
238
+ Ok(true) => {},
239
+ Ok(false) => return json_resp(401, ErrBody { error: "invalid token".into() }),
240
+ Err(e) => {
241
+ tracing::warn!(error = %e, "ssm verify failed");
242
+ return json_resp(500, ErrBody { error: "auth backend error".into() });
243
+ }
244
+ }
245
+ let body = match event.body() {
246
+ Body::Text(s) => s.as_bytes().to_vec(),
247
+ Body::Binary(b) => b.clone(),
248
+ Body::Empty => Vec::new(),
249
+ };
250
+ let req: SendRequest = match serde_json::from_slice(&body) {
251
+ Ok(r) => r,
252
+ Err(e) => return json_resp(400, ErrBody { error: format!("invalid payload: {e}") }),
253
+ };
254
+ if let Err(e) = validate_payload(&req) {
255
+ return json_resp(400, ErrBody { error: e });
256
+ }
257
+ let raw = match build_raw(&req) {
258
+ Ok(r) => r,
259
+ Err(e) => return json_resp(400, ErrBody { error: e }),
260
+ };
261
+ let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
262
+ let ses = aws_sdk_sesv2::Client::new(&config);
263
+ let from_addr = if let Some(a) = req.from.rfind('<') {
264
+ req.from[a + 1..].trim_end_matches('>').trim().to_string()
265
+ } else {
266
+ req.from.trim().to_string()
267
+ };
268
+ let mut to_all = req.to.clone();
269
+ to_all.extend(req.cc.clone());
270
+ to_all.extend(req.bcc.clone());
271
+ let config_set = std::env::var("CONFIG_SET").ok().filter(|s| !s.trim().is_empty());
272
+ let out = ses
273
+ .send_email()
274
+ .from_email_address(&from_addr)
275
+ .set_configuration_set_name(config_set)
276
+ .set_destination(Some(
277
+ aws_sdk_sesv2::types::Destination::builder()
278
+ .set_to_addresses(Some(to_all))
279
+ .build(),
280
+ ))
281
+ .content(
282
+ aws_sdk_sesv2::types::EmailContent::builder()
283
+ .raw(aws_sdk_sesv2::types::RawMessage::builder().data(aws_sdk_sesv2::primitives::Blob::new(raw)).build().map_err(|e| format!("{e:?}"))?)
284
+ .build(),
285
+ )
286
+ .send()
287
+ .await;
288
+ match out {
289
+ Ok(o) => json_resp(200, SendResponse { id: o.message_id().unwrap_or("").to_string(), status: "sent".into() }),
290
+ Err(e) => {
291
+ tracing::warn!(error = %e, "ses send failed");
292
+ json_resp(502, ErrBody { error: "ses send failed".into() })
293
+ }
294
+ }
295
+ }
296
+
297
+ #[tokio::main]
298
+ async fn main() -> Result<(), Error> {
299
+ tracing_subscriber::fmt()
300
+ .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
301
+ .json()
302
+ .init();
303
+ run(service_fn(handler)).await
304
+ }
305
+
306
+ #[cfg(test)]
307
+ mod tests {
308
+ use super::*;
309
+
310
+ #[test]
311
+ fn bearer_parsing() {
312
+ assert_eq!(bearer_token(Some("Bearer 4w_live_abc")).as_deref(), Some("4w_live_abc"));
313
+ assert!(bearer_token(Some("Bearer ")).is_none());
314
+ assert!(bearer_token(None).is_none());
315
+ }
316
+
317
+ #[test]
318
+ fn from_domain_parse() {
319
+ assert_eq!(from_domain("Acme <alerts@example.com>").as_deref(), Some("example.com"));
320
+ assert_eq!(from_domain("a@x.io").as_deref(), Some("x.io"));
321
+ }
322
+
323
+ #[test]
324
+ fn payload_validation() {
325
+ let good = SendRequest {
326
+ from: "Acme <alerts@example.com>".into(),
327
+ to: vec!["customer@domain.com".into()],
328
+ cc: vec![], bcc: vec![],
329
+ reply_to: Some("support@example.com".into()),
330
+ subject: "System Verification".into(),
331
+ html: Some("<p>Your code is: <strong>849201</strong></p>".into()),
332
+ text: Some("Your code is: 849201".into()),
333
+ attachments: vec![AttachmentIn { filename: "document.pdf".into(), content: "JVBERi0xLjQK".into(), content_type: "application/pdf".into() }],
334
+ };
335
+ assert!(validate_payload(&good).is_ok());
336
+ let mut bad = good.clone();
337
+ bad.to.clear();
338
+ assert!(validate_payload(&bad).is_err());
339
+ let mut bad2 = good.clone();
340
+ bad2.attachments[0].content = "!!!not-base64!!!".into();
341
+ assert!(validate_payload(&bad2).is_err());
342
+ }
343
+
344
+ #[test]
345
+ fn raw_build_includes_attachment() {
346
+ let req = SendRequest {
347
+ from: "Acme <alerts@example.com>".into(),
348
+ to: vec!["c@d.com".into()],
349
+ cc: vec![], bcc: vec![],
350
+ reply_to: None,
351
+ subject: "t".into(),
352
+ html: Some("<p>hi</p>".into()),
353
+ text: None,
354
+ attachments: vec![AttachmentIn { filename: "document.pdf".into(), content: "aGVsbG8=".into(), content_type: "application/pdf".into() }],
355
+ };
356
+ let raw = build_raw(&req).unwrap();
357
+ let s = String::from_utf8_lossy(&raw);
358
+ assert!(s.contains("document.pdf"), "attachment kept:\n{s}");
359
+ }
360
+ }
@@ -0,0 +1,29 @@
1
+ [package]
2
+ name = "lambda-forwarder"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [[bin]]
7
+ name = "bootstrap"
8
+ path = "src/main.rs"
9
+
10
+ [dependencies]
11
+ lambda_runtime = "0.14"
12
+ aws_lambda_events = "0.18"
13
+ aws-config = "1"
14
+ aws-sdk-s3 = "1"
15
+ aws-sdk-sesv2 = "1"
16
+ mail-parser = "0.11"
17
+ mail-builder = "0.3"
18
+ rsa = { version = "0.9", features = ["sha2"] }
19
+ sha2 = "0.10"
20
+ tokio = { version = "1", features = ["full"] }
21
+ serde = { version = "1", features = ["derive"] }
22
+ serde_json = "1"
23
+ tracing = "0.1"
24
+ tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
25
+ fourward-core = { path = "../../crates/4ward-core" }
26
+ base64 = "0.22"
27
+
28
+ [dev-dependencies]
29
+ rand = "0.8"