aster_forge_test/
smtp.rs

1//! Shared Mailpit container for integration tests.
2
3use crate::state::{ContainerLease, ContainerStateLock};
4use crate::suite::TestContainerSuite;
5use crate::wait::wait_until;
6use std::net::{SocketAddr, TcpStream};
7use std::time::Duration;
8use testcontainers::core::{ContainerAsync, IntoContainerPort};
9use testcontainers::{GenericImage, ImageExt, ReuseDirective, runners::AsyncRunner};
10
11/// Reusable Mailpit SMTP/API container.
12pub struct SmtpTestContainer {
13    smtp_address: SocketAddr,
14    api_base_url: String,
15    client: reqwest::Client,
16    _container: ContainerAsync<GenericImage>,
17    _lease: ContainerLease,
18}
19
20impl SmtpTestContainer {
21    /// Starts or reuses a Mailpit container for the test suite.
22    ///
23    /// # Panics
24    ///
25    /// Panics when shared state, container startup, port discovery, SMTP readiness, or HTTP API
26    /// readiness fails.
27    pub async fn start(suite: &TestContainerSuite) -> Self {
28        let lock = ContainerStateLock::acquire(suite, "mailpit");
29        let mut state = lock.load();
30        let _ = state.prune_stale_before_current_execution();
31        state.register_current_process();
32        lock.save(&state);
33        let container = GenericImage::new("axllent/mailpit", "v1.21.8")
34            .with_exposed_port(IntoContainerPort::tcp(1025))
35            .with_exposed_port(IntoContainerPort::tcp(8025))
36            .with_container_name(suite.container_name("mailpit"))
37            .with_reuse(ReuseDirective::Always)
38            .start()
39            .await
40            .expect("failed to start Mailpit test container");
41        let smtp_port = container
42            .get_host_port_ipv4(IntoContainerPort::tcp(1025))
43            .await
44            .expect("Mailpit SMTP port should be exposed");
45        let api_port = container
46            .get_host_port_ipv4(IntoContainerPort::tcp(8025))
47            .await
48            .expect("Mailpit API port should be exposed");
49        let smtp_address = SocketAddr::from(([127, 0, 0, 1], smtp_port));
50        let smtp_ready = wait_until(
51            Duration::from_secs(90),
52            Duration::from_millis(250),
53            || async {
54                TcpStream::connect_timeout(&smtp_address, Duration::from_millis(500)).is_ok()
55            },
56        )
57        .await;
58        assert!(smtp_ready, "Mailpit SMTP endpoint did not become ready");
59
60        let api_base_url = format!("http://127.0.0.1:{api_port}");
61        let client = reqwest::Client::new();
62        let api_ready = wait_until(
63            Duration::from_secs(90),
64            Duration::from_millis(250),
65            || async {
66                client
67                    .get(format!("{api_base_url}/api/v1/messages"))
68                    .send()
69                    .await
70                    .is_ok_and(|response| response.status().is_success())
71            },
72        )
73        .await;
74        assert!(api_ready, "Mailpit API endpoint did not become ready");
75        drop(lock);
76
77        Self {
78            smtp_address,
79            api_base_url,
80            client,
81            _container: container,
82            _lease: ContainerLease::new(suite.clone(), "mailpit"),
83        }
84    }
85
86    /// Returns the SMTP endpoint host and port.
87    #[must_use]
88    pub fn smtp_address(&self) -> SocketAddr {
89        self.smtp_address
90    }
91
92    /// Deletes all messages currently stored by Mailpit.
93    ///
94    /// # Panics
95    ///
96    /// Panics when the HTTP request fails or Mailpit returns a non-success status.
97    pub async fn clear_messages(&self) {
98        let response = self
99            .client
100            .delete(format!("{}/api/v1/messages", self.api_base_url))
101            .send()
102            .await
103            .expect("failed to clear Mailpit messages");
104        assert!(
105            response.status().is_success(),
106            "Mailpit message cleanup failed with {}",
107            response.status()
108        );
109    }
110
111    /// Returns the number of messages currently stored by Mailpit.
112    ///
113    /// # Panics
114    ///
115    /// Panics when the HTTP request fails, the response is unsuccessful or invalid JSON, or the
116    /// response omits its numeric `total` field.
117    pub async fn message_count(&self) -> u64 {
118        let response = self
119            .client
120            .get(format!("{}/api/v1/messages", self.api_base_url))
121            .send()
122            .await
123            .expect("failed to query Mailpit messages");
124        let status = response.status();
125        let body: serde_json::Value = response
126            .json()
127            .await
128            .expect("failed to decode Mailpit messages response");
129        assert!(status.is_success(), "Mailpit API failed: {body}");
130        body["total"]
131            .as_u64()
132            .expect("Mailpit response should include total")
133    }
134}