Skip to main content

mailsis_utils/
message.rs

1//! Core email message types used throughout the mail pipeline.
2//!
3//! This module defines [`EmailMessage`], the central representation of an
4//! email with structured [RFC 5322](https://www.rfc-editor.org/rfc/rfc5322)
5//! headers, cached serialization, and connection metadata. It also defines
6//! [`IncomingMessage`], the SMTP envelope wrapper received before
7//! per-recipient routing.
8
9use std::{collections::HashSet, net::IpAddr};
10
11use uuid::Uuid;
12
13use crate::parse_raw_headers;
14
15/// Represents an email message for storage operations.
16///
17/// Headers are stored as an ordered `Vec` (preserving RFC 5322 order and
18/// supporting duplicate headers such as `Received`). A cached `raw` field
19/// holds the full serialized form; call [`rebuild`](Self::rebuild) after
20/// modifying headers so that [`raw`](Self::raw) reflects the changes.
21#[derive(Debug, Clone)]
22pub struct EmailMessage {
23    /// Unique message identifier, RFC 5322 message-id format.
24    pub message_id: String,
25
26    /// Sender address, RFC 5322 address format.
27    pub from: String,
28
29    /// Recipient address, RFC 5322 address format.
30    pub to: String,
31
32    /// IP address of the connecting SMTP client (for SPF verification).
33    pub client_ip: Option<IpAddr>,
34
35    /// HELO/EHLO domain presented by the connecting client (for SPF verification).
36    pub helo_domain: Option<String>,
37
38    /// Ordered list of MIME headers (case-preserved keys, trimmed values).
39    headers: Vec<(String, String)>,
40
41    /// Message body after the blank-line separator (RFC 5322 body).
42    body: String,
43
44    /// Cached full serialization (headers + blank line + body).
45    /// Rebuilt via [`rebuild`](Self::rebuild) after header mutations.
46    raw: String,
47
48    /// Original raw body as received, never modified after construction.
49    /// Used for byte-exact operations such as DKIM signature verification.
50    original_raw: String,
51}
52
53impl EmailMessage {
54    pub fn new(from: String, to: String, raw: String) -> Self {
55        let message_id = Uuid::new_v4().to_string();
56        let (headers, content) = parse_raw_headers(&raw);
57        Self {
58            message_id,
59            from,
60            to,
61            headers,
62            body: content.to_string(),
63            raw: raw.clone(),
64            original_raw: raw,
65            client_ip: None,
66            helo_domain: None,
67        }
68    }
69
70    pub fn from_raw(from: &str, to: &str, raw: &str) -> Self {
71        Self::new(from.to_string(), to.to_string(), raw.to_string())
72    }
73
74    pub fn with_id(
75        message_id: String,
76        from: String,
77        to: String,
78        subject: String,
79        raw: String,
80    ) -> Self {
81        let (headers, content) = parse_raw_headers(&raw);
82        let has_subject = headers
83            .iter()
84            .any(|(k, _)| k.eq_ignore_ascii_case("Subject"));
85        let mut msg = Self {
86            message_id,
87            from,
88            to,
89            headers,
90            body: content.to_string(),
91            raw: raw.clone(),
92            original_raw: raw,
93            client_ip: None,
94            helo_domain: None,
95        };
96        if !has_subject && !subject.is_empty() {
97            msg.prepend_header("Subject", &subject);
98            msg.rebuild();
99        }
100        msg
101    }
102
103    /// Returns the first header value matching `name` (case-insensitive).
104    pub fn header(&self, name: &str) -> Option<&str> {
105        self.headers
106            .iter()
107            .find(|(k, _)| k.eq_ignore_ascii_case(name))
108            .map(|(_, v)| v.as_str())
109    }
110
111    /// Returns the email subject (convenience for `header("Subject")`).
112    pub fn subject(&self) -> &str {
113        self.header("Subject").unwrap_or_default()
114    }
115
116    /// Returns the full serialized email (headers + blank line + content).
117    ///
118    /// Returns the cached [`Self::raw`] field. Call [`rebuild`](Self::rebuild) after
119    /// modifying headers to ensure this is up to date.
120    pub fn raw(&self) -> &str {
121        &self.raw
122    }
123
124    /// Returns the original raw email as received, before any transformer modifications.
125    ///
126    /// Use this for byte-exact operations such as DKIM signature verification,
127    /// where the original byte sequence must be preserved.
128    pub fn original_raw(&self) -> &str {
129        &self.original_raw
130    }
131
132    /// Returns the message body after the header section (RFC 5322 body).
133    pub fn body(&self) -> &str {
134        &self.body
135    }
136
137    /// Returns whether the message has any MIME headers.
138    pub fn has_headers(&self) -> bool {
139        !self.headers.is_empty()
140    }
141
142    /// Returns a reference to the ordered header list.
143    pub fn headers(&self) -> &[(String, String)] {
144        &self.headers
145    }
146
147    /// Prepends a header to the beginning of the header list.
148    ///
149    /// The cached [`raw`](Self::raw) field is **not** updated automatically, call
150    /// [`rebuild`](Self::rebuild) once after all header modifications are done
151    /// (e.g. after running all transformers via [`MessageTransformer::apply`]).
152    pub fn prepend_header(&mut self, name: &str, value: &str) {
153        self.headers
154            .insert(0, (name.to_string(), value.to_string()));
155    }
156
157    /// Rebuilds the cached [`raw`](Self::raw) field from [`headers`](Self::headers) and [`body`](Self::body).
158    ///
159    /// Call this once after all header modifications are complete so that
160    /// [`raw()`](Self::raw) returns the up-to-date serialized form.
161    ///
162    /// Pre-computes the exact byte length, allocates once, and writes all
163    /// parts via `push_str`.
164    pub fn rebuild(&mut self) {
165        let headers_len: usize = self
166            .headers
167            .iter()
168            .map(|(k, v)| k.len() + 2 + v.len() + 2)
169            .sum();
170
171        let capacity = headers_len + if self.headers.is_empty() { 0 } else { 2 } + self.body.len();
172
173        let mut raw = String::with_capacity(capacity);
174
175        for (key, value) in &self.headers {
176            raw.push_str(key);
177            raw.push_str(": ");
178            raw.push_str(value);
179            raw.push_str("\r\n");
180        }
181
182        if !self.headers.is_empty() {
183            raw.push_str("\r\n");
184        }
185
186        raw.push_str(&self.body);
187
188        self.raw = raw;
189    }
190}
191
192/// An incoming email message with connection metadata.
193///
194/// Represents a message received over SMTP before it is split into
195/// per-recipient [`EmailMessage`] instances for routing.
196#[derive(Debug, Clone)]
197pub struct IncomingMessage {
198    /// Envelope sender address.
199    pub from: String,
200
201    /// Set of envelope recipient addresses.
202    pub rcpts: HashSet<String>,
203
204    /// Raw message data (headers + content).
205    pub raw: String,
206
207    /// IP address of the connecting SMTP client.
208    pub client_ip: Option<IpAddr>,
209
210    /// HELO/EHLO domain presented by the connecting client.
211    pub helo_domain: Option<String>,
212}
213
214impl IncomingMessage {
215    /// Creates an [`EmailMessage`] for a specific recipient from this incoming message.
216    pub fn to_email_message(&self, rcpt: &str) -> EmailMessage {
217        let mut message = EmailMessage::new(self.from.clone(), rcpt.to_string(), self.raw.clone());
218        message.client_ip = self.client_ip;
219        message.helo_domain = self.helo_domain.clone();
220        message
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use std::collections::HashSet;
227
228    use super::*;
229
230    #[test]
231    fn test_email_message_new() {
232        let message = EmailMessage::new(
233            "sender@example.com".to_string(),
234            "recipient@example.com".to_string(),
235            "Subject: Hello\r\n\r\nBody text".to_string(),
236        );
237
238        assert_eq!(message.from, "sender@example.com");
239        assert_eq!(message.to, "recipient@example.com");
240        assert_eq!(message.subject(), "Hello");
241        assert_eq!(message.body(), "Body text");
242        assert_eq!(message.raw(), "Subject: Hello\r\n\r\nBody text");
243    }
244
245    #[test]
246    fn test_email_message_from_raw() {
247        let message = EmailMessage::from_raw(
248            "sender@example.com",
249            "recipient@example.com",
250            "Subject: Test\r\n\r\nContent",
251        );
252
253        assert_eq!(message.subject(), "Test");
254        assert_eq!(message.body(), "Content");
255    }
256
257    #[test]
258    fn test_email_message_with_id() {
259        let message = EmailMessage::with_id(
260            "custom-id".to_string(),
261            "sender@example.com".to_string(),
262            "recipient@example.com".to_string(),
263            "My Subject".to_string(),
264            "Body only".to_string(),
265        );
266
267        assert_eq!(message.message_id, "custom-id");
268        assert_eq!(message.subject(), "My Subject");
269        assert!(message.raw().contains("Subject: My Subject"));
270        assert!(message.raw().contains("Body only"));
271    }
272
273    #[test]
274    fn test_email_message_with_id_existing_subject() {
275        let message = EmailMessage::with_id(
276            "id".to_string(),
277            "from@test.com".to_string(),
278            "to@test.com".to_string(),
279            "Ignored".to_string(),
280            "Subject: Existing\r\n\r\nBody".to_string(),
281        );
282
283        assert_eq!(message.subject(), "Existing");
284    }
285
286    #[test]
287    fn test_email_message_no_headers() {
288        let message = EmailMessage::from_raw("from@test.com", "to@test.com", "Plain text body");
289
290        assert!(!message.has_headers());
291        assert_eq!(message.subject(), "");
292        assert_eq!(message.body(), "Plain text body");
293    }
294
295    #[test]
296    fn test_email_message_prepend_header_and_rebuild() {
297        let mut message =
298            EmailMessage::from_raw("from@test.com", "to@test.com", "Subject: Test\r\n\r\nBody");
299
300        message.prepend_header("X-Custom", "value");
301        message.rebuild();
302
303        assert!(message.raw().starts_with("X-Custom: value\r\n"));
304        assert!(message.raw().contains("Subject: Test"));
305        assert!(message.raw().ends_with("Body"));
306    }
307
308    #[test]
309    fn test_email_message_original_raw_preserved() {
310        let mut message =
311            EmailMessage::from_raw("from@test.com", "to@test.com", "Subject: Test\r\n\r\nBody");
312        let original = message.original_raw().to_string();
313
314        message.prepend_header("X-New", "header");
315        message.rebuild();
316
317        assert_eq!(message.original_raw(), original);
318        assert_ne!(message.raw(), message.original_raw());
319    }
320
321    #[test]
322    fn test_email_message_headers_accessor() {
323        let message = EmailMessage::from_raw(
324            "from@test.com",
325            "to@test.com",
326            "From: a@b.com\r\nTo: c@d.com\r\n\r\nBody",
327        );
328
329        assert_eq!(message.headers().len(), 2);
330        assert_eq!(message.headers()[0].0, "From");
331        assert_eq!(message.headers()[1].0, "To");
332    }
333
334    #[test]
335    fn test_incoming_message_to_email_message() {
336        let incoming = IncomingMessage {
337            from: "sender@example.com".to_string(),
338            rcpts: HashSet::from(["rcpt@example.com".to_string()]),
339            raw: "Subject: Test\r\n\r\nBody".to_string(),
340            client_ip: Some("127.0.0.1".parse().unwrap()),
341            helo_domain: Some("mail.example.com".to_string()),
342        };
343
344        let message = incoming.to_email_message("rcpt@example.com");
345
346        assert_eq!(message.from, "sender@example.com");
347        assert_eq!(message.to, "rcpt@example.com");
348        assert_eq!(message.raw(), "Subject: Test\r\n\r\nBody");
349        assert_eq!(message.client_ip, Some("127.0.0.1".parse().unwrap()));
350        assert_eq!(message.helo_domain, Some("mail.example.com".to_string()));
351    }
352
353    #[test]
354    fn test_incoming_message_to_email_message_without_metadata() {
355        let incoming = IncomingMessage {
356            from: "sender@example.com".to_string(),
357            rcpts: HashSet::from(["rcpt@example.com".to_string()]),
358            raw: "Hello".to_string(),
359            client_ip: None,
360            helo_domain: None,
361        };
362
363        let message = incoming.to_email_message("rcpt@example.com");
364
365        assert_eq!(message.from, "sender@example.com");
366        assert_eq!(message.to, "rcpt@example.com");
367        assert_eq!(message.raw(), "Hello");
368        assert!(message.client_ip.is_none());
369        assert!(message.helo_domain.is_none());
370    }
371
372    #[test]
373    fn test_incoming_message_to_email_message_different_recipient() {
374        let incoming = IncomingMessage {
375            from: "sender@example.com".to_string(),
376            rcpts: HashSet::from([
377                "alice@example.com".to_string(),
378                "bob@example.com".to_string(),
379            ]),
380            raw: "Subject: Multi\r\n\r\nBody".to_string(),
381            client_ip: Some("10.0.0.1".parse().unwrap()),
382            helo_domain: Some("smtp.example.com".to_string()),
383        };
384
385        let msg_alice = incoming.to_email_message("alice@example.com");
386        let msg_bob = incoming.to_email_message("bob@example.com");
387
388        assert_eq!(msg_alice.to, "alice@example.com");
389        assert_eq!(msg_bob.to, "bob@example.com");
390        assert_eq!(msg_alice.from, msg_bob.from);
391        assert_eq!(msg_alice.raw(), msg_bob.raw());
392        assert_ne!(msg_alice.message_id, msg_bob.message_id);
393    }
394
395    #[test]
396    fn test_incoming_message_to_email_message_parses_subject() {
397        let incoming = IncomingMessage {
398            from: "sender@example.com".to_string(),
399            rcpts: HashSet::from(["rcpt@example.com".to_string()]),
400            raw: "Subject: Important Update\r\n\r\nBody content".to_string(),
401            client_ip: None,
402            helo_domain: None,
403        };
404
405        let message = incoming.to_email_message("rcpt@example.com");
406
407        assert_eq!(message.subject(), "Important Update");
408    }
409
410    #[test]
411    fn test_incoming_message_to_email_message_generates_unique_ids() {
412        let incoming = IncomingMessage {
413            from: "sender@example.com".to_string(),
414            rcpts: HashSet::from(["rcpt@example.com".to_string()]),
415            raw: "Body".to_string(),
416            client_ip: None,
417            helo_domain: None,
418        };
419
420        let msg1 = incoming.to_email_message("rcpt@example.com");
421        let msg2 = incoming.to_email_message("rcpt@example.com");
422
423        assert_ne!(msg1.message_id, msg2.message_id);
424    }
425}