mailhog_rs/
lib.rs

1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use reqwest::header::ACCEPT;
4use std::cmp::Ordering;
5use std::collections::HashMap;
6use std::fmt::{Display, Formatter};
7
8const APPLICATION_JSON: &str = "application/json";
9
10#[derive(Debug, Clone)]
11pub struct MailHog {
12    client: reqwest::Client,
13    base_url: String,
14}
15
16#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
17pub struct ListMessagesParams {
18    pub start: Option<i64>,
19    pub limit: Option<i64>,
20}
21
22#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
23pub enum SearchKind {
24    #[serde(rename = "from")]
25    From,
26    #[serde(rename = "to")]
27    To,
28    #[serde(rename = "containing")]
29    Containing,
30}
31
32#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
33pub struct SearchParams {
34    pub kind: SearchKind,
35    pub query: String,
36    pub start: Option<i64>,
37    pub limit: Option<i64>,
38}
39
40#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
41pub struct EmailAddr {
42    #[serde(rename = "Mailbox")]
43    pub mailbox: String,
44    #[serde(rename = "Domain")]
45    pub domain: String,
46    #[serde(rename = "Params")]
47    pub params: String,
48    #[serde(rename = "Relays")]
49    pub relays: Option<String>,
50}
51
52impl Display for EmailAddr {
53    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}@{}", self.mailbox, self.domain)
55    }
56}
57
58#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
59pub struct MessageContent {
60    #[serde(rename = "Headers")]
61    pub headers: HashMap<String, Vec<String>>,
62    #[serde(rename = "Body")]
63    pub body: String,
64    #[serde(rename = "Size")]
65    pub size: usize,
66    #[serde(rename = "MIME")]
67    pub mime: Option<String>,
68}
69
70#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
71pub struct Message {
72    #[serde(rename = "ID")]
73    pub id: String,
74    #[serde(rename = "From")]
75    pub from: EmailAddr,
76    #[serde(rename = "To")]
77    pub to: Vec<EmailAddr>,
78    #[serde(rename = "Content")]
79    pub content: MessageContent,
80    #[serde(rename = "Created")]
81    pub created: DateTime<Utc>,
82}
83
84impl PartialOrd<Self> for Message {
85    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
86        self.created.partial_cmp(&other.created)
87    }
88}
89
90impl Ord for Message {
91    fn cmp(&self, other: &Self) -> Ordering {
92        self.created.cmp(&other.created)
93    }
94}
95
96#[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
97pub struct MessageList {
98    pub total: i64,
99    pub start: i64,
100    pub count: i64,
101    #[serde(default)]
102    pub items: Vec<Message>,
103}
104
105impl MailHog {
106    pub fn new(base_url: String) -> MailHog {
107        MailHog {
108            client: reqwest::Client::new(),
109            base_url,
110        }
111    }
112
113    pub async fn list_messages(&self, params: ListMessagesParams) -> Result<MessageList> {
114        Ok(self
115            .client
116            .execute(
117                self.client
118                    .get(format!("{}/api/v2/messages", self.base_url))
119                    .query(&params)
120                    .header(ACCEPT, APPLICATION_JSON)
121                    .build()?,
122            )
123            .await?
124            .error_for_status()?
125            .json()
126            .await?)
127    }
128
129    pub async fn search(&self, params: SearchParams) -> Result<MessageList> {
130        Ok(self
131            .client
132            .execute(
133                self.client
134                    .get(format!("{}/api/v2/search", self.base_url))
135                    .query(&params)
136                    .header(ACCEPT, APPLICATION_JSON)
137                    .build()?,
138            )
139            .await?
140            .error_for_status()?
141            .json()
142            .await?)
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use crate::{ListMessagesParams, MailHog, SearchKind, SearchParams};
149    use chrono::Utc;
150    use lettre::transport::smtp::client::Tls;
151    use lettre::{Message, SmtpTransport, Transport};
152    use rand::distributions::Alphanumeric;
153    use rand::{thread_rng, Rng};
154    use testcontainers::clients::Cli;
155    use testcontainers::images::generic::GenericImage;
156    use testcontainers::Container;
157
158    const SMTP_PORT: u16 = 1025;
159    const HTTP_PORT: u16 = 8025;
160
161    struct TestEnv<'a> {
162        mh: MailHog,
163        mailer: SmtpTransport,
164        _container: Container<'a, GenericImage>,
165    }
166
167    struct MsgDetails {
168        to: String,
169        from: String,
170        subject: String,
171        body: String,
172    }
173
174    #[derive(Debug, Default, Clone)]
175    struct MakeMessagesParams {
176        to: Option<String>,
177        from: Option<String>,
178        subject: Option<String>,
179        body: Option<String>,
180    }
181
182    fn make_rand_str(n: usize) -> String {
183        thread_rng()
184            .sample_iter(&Alphanumeric)
185            .take(n)
186            .map(char::from)
187            .collect()
188    }
189
190    fn make_rand_email_addr(domain: Option<String>) -> String {
191        format!(
192            "{}@{}",
193            make_rand_str(10),
194            domain.unwrap_or_else(|| format!("{}.com", make_rand_str(10)))
195        )
196    }
197
198    fn make_rand_messages(n: usize, params: MakeMessagesParams) -> Vec<MsgDetails> {
199        (0..n)
200            .map(|_| MsgDetails {
201                to: params
202                    .to
203                    .clone()
204                    .unwrap_or_else(|| make_rand_email_addr(None)),
205                from: params
206                    .from
207                    .clone()
208                    .unwrap_or_else(|| make_rand_email_addr(None)),
209                subject: params.subject.clone().unwrap_or_else(|| make_rand_str(50)),
210                body: params.body.clone().unwrap_or_else(|| make_rand_str(200)),
211            })
212            .collect()
213    }
214
215    fn make_rand_message(params: MakeMessagesParams) -> MsgDetails {
216        make_rand_messages(1, params).swap_remove(0)
217    }
218
219    fn setup(cli: &Cli) -> TestEnv {
220        let container = cli.run(
221            GenericImage::new("mailhog/mailhog", "v1.0.1")
222                .with_exposed_port(SMTP_PORT)
223                .with_exposed_port(HTTP_PORT),
224        );
225        let smtp_port = container.get_host_port_ipv4(SMTP_PORT);
226        let mailer = SmtpTransport::relay("localhost")
227            .unwrap()
228            .port(smtp_port)
229            .tls(Tls::None)
230            .build();
231
232        let http_port = container.get_host_port_ipv4(HTTP_PORT);
233        println!("mailhog http port: {}", http_port);
234        TestEnv {
235            _container: container,
236            mh: MailHog::new(format!("http://localhost:{}", http_port)),
237            mailer,
238        }
239    }
240
241    fn normalize_body(b: impl AsRef<str>) -> String {
242        b.as_ref().replace("=\r\n", "")
243    }
244
245    #[tokio::test]
246    async fn list_messages() {
247        let cli = Cli::docker();
248        let env = setup(&cli);
249        let mh = env.mh;
250        let mailer = env.mailer;
251
252        let message_list = mh
253            .list_messages(ListMessagesParams {
254                start: None,
255                limit: None,
256            })
257            .await
258            .unwrap();
259
260        assert_eq!(0, message_list.total);
261        assert_eq!(0, message_list.count);
262        assert_eq!(0, message_list.start);
263        assert_eq!(0, message_list.items.len());
264
265        let msg = make_rand_message(Default::default());
266        let from = msg.from.as_str();
267        let to = msg.to.as_str();
268        let subject = msg.subject.as_str();
269        let body = msg.body.as_str();
270        mailer
271            .send(
272                &Message::builder()
273                    .from(from.parse().unwrap())
274                    .to(to.parse().unwrap())
275                    .subject(subject)
276                    .body(body.to_string())
277                    .unwrap(),
278            )
279            .unwrap();
280
281        let message_list = mh
282            .list_messages(ListMessagesParams {
283                start: None,
284                limit: None,
285            })
286            .await
287            .unwrap();
288        assert_eq!(1, message_list.total);
289        assert_eq!(1, message_list.count);
290        assert_eq!(0, message_list.start);
291        assert_eq!(1, message_list.items.len());
292        let message = &message_list.items[0];
293        assert_eq!(from, message.from.to_string());
294        assert_eq!(1, message.to.len());
295        assert_eq!(to, message.to[0].to_string());
296        assert_eq!(body, normalize_body(message.content.body.as_str()));
297        assert!(message.content.size > body.len());
298        assert!(message.created < Utc::now());
299
300        const SUBJECT: &str = "Subject";
301        assert!(message.content.headers.get(SUBJECT).is_some());
302        assert_eq!(vec![subject], message.content.headers[SUBJECT]);
303    }
304
305    #[tokio::test]
306    async fn search_messages() {
307        let cli = Cli::docker();
308        let env = setup(&cli);
309        let mh = env.mh;
310        let mailer = env.mailer;
311
312        let from = make_rand_email_addr(None);
313        let to = make_rand_email_addr(None);
314        let subject_part = make_rand_str(10);
315        let subject = subject_part.clone() + " " + &make_rand_str(30);
316        let body_part = make_rand_str(10);
317        let body = body_part.clone() + " " + &make_rand_str(200);
318
319        let params = vec![
320            (
321                MakeMessagesParams {
322                    from: Some(from.to_string()),
323                    ..Default::default()
324                },
325                SearchParams {
326                    kind: SearchKind::From,
327                    query: from.to_string(),
328                    start: None,
329                    limit: None,
330                },
331            ),
332            (
333                MakeMessagesParams {
334                    to: Some(to.to_string()),
335                    ..Default::default()
336                },
337                SearchParams {
338                    kind: SearchKind::To,
339                    query: to.to_string(),
340                    start: None,
341                    limit: None,
342                },
343            ),
344            (
345                MakeMessagesParams {
346                    subject: Some(subject.to_string()),
347                    ..Default::default()
348                },
349                SearchParams {
350                    kind: SearchKind::Containing,
351                    query: subject_part.to_string(),
352                    start: None,
353                    limit: None,
354                },
355            ),
356            (
357                MakeMessagesParams {
358                    body: Some(body.to_string()),
359                    ..Default::default()
360                },
361                SearchParams {
362                    kind: SearchKind::Containing,
363                    query: body_part.to_string(),
364                    start: None,
365                    limit: None,
366                },
367            ),
368        ];
369
370        let num_messages = thread_rng().gen_range(1..50);
371
372        for (make_messages_params, search_params) in params {
373            let outbox = make_rand_messages(num_messages, make_messages_params);
374            for m in &outbox {
375                mailer
376                    .send(
377                        &Message::builder()
378                            .from(m.from.parse().unwrap())
379                            .to(m.to.parse().unwrap())
380                            .subject(&m.subject)
381                            .body(m.body.to_string())
382                            .unwrap(),
383                    )
384                    .unwrap();
385            }
386
387            let mut message_list = mh.search(search_params).await.unwrap();
388            message_list.items.sort();
389
390            assert_eq!(num_messages, message_list.total as usize);
391            assert_eq!(num_messages, message_list.count as usize);
392            assert_eq!(0, message_list.start);
393            assert_eq!(num_messages, message_list.items.len());
394
395            for (idx, message) in message_list.items.iter().enumerate() {
396                let from = outbox[idx].from.as_str();
397                let to = outbox[idx].to.as_str();
398                let subject = outbox[idx].subject.as_str();
399                let body = outbox[idx].body.as_str();
400
401                assert_eq!(from, message.from.to_string());
402                assert_eq!(1, message.to.len());
403                assert_eq!(to, message.to[0].to_string());
404                assert_eq!(body, normalize_body(message.content.body.as_str()));
405                assert!(message.content.size > body.len());
406                assert!(message.created < Utc::now());
407
408                const SUBJECT: &str = "Subject";
409                assert!(message.content.headers.get(SUBJECT).is_some());
410                assert_eq!(vec![subject], message.content.headers[SUBJECT]);
411            }
412        }
413    }
414}