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,258 @@
1
+ //! Minimal first-hop ARC sealing (RFC 8617 §5) for relayed mail.
2
+ //!
3
+ //! - Only seals when the message carries **no** incoming ARC chain (the SES
4
+ //! inbound case). Existing chains are left untouched (skip, not fail).
5
+ //! - Pure signing: no DNS, no network. Verification of upstream auth is out
6
+ //! of scope, so `cv=none` with an all-`none` Authentication-Results.
7
+ //! - Fail-safe direction: a broken seal is ignored by receivers; the mail
8
+ //! itself is unaffected. Callers must fail **open** (skip sealing on Err).
9
+
10
+ use base64::{engine::general_purpose::STANDARD as B64, Engine};
11
+ use rsa::pkcs1::DecodeRsaPrivateKey;
12
+ use rsa::pkcs1v15::SigningKey;
13
+ use rsa::signature::{SignatureEncoding, Signer};
14
+ use rsa::RsaPrivateKey;
15
+ use sha2::{Digest, Sha256};
16
+
17
+ /// Headers covered by the ARC message signature (intersected with present).
18
+ const SIGNED: &[&str] = &[
19
+ "from",
20
+ "to",
21
+ "subject",
22
+ "date",
23
+ "message-id",
24
+ "reply-to",
25
+ "mime-version",
26
+ "content-type",
27
+ ];
28
+
29
+ pub fn has_arc_chain(header_section: &str) -> bool {
30
+ header_section.lines().any(|l| {
31
+ let name = l.split_once(':').map(|(n, _)| n.trim()).unwrap_or("");
32
+ name.eq_ignore_ascii_case("arc-seal")
33
+ || name.eq_ignore_ascii_case("arc-message-signature")
34
+ || name.eq_ignore_ascii_case("arc-authentication-results")
35
+ })
36
+ }
37
+
38
+ fn compress_ws(s: &str) -> String {
39
+ let mut out = String::with_capacity(s.len());
40
+ let mut in_ws = false;
41
+ for c in s.chars() {
42
+ if c == ' ' || c == '\t' {
43
+ if !in_ws {
44
+ out.push(' ');
45
+ in_ws = true;
46
+ }
47
+ } else {
48
+ out.push(c);
49
+ in_ws = false;
50
+ }
51
+ }
52
+ out
53
+ }
54
+
55
+ /// Relaxed header canonicalization (RFC 6376 §3.4.2): lowercase name,
56
+ /// compress WSP, drop trailing WSP. `line` must be a single unfolded header.
57
+ fn relaxed_header(line: &str) -> Option<String> {
58
+ let (name, value) = line.split_once(':')?;
59
+ let body = compress_ws(value).trim_end().to_string();
60
+ // Drop leading WSP too: canonical "name:value" form.
61
+ let body = body.trim_start().to_string();
62
+ Some(format!("{}:{body}", name.trim().to_lowercase()))
63
+ }
64
+
65
+ /// Relaxed body canonicalization (RFC 6376 §3.4.3).
66
+ fn relaxed_body(body: &str) -> String {
67
+ let lines: Vec<&str> = body.split('\n').collect();
68
+ let mut end = lines.len();
69
+ while end > 0 && lines[end - 1].trim_end_matches([' ', '\t', '\r']).is_empty() {
70
+ end -= 1;
71
+ }
72
+ lines[..end]
73
+ .iter()
74
+ .map(|l| l.trim_end_matches([' ', '\t', '\r']))
75
+ .collect::<Vec<_>>()
76
+ .join("\r\n")
77
+ }
78
+
79
+ fn split_headers_body(message: &str) -> (String, String) {
80
+ let norm = message.replace("\r\n", "\n").replace('\r', "\n");
81
+ match norm.split_once("\n\n") {
82
+ Some((h, b)) => (h.to_string(), b.to_string()),
83
+ None => (norm, String::new()),
84
+ }
85
+ }
86
+
87
+ /// Unfold continuation lines, preserving order. Returns (lower_name, full_line).
88
+ fn unfolded_headers(header_section: &str) -> Vec<(String, String)> {
89
+ let mut out = Vec::new();
90
+ let mut cur = String::new();
91
+ for line in header_section.lines() {
92
+ if line.starts_with([' ', '\t']) && !cur.is_empty() {
93
+ cur.push(' ');
94
+ cur.push_str(line.trim());
95
+ } else {
96
+ if !cur.is_empty() {
97
+ let name = cur.split_once(':').map(|(n, _)| n.trim().to_lowercase()).unwrap_or_default();
98
+ out.push((name, cur.clone()));
99
+ }
100
+ cur = line.to_string();
101
+ }
102
+ }
103
+ if !cur.is_empty() {
104
+ let name = cur.split_once(':').map(|(n, _)| n.trim().to_lowercase()).unwrap_or_default();
105
+ out.push((name, cur));
106
+ }
107
+ out
108
+ }
109
+
110
+ fn fold_b64(prefix: &str, b64: &str) -> String {
111
+ // Fold long base64 across continuation lines (unfolds losslessly).
112
+ let mut s = String::from(prefix);
113
+ for (i, chunk) in b64.as_bytes().chunks(64).enumerate() {
114
+ if i > 0 {
115
+ s.push_str("\r\n ");
116
+ }
117
+ s.push_str(std::str::from_utf8(chunk).unwrap_or(""));
118
+ }
119
+ s
120
+ }
121
+
122
+ fn parse_rsa(pem: &str) -> Result<RsaPrivateKey, String> {
123
+ if pem.contains("BEGIN RSA PRIVATE KEY") {
124
+ RsaPrivateKey::from_pkcs1_pem(pem).map_err(|e| format!("pkcs1 parse: {e}"))
125
+ } else {
126
+ use rsa::pkcs8::DecodePrivateKey;
127
+ RsaPrivateKey::from_pkcs8_pem(pem).map_err(|e| format!("pkcs8 parse: {e}"))
128
+ }
129
+ }
130
+
131
+ /// Seal a fully-built relayed message. Returns the ARC header block
132
+ /// (trailing CRLF included) to **prepend** to `message`.
133
+ pub fn seal_first_hop(
134
+ message: &[u8],
135
+ authserv_id: &str,
136
+ seal_domain: &str,
137
+ selector: &str,
138
+ private_pem: &str,
139
+ now_unix: u64,
140
+ ) -> Result<String, String> {
141
+ if selector.trim().is_empty() || seal_domain.trim().is_empty() {
142
+ return Err("selector/domain required".into());
143
+ }
144
+ let text = String::from_utf8_lossy(message);
145
+ let (headers, body) = split_headers_body(&text);
146
+ if has_arc_chain(&headers) {
147
+ return Err("incoming ARC chain present; only first-hop sealing supported".into());
148
+ }
149
+ let hdrs = unfolded_headers(&headers);
150
+ if !hdrs.iter().any(|(n, _)| n == "from") {
151
+ return Err("no From header to seal".into());
152
+ }
153
+ let cover: Vec<String> = SIGNED
154
+ .iter()
155
+ .filter(|n| hdrs.iter().any(|(h, _)| h == **n))
156
+ .map(|s| s.to_string())
157
+ .collect();
158
+ let h_tag = cover.join(":");
159
+
160
+ let bh = B64.encode(Sha256::digest(relaxed_body(&body)));
161
+
162
+ let aar = format!(
163
+ "ARC-Authentication-Results: i=1; {authserv_id}; spf=none; dkim=none; dmarc=none"
164
+ );
165
+ let ams_unsigned = format!(
166
+ "ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d={seal_domain}; s={selector}; t={now_unix}; h={h_tag}; bh={bh}; b="
167
+ );
168
+
169
+ let key = parse_rsa(private_pem)?;
170
+ let signer = SigningKey::<Sha256>::new(key);
171
+
172
+ // AMS signs covered headers + itself (b= empty).
173
+ let mut ams_input = Vec::new();
174
+ for name in &cover {
175
+ let line = hdrs.iter().rev().find(|(h, _)| h == name).and_then(|(_, l)| relaxed_header(l)).ok_or("header vanished")?;
176
+ ams_input.extend_from_slice(line.as_bytes());
177
+ ams_input.extend_from_slice(b"\r\n");
178
+ }
179
+ let ams_relaxed = relaxed_header(&ams_unsigned).ok_or("ams malformed")?;
180
+ ams_input.extend_from_slice(ams_relaxed.as_bytes());
181
+ ams_input.extend_from_slice(b"\r\n");
182
+ let ams_sig = signer.sign(&Sha256::digest(&ams_input));
183
+ let ams_full = fold_b64(&ams_unsigned, &B64.encode(ams_sig.to_vec()));
184
+
185
+ // Seal signs seal(empty) + AMS + AAR, in that order.
186
+ let seal_unsigned = format!(
187
+ "ARC-Seal: i=1; a=rsa-sha256; cv=none; d={seal_domain}; s={selector}; t={now_unix}; b="
188
+ );
189
+ let mut seal_input = Vec::new();
190
+ for h in [&seal_unsigned, &ams_full, &aar] {
191
+ let r = relaxed_header(&h.replace("\r\n ", " ")).ok_or("seal input malformed")?;
192
+ seal_input.extend_from_slice(r.as_bytes());
193
+ seal_input.extend_from_slice(b"\r\n");
194
+ }
195
+ let seal_sig = signer.sign(&Sha256::digest(&seal_input));
196
+ let seal_full = fold_b64(&seal_unsigned, &B64.encode(seal_sig.to_vec()));
197
+
198
+ Ok(format!("{seal_full}\r\n{ams_full}\r\n{aar}\r\n"))
199
+ }
200
+
201
+ #[cfg(test)]
202
+ mod tests {
203
+ use super::*;
204
+ use rsa::pkcs1v15::VerifyingKey;
205
+ use rsa::signature::Verifier;
206
+
207
+ fn test_key() -> RsaPrivateKey {
208
+ RsaPrivateKey::new(&mut rand::thread_rng(), 1024).unwrap()
209
+ }
210
+
211
+ fn pem(key: &RsaPrivateKey) -> String {
212
+ use rsa::pkcs1::EncodeRsaPrivateKey;
213
+ key.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF).unwrap().to_string()
214
+ }
215
+
216
+ const MSG: &str = "From: Alice <alice@example.org>\r\nTo: you@gmail.com\r\nSubject: hi\r\nDate: Thu, 01 Jan 2026 00:00:00 +0000\r\nContent-Type: text/plain\r\n\r\nHello\r\n";
217
+
218
+ #[test]
219
+ fn seals_and_verifies() {
220
+ let key = test_key();
221
+ let block = seal_first_hop(MSG.as_bytes(), "relay.example.com", "example.com", "fw1", &pem(&key), 1767225600).unwrap();
222
+ assert!(block.contains("ARC-Seal: i=1"), "seal header:\n{block}");
223
+ assert!(block.contains("ARC-Message-Signature: i=1"), "ams header");
224
+ assert!(block.contains("ARC-Authentication-Results: i=1"), "aar header");
225
+ assert!(block.contains("cv=none"), "first hop cv");
226
+
227
+ // Independently verify the AMS signature over the same inputs.
228
+ let unfolded_block = block.replace("\r\n ", " ");
229
+ let ams = unfolded_block.lines().find(|l| l.starts_with("ARC-Message-Signature:")).unwrap();
230
+ let b_pos = ams.find("; b=").unwrap();
231
+ let ams_empty = format!("{}; b=", &ams[..b_pos]);
232
+ let (headers, _) = split_headers_body(MSG);
233
+ let hdrs = unfolded_headers(&headers);
234
+ let mut input = Vec::new();
235
+ for n in ["from", "to", "subject", "date", "content-type"] {
236
+ let l = hdrs.iter().rev().find(|(h, _)| h == n).and_then(|(_, l)| relaxed_header(l)).unwrap();
237
+ input.extend_from_slice(l.as_bytes());
238
+ input.extend_from_slice(b"\r\n");
239
+ }
240
+ input.extend_from_slice(relaxed_header(&ams_empty).unwrap().as_bytes());
241
+ input.extend_from_slice(b"\r\n");
242
+ let sig_b64: String = ams[b_pos + 4..].split_whitespace().collect();
243
+ let sig = rsa::pkcs1v15::Signature::try_from(B64.decode(sig_b64).unwrap().as_slice()).unwrap();
244
+ VerifyingKey::<Sha256>::new(key.to_public_key()).verify(&Sha256::digest(&input), &sig).unwrap();
245
+ }
246
+
247
+ #[test]
248
+ fn skips_existing_chain() {
249
+ let chained = format!("ARC-Seal: i=1; cv=none; b=abc\r\n{MSG}");
250
+ let key = test_key();
251
+ assert!(seal_first_hop(chained.as_bytes(), "r", "d", "s", &pem(&key), 0).is_err());
252
+ }
253
+
254
+ #[test]
255
+ fn rejects_bad_key() {
256
+ assert!(seal_first_hop(MSG.as_bytes(), "r", "d", "s", "not-a-key", 0).is_err());
257
+ }
258
+ }
@@ -0,0 +1,369 @@
1
+ use aws_lambda_events::event::s3::S3Event;
2
+ use lambda_runtime::{service_fn, Error, LambdaEvent};
3
+ use mail_builder::MessageBuilder;
4
+ use mail_builder::headers::text::Text;
5
+ use mail_parser::{MessageParser, MimeHeaders};
6
+ use std::collections::HashMap;
7
+
8
+ pub mod arc;
9
+
10
+ /// Env-driven routing. Keep the Lambda itself stateless:
11
+ /// - `RELAY_DOMAIN`: verified SES identity, e.g. `example.com` (From: relay@domain)
12
+ /// - `ALIAS_MAP_JSON`: `{"support@example.com": ["you@gmail.com"], ...}`
13
+ /// - `CATCH_ALL`: optional fallback destination
14
+ /// - `BANNER_ENABLED`: "true"/"false"
15
+ /// - `LOOP_SALT`: salt for loop-detection hash (defaults to relay domain)
16
+ /// - `CONFIG_SET`: SES configuration set (bounce/complaint tracking)
17
+ /// - `ARC_SELECTOR` + `ARC_PRIVATE_KEY`: first-hop ARC seal RSA PEM (absent = skip)
18
+ pub const LOOP_HEADER: &str = "X-4ward-Loop-Detection";
19
+
20
+ pub fn loop_hash(domain: &str, salt: &str) -> String {
21
+ // ponytail: djb2 hex, std-only; swap for HMAC if spoofing matters
22
+ let mut h: u64 = 5381;
23
+ for b in format!("{salt}:{domain}").bytes() {
24
+ h = h.wrapping_mul(33).wrapping_add(b as u64);
25
+ }
26
+ format!("{h:016x}")
27
+ }
28
+
29
+ pub fn is_loop(headers_raw: &str, expected_hash: &str) -> bool {
30
+ headers_raw.lines().any(|l| {
31
+ let lt = l.trim();
32
+ lt.len() > LOOP_HEADER.len()
33
+ && lt[..LOOP_HEADER.len()].eq_ignore_ascii_case(LOOP_HEADER)
34
+ && lt[LOOP_HEADER.len()..].contains(expected_hash)
35
+ })
36
+ }
37
+
38
+ pub fn split_alias(recipient: &str) -> Option<(String, String)> {
39
+ let r = recipient.trim().trim_matches(|c| c == '<' || c == '>').trim();
40
+ let (_, addr) = parse_address(r);
41
+ let (user, domain) = addr.split_once('@')?;
42
+ Some((user.to_lowercase(), domain.to_lowercase()))
43
+ }
44
+
45
+ fn parse_address(s: &str) -> (String, String) {
46
+ // "Name <addr@dom>" -> ("Name", "addr@dom"); bare addr -> ("", addr).
47
+ // Tolerates a missing closing '>' (e.g. after trimming).
48
+ if let Some(a) = s.rfind('<') {
49
+ let addr = s[a + 1..].trim_end_matches('>').trim();
50
+ if addr.contains('@') {
51
+ let name = s[..a].trim().trim_matches('"').trim().to_string();
52
+ return (name, addr.to_string());
53
+ }
54
+ }
55
+ (String::new(), s.trim().to_string())
56
+ }
57
+
58
+ fn alias_map() -> HashMap<String, Vec<String>> {
59
+ std::env::var("ALIAS_MAP_JSON")
60
+ .ok()
61
+ .and_then(|s| serde_json::from_str(&s).ok())
62
+ .unwrap_or_default()
63
+ }
64
+
65
+ pub fn resolve_destinations(alias: &str, domain: &str) -> Vec<String> {
66
+ let map = alias_map();
67
+ let key = format!("{alias}@{domain}");
68
+ if let Some(v) = map.get(&key) {
69
+ return v.clone();
70
+ }
71
+ // case-insensitive fallback
72
+ for (k, v) in &map {
73
+ if k.eq_ignore_ascii_case(&key) {
74
+ return v.clone();
75
+ }
76
+ }
77
+ std::env::var("CATCH_ALL").ok().filter(|s| !s.is_empty()).map(|s| vec![s]).unwrap_or_default()
78
+ }
79
+
80
+ pub fn banner_enabled() -> bool {
81
+ std::env::var("BANNER_ENABLED").map(|v| v != "false" && v != "0").unwrap_or(true)
82
+ }
83
+
84
+ pub fn banner_html(alias: &str, domain: &str, dest: &str, original: &str) -> String {
85
+ format!(
86
+ "<div style=\"background:#f4f4f5;padding:8px;font-size:12px;border-radius:4px;color:#333;margin-bottom:12px;\">\n Forwarded by 4ward from <b>{alias}@{domain}</b> to <b>{dest}</b>. Original sender: <b>{original}</b>\n</div>"
87
+ )
88
+ }
89
+
90
+ pub fn banner_text(alias: &str, domain: &str, dest: &str, original: &str) -> String {
91
+ format!("Forwarded by 4ward from {alias}@{domain} to {dest}. Original sender: {original}\n\n")
92
+ }
93
+
94
+ pub struct ForwardPlan {
95
+ pub alias: String,
96
+ pub domain: String,
97
+ pub destinations: Vec<String>,
98
+ pub original_from: String,
99
+ pub original_from_name: String,
100
+ pub original_to: String,
101
+ pub subject: String,
102
+ pub loop_value: String,
103
+ }
104
+
105
+ /// Drop mail SES flagged as spam or virus: relaying it burns SES reputation.
106
+ pub fn is_filtered(raw: &[u8]) -> bool {
107
+ let text = String::from_utf8_lossy(raw).replace("\r\n", "\n");
108
+ let headers = text.split("\n\n").next().unwrap_or("");
109
+ headers.lines().any(|l| {
110
+ match l.split_once(':') {
111
+ Some((n, v)) if n.trim().eq_ignore_ascii_case("X-SES-Spam-Verdict") => v.contains("FAIL"),
112
+ Some((n, v)) if n.trim().eq_ignore_ascii_case("X-SES-Virus-Verdict") => v.contains("FAIL"),
113
+ _ => false,
114
+ }
115
+ })
116
+ }
117
+
118
+ /// Pure planning step — easy to unit test without AWS.
119
+ pub fn plan_forward(raw: &[u8], relay_domain: &str, loop_salt: &str) -> Result<Option<ForwardPlan>, String> {
120
+ let raw_str = String::from_utf8_lossy(raw);
121
+ let expected = loop_hash(relay_domain, loop_salt);
122
+ // Fast header-section scan for loop marker before full parse.
123
+ let header_section = raw_str.split("\r\n\r\n").next().unwrap_or(&raw_str).to_string();
124
+ let header_section = if header_section.contains('\n') && !header_section.contains("\r\n") {
125
+ header_section.replace('\n', "\r\n")
126
+ } else {
127
+ header_section
128
+ };
129
+ if is_loop(&header_section, &expected) {
130
+ return Ok(None);
131
+ }
132
+ let msg = MessageParser::default().parse(raw).ok_or("mime parse failed")?;
133
+ let from = msg.from().and_then(|a| a.first()).map(|a| {
134
+ (
135
+ a.name().unwrap_or("").to_string(),
136
+ a.address().unwrap_or("").to_string(),
137
+ )
138
+ }).unwrap_or_default();
139
+ let to = msg.to().and_then(|a| a.first()).map(|a| a.address().unwrap_or("").to_string()).unwrap_or_default();
140
+ let (alias, domain) = split_alias(&to).ok_or("no routable To header")?;
141
+ let destinations = resolve_destinations(&alias, &domain);
142
+ if destinations.is_empty() {
143
+ return Err("no route for recipient and no catch-all".into());
144
+ }
145
+ Ok(Some(ForwardPlan {
146
+ alias,
147
+ domain,
148
+ destinations,
149
+ original_from: from.1,
150
+ original_from_name: from.0,
151
+ original_to: to,
152
+ subject: msg.subject().unwrap_or("").to_string(),
153
+ loop_value: expected,
154
+ }))
155
+ }
156
+
157
+ /// Build the relayed raw MIME, preserving text/html + attachments.
158
+ pub fn build_forwarded_raw(raw: &[u8], plan: &ForwardPlan, relay_domain: &str) -> Result<Vec<u8>, String> {
159
+ let msg = MessageParser::default().parse(raw).ok_or("mime parse failed")?;
160
+ let dest0 = plan.destinations.first().cloned().unwrap_or_default();
161
+ let from_name = if plan.original_from_name.trim().is_empty() {
162
+ plan.alias.clone()
163
+ } else {
164
+ plan.original_from_name.clone()
165
+ };
166
+ let relay_from = format!("relay@{relay_domain}");
167
+ let display_from = format!("{from_name} (via {})", plan.alias);
168
+
169
+ let text_body = msg.body_text(0).map(|b| b.to_string());
170
+ let html_body = msg.body_html(0).map(|b| b.to_string());
171
+
172
+ let (text_out, html_out) = if banner_enabled() {
173
+ let bt = banner_text(&plan.alias, &plan.domain, &dest0, &plan.original_from);
174
+ let bh = banner_html(&plan.alias, &plan.domain, &dest0, &plan.original_from);
175
+ (
176
+ text_body.map(|b| format!("{bt}{b}")),
177
+ html_body.map(|b| format!("{bh}{b}")),
178
+ )
179
+ } else {
180
+ (text_body, html_body)
181
+ };
182
+
183
+ let mut builder = MessageBuilder::new()
184
+ .from((display_from.as_str(), relay_from.as_str()))
185
+ .reply_to(plan.original_from.as_str())
186
+ .to(plan.destinations.iter().map(|s| s.as_str()).collect::<Vec<_>>())
187
+ .subject(plan.subject.as_str())
188
+ .header("X-Original-From", Text::new(plan.original_from.as_str()))
189
+ .header("X-Original-To", Text::new(plan.original_to.as_str()))
190
+ .header("X-4ward-Relay", Text::new("true"))
191
+ .header(LOOP_HEADER, Text::new(plan.loop_value.as_str()));
192
+
193
+ if let Some(t) = text_out.as_deref() {
194
+ builder = builder.text_body(t);
195
+ }
196
+ if let Some(h) = html_out.as_deref() {
197
+ builder = builder.html_body(h);
198
+ }
199
+ if text_out.is_none() && html_out.is_none() {
200
+ builder = builder.text_body("(empty message forwarded by 4ward)");
201
+ }
202
+ // Re-attach original attachments (retention, not transformation).
203
+ // ponytail: generic content-type; sniff real type per part if clients need it
204
+ let atts: Vec<(String, Vec<u8>)> = msg
205
+ .attachments()
206
+ .map(|att| {
207
+ (
208
+ att.attachment_name().unwrap_or("attachment.bin").to_string(),
209
+ att.contents().to_vec(),
210
+ )
211
+ })
212
+ .collect();
213
+ for (name, bytes) in &atts {
214
+ builder = builder.attachment("application/octet-stream", name.as_str(), bytes.as_slice());
215
+ }
216
+
217
+ builder.write_to_vec().map_err(|e| e.to_string())
218
+ }
219
+
220
+ async fn handle(event: LambdaEvent<S3Event>) -> Result<(), Error> {
221
+ let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
222
+ let loop_salt = std::env::var("LOOP_SALT").unwrap_or_else(|_| relay_domain.clone());
223
+ if relay_domain.is_empty() {
224
+ tracing::warn!("RELAY_DOMAIN unset, skipping");
225
+ return Ok(());
226
+ }
227
+ let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
228
+ let s3 = aws_sdk_s3::Client::new(&config);
229
+ let ses = aws_sdk_sesv2::Client::new(&config);
230
+
231
+ for record in event.payload.records {
232
+ let bucket = record.s3.bucket.name.unwrap_or_default();
233
+ let key = record.s3.object.key.unwrap_or_default();
234
+ tracing::info!(bucket = %bucket, key = %key, "fetching raw mime from s3");
235
+ let obj = s3.get_object().bucket(&bucket).key(&key).send().await?;
236
+ let bytes = obj.body.collect().await?.into_bytes().to_vec();
237
+
238
+ let plan = plan_forward(&bytes, &relay_domain, &loop_salt).map_err(|e| format!("plan: {e}"))?;
239
+ let plan = match plan {
240
+ None => {
241
+ tracing::warn!("loop header matched, dropping");
242
+ continue;
243
+ }
244
+ Some(p) => p,
245
+ };
246
+ if is_filtered(&bytes) {
247
+ tracing::warn!("ses spam/virus verdict FAIL, dropping (reputation protection)");
248
+ continue;
249
+ }
250
+ let mut raw = build_forwarded_raw(&bytes, &plan, &relay_domain).map_err(|e| format!("build: {e}"))?;
251
+ // First-hop ARC seal (fail-open: skip on any error, mail still flows).
252
+ let arc_sel = std::env::var("ARC_SELECTOR").ok().filter(|s| !s.trim().is_empty());
253
+ let arc_pem = std::env::var("ARC_PRIVATE_KEY").ok().filter(|s| !s.trim().is_empty());
254
+ if let (Some(sel), Some(pem)) = (arc_sel, arc_pem) {
255
+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
256
+ match arc::seal_first_hop(&raw, &relay_domain, &relay_domain, &sel, &pem, now) {
257
+ Ok(block) => {
258
+ let mut sealed = block.into_bytes();
259
+ sealed.extend_from_slice(&raw);
260
+ raw = sealed;
261
+ }
262
+ Err(e) => tracing::warn!(error = %e, "arc seal skipped"),
263
+ }
264
+ }
265
+ let relay_from = format!("relay@{relay_domain}");
266
+ let config_set = std::env::var("CONFIG_SET").ok().filter(|s| !s.trim().is_empty());
267
+ ses.send_email()
268
+ .from_email_address(&relay_from)
269
+ .set_configuration_set_name(config_set)
270
+ .set_destination(Some(
271
+ aws_sdk_sesv2::types::Destination::builder()
272
+ .set_to_addresses(Some(plan.destinations.clone()))
273
+ .build(),
274
+ ))
275
+ .content(
276
+ aws_sdk_sesv2::types::EmailContent::builder()
277
+ .raw(aws_sdk_sesv2::types::RawMessage::builder().data(aws_sdk_sesv2::primitives::Blob::new(raw)).build().map_err(|e| format!("raw: {e}"))?)
278
+ .build(),
279
+ )
280
+ .send()
281
+ .await?;
282
+ tracing::info!(to = ?plan.destinations, "relayed via sesv2");
283
+ }
284
+ Ok(())
285
+ }
286
+
287
+ #[tokio::main]
288
+ async fn main() -> Result<(), Error> {
289
+ tracing_subscriber::fmt()
290
+ .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
291
+ .json()
292
+ .init();
293
+ lambda_runtime::run(service_fn(handle)).await
294
+ }
295
+
296
+ #[cfg(test)]
297
+ mod tests {
298
+ use super::*;
299
+
300
+ const PLAIN: &[u8] = b"From: Alice <alice@example.org>\r\nTo: support@relay.example.com\r\nSubject: hello\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHi there";
301
+ const HTML: &[u8] = b"From: Bob <bob@example.org>\r\nTo: sales@relay.example.com\r\nSubject: hi html\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<p>Hi <b>there</b></p>";
302
+
303
+ fn multipart_fixture() -> Vec<u8> {
304
+ let boundary = "BOUNDARY123";
305
+ format!(
306
+ "From: Carol <carol@example.org>\r\nTo: info@relay.example.com\r\nSubject: with file\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\r\n--{boundary}\r\nContent-Type: text/plain\r\n\r\nsee attached\r\n--{boundary}\r\nContent-Type: application/pdf; name=\"document.pdf\"\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"document.pdf\"\r\n\r\nJVBERi0xLjQK\r\n--{boundary}--\r\n"
307
+ ).into_bytes()
308
+ }
309
+
310
+ #[test]
311
+ fn spam_verdict_drops() {
312
+ let bad = b"From: a@b.c\r\nTo: x@y.z\r\nX-SES-Spam-Verdict: FAIL\r\nSubject: t\r\n\r\nbody";
313
+ assert!(is_filtered(bad));
314
+ let virus = b"From: a@b.c\r\nTo: x@y.z\r\nX-SES-Virus-Verdict: FAIL\r\nSubject: t\r\n\r\nbody";
315
+ assert!(is_filtered(virus));
316
+ let good = b"From: a@b.c\r\nTo: x@y.z\r\nX-SES-Spam-Verdict: PASS\r\nSubject: t\r\n\r\nbody";
317
+ assert!(!is_filtered(good));
318
+ assert!(!is_filtered(PLAIN));
319
+ }
320
+
321
+ #[test]
322
+ fn loop_detection_trips() {
323
+ let h = loop_hash("example.com", "example.com");
324
+ let raw = format!("From: a@b.c\r\nTo: x@y.z\r\n{LOOP_HEADER}: {h}\r\nSubject: t\r\n\r\nbody");
325
+ assert!(plan_forward(raw.as_bytes(), "example.com", "example.com").unwrap().is_none());
326
+ }
327
+
328
+ #[test]
329
+ fn splits_alias() {
330
+ assert_eq!(split_alias("\"Support\" <Support@Example.COM>").unwrap(), ("support".into(), "example.com".into()));
331
+ }
332
+
333
+ #[test]
334
+ fn rebuild_plain_keeps_body_and_audit_headers() {
335
+ let relay = "example.com";
336
+ let plan = ForwardPlan {
337
+ alias: "support".into(), domain: "relay.example.com".into(),
338
+ destinations: vec!["you@gmail.com".into()],
339
+ original_from: "alice@example.org".into(), original_from_name: "Alice".into(),
340
+ original_to: "support@relay.example.com".into(), subject: "hello".into(),
341
+ loop_value: loop_hash(relay, relay),
342
+ };
343
+ let raw = build_forwarded_raw(PLAIN, &plan, relay).unwrap();
344
+ let s = String::from_utf8_lossy(&raw);
345
+ assert!(s.contains("Hi there"), "body preserved");
346
+ assert!(s.contains("X-Original-From"), "audit header");
347
+ assert!(s.contains("X-4ward-Relay"), "relay marker");
348
+ assert!(s.contains(LOOP_HEADER), "loop header");
349
+ assert!(s.contains("reply-to") || s.contains("Reply-To"), "reply-to set");
350
+ assert!(s.contains("Forwarded by 4ward"), "banner injected");
351
+ }
352
+
353
+ #[test]
354
+ fn rebuild_html_banner_and_attachment_retained() {
355
+ let relay = "example.com";
356
+ let plan = ForwardPlan {
357
+ alias: "info".into(), domain: "relay.example.com".into(),
358
+ destinations: vec!["you@gmail.com".into()],
359
+ original_from: "carol@example.org".into(), original_from_name: "Carol".into(),
360
+ original_to: "info@relay.example.com".into(), subject: "with file".into(),
361
+ loop_value: loop_hash(relay, relay),
362
+ };
363
+ let raw = build_forwarded_raw(&multipart_fixture(), &plan, relay).unwrap();
364
+ let s = String::from_utf8_lossy(&raw);
365
+ assert!(s.contains("document.pdf"), "attachment retained, got:\n{s}");
366
+ assert!(s.contains("X-Original-To"), "audit header");
367
+ let _ = HTML;
368
+ }
369
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "4ward",
3
+ "version": "0.1.0",
4
+ "description": "Serverless email infrastructure for AWS in Rust",
5
+ "license": "Apache-2.0",
6
+ "author": "tljohnsilver <tljohnsilver@users.noreply.github.com>",
7
+ "homepage": "https://github.com/tljohnsilver/4ward#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/tljohnsilver/4ward.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/tljohnsilver/4ward/issues"
14
+ },
15
+ "keywords": [
16
+ "aws",
17
+ "ses",
18
+ "email",
19
+ "email-forwarding",
20
+ "transactional-email",
21
+ "serverless",
22
+ "lambda",
23
+ "rust",
24
+ "srs",
25
+ "arc",
26
+ "spf",
27
+ "dmarc",
28
+ "api-gateway"
29
+ ],
30
+ "bin": {
31
+ "4ward": "./bin/run.js"
32
+ },
33
+ "files": [
34
+ "bin/",
35
+ "scripts/",
36
+ "crates/",
37
+ "lambdas/",
38
+ "docs/assets/",
39
+ "Cargo.toml",
40
+ "Cargo.lock"
41
+ ],
42
+ "scripts": {
43
+ "postinstall": "node ./scripts/install-binary.js"
44
+ },
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "os": ["darwin", "linux", "win32"],
49
+ "cpu": ["x64", "arm64"]
50
+ }