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(¶ms)
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(¶ms)
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}