Skip to main content

mailsis_utils/transformers/
message_id.rs

1//! Ensure every email carries a `Message-ID` MIME header.
2//!
3//! Some clients omit the `Message-ID` header, which breaks threading and
4//! de-duplication in downstream systems. This transformer inspects the
5//! incoming email: if a `Message-ID` already exists it is preserved,
6//! otherwise one is generated using the configured domain.
7
8use tracing::{debug, info};
9
10use crate::{EmailMessage, MessageTransformer, TransformFuture};
11
12/// Transformer that ensures every email has a `Message-ID` MIME header.
13///
14/// If the body already contains a `Message-ID` header, the struct's
15/// [`EmailMessage::message_id`] field is updated to match it. Otherwise, a new header
16/// is prepended using the existing [`EmailMessage::message_id`] and the configured domain.
17pub struct MessageIdTransformer {
18    domain: String,
19}
20
21impl MessageIdTransformer {
22    /// Creates a new [`MessageIdTransformer`] with the given domain for generated IDs.
23    pub fn new(domain: String) -> Self {
24        info!(domain = %domain, "Message-ID transformer initialized");
25        Self { domain }
26    }
27}
28
29impl MessageTransformer for MessageIdTransformer {
30    fn transform<'a>(&'a self, message: &'a mut EmailMessage) -> TransformFuture<'a> {
31        Box::pin(async move {
32            if let Some(existing_id) = message.header("Message-ID") {
33                let cleaned = existing_id
34                    .strip_prefix('<')
35                    .and_then(|s| s.strip_suffix('>'))
36                    .unwrap_or(existing_id)
37                    .to_string();
38                debug!(
39                    old_id = %message.message_id,
40                    mime_id = %cleaned,
41                    "Syncing message_id from existing MIME header"
42                );
43                message.message_id = cleaned;
44            } else {
45                let value = format!("<{}@{}>", message.message_id, self.domain);
46                debug!(
47                    message_id = %message.message_id,
48                    "Injecting Message-ID header"
49                );
50                message.prepend_header("Message-ID", &value);
51            }
52        })
53    }
54
55    fn name(&self) -> &str {
56        "message_id"
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[tokio::test]
65    async fn test_inject_message_id_when_missing() {
66        let transformer = MessageIdTransformer::new("example.com".to_string());
67        let mut message = EmailMessage::from_raw(
68            "sender@example.com",
69            "rcpt@example.com",
70            "Subject: Hello\r\n\r\nBody text",
71        );
72        let original_id = message.message_id.clone();
73
74        transformer.transform(&mut message).await;
75        message.rebuild();
76
77        assert!(message
78            .raw()
79            .starts_with(&format!("Message-ID: <{original_id}@example.com>\r\n")));
80        assert_eq!(message.message_id, original_id);
81    }
82
83    #[tokio::test]
84    async fn test_sync_message_id_from_existing_header() {
85        let transformer = MessageIdTransformer::new("example.com".to_string());
86        let mut message = EmailMessage::from_raw(
87            "sender@example.com",
88            "rcpt@example.com",
89            "Message-ID: <abc123@mail.example.com>\r\nSubject: Hello\r\n\r\nBody text",
90        );
91
92        transformer.transform(&mut message).await;
93
94        assert_eq!(message.message_id, "abc123@mail.example.com");
95        // Body unchanged (no prepend_header called, no rebuild needed)
96        assert!(message.raw().starts_with("Message-ID:"));
97    }
98
99    #[tokio::test]
100    async fn test_sync_message_id_case_insensitive() {
101        let transformer = MessageIdTransformer::new("example.com".to_string());
102        let mut message = EmailMessage::from_raw(
103            "sender@example.com",
104            "rcpt@example.com",
105            "message-id: <lowercase@example.com>\r\nSubject: Test\r\n\r\nBody",
106        );
107
108        transformer.transform(&mut message).await;
109
110        assert_eq!(message.message_id, "lowercase@example.com");
111    }
112
113    #[tokio::test]
114    async fn test_inject_into_plain_text_body() {
115        let transformer = MessageIdTransformer::new("localhost".to_string());
116        let mut message =
117            EmailMessage::from_raw("sender@example.com", "rcpt@example.com", "Just plain text");
118        let original_id = message.message_id.clone();
119
120        transformer.transform(&mut message).await;
121        message.rebuild();
122
123        assert!(message
124            .raw()
125            .starts_with(&format!("Message-ID: <{original_id}@localhost>\r\n")));
126        assert!(message.raw().ends_with("Just plain text"));
127    }
128
129    #[tokio::test]
130    async fn test_apply_transformers() {
131        let transformers: Vec<Box<dyn MessageTransformer>> = vec![Box::new(
132            MessageIdTransformer::new("example.com".to_string()),
133        )];
134        let mut message = EmailMessage::from_raw(
135            "sender@example.com",
136            "rcpt@example.com",
137            "Subject: Test\r\n\r\nBody",
138        );
139
140        <MessageIdTransformer as MessageTransformer>::apply(&transformers, &mut message).await;
141
142        assert!(message.raw().contains("Message-ID:"));
143    }
144
145    #[tokio::test]
146    async fn test_message_id_without_angle_brackets() {
147        let transformer = MessageIdTransformer::new("example.com".to_string());
148        let mut message = EmailMessage::from_raw(
149            "sender@example.com",
150            "rcpt@example.com",
151            "Message-ID: bare-id@example.com\r\nSubject: Test\r\n\r\nBody",
152        );
153
154        transformer.transform(&mut message).await;
155
156        assert_eq!(message.message_id, "bare-id@example.com");
157    }
158}