Skip to main content

nestrs_mail/
lib.rs

1//! NestJS [`@nestjs/mail`](https://docs.nestjs.com/techniques/mail) analogue —
2//! templated email with HTML-escaped `{{placeholders}}`, pluggable transports,
3//! and an in-memory inbox for tests.
4//!
5//! ```ignore
6//! use nestrs_mail::{render_template, MailMessage, MailModule, MailOptions, MailService};
7//! use std::collections::HashMap;
8//!
9//! let dm = MailModule::register(MailOptions::default());
10//! # async fn send(mail: std::sync::Arc<MailService>) {
11//! let mut data = HashMap::new();
12//! data.insert("name".into(), "<b>Ada</b>".into());
13//! let html = render_template("<p>Hi {{name}}</p>", &data);
14//! mail.send(MailMessage {
15//!     to: "a@b.com".into(),
16//!     subject: "Hi".into(),
17//!     html,
18//!     idempotency_key: None,
19//! }).await.unwrap();
20//! # }
21//! ```
22//!
23//! Umbrella feature: `nestrs` / `mail` → `nestrs::mail`.
24#![doc(html_root_url = "https://docs.rs/nestrs-mail/1.7.1")]
25
26mod module;
27mod options;
28mod transport;
29
30pub use module::MailModule;
31pub use options::{MailMessage, MailOptions};
32#[cfg(feature = "http")]
33pub use transport::HttpMailTransport;
34pub use transport::{InMemoryMailTransport, MailTransport, SharedMailTransport};
35
36pub use nestrs_core::{DynamicModule, Injectable, ProviderRegistry};
37use std::collections::HashMap;
38use std::sync::Arc;
39
40#[derive(Debug, Clone)]
41pub enum MailError {
42    Transport(String),
43    Permanent(String),
44}
45
46impl std::fmt::Display for MailError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::Transport(m) | Self::Permanent(m) => write!(f, "{m}"),
50        }
51    }
52}
53
54impl std::error::Error for MailError {}
55
56impl MailError {
57    pub fn permanent(&self) -> bool {
58        matches!(self, Self::Permanent(_))
59    }
60}
61
62/// Escape and substitute `{{key}}` placeholders (Nest built-in engine analogue).
63pub fn render_template(template: &str, data: &HashMap<String, String>) -> String {
64    let mut out = template.to_string();
65    for (k, v) in data {
66        let needle = format!("{{{{{k}}}}}");
67        out = out.replace(&needle, &html_escape::encode_text(v));
68    }
69    out
70}
71
72pub struct MailService {
73    transport: SharedMailTransport,
74}
75
76impl MailService {
77    pub fn from_options(opts: &MailOptions) -> Self {
78        Self {
79            transport: opts.transport.clone(),
80        }
81    }
82
83    pub async fn send(&self, message: MailMessage) -> Result<(), MailError> {
84        self.transport.send(message).await
85    }
86
87    /// Test helper when using [`InMemoryMailTransport`].
88    pub fn transport(&self) -> SharedMailTransport {
89        self.transport.clone()
90    }
91}
92
93impl Injectable for MailService {
94    fn construct(_registry: &nestrs_core::ProviderRegistry) -> Arc<Self> {
95        panic!("MailService must be provided by MailModule::register(...)");
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::transport::InMemoryMailTransport;
103
104    #[tokio::test]
105    async fn renders_and_sends_to_memory() {
106        let mem = InMemoryMailTransport::new();
107        let svc = MailService {
108            transport: mem.clone(),
109        };
110        let mut data = HashMap::new();
111        data.insert("name".into(), "<script>".into());
112        let body = render_template("Hello {{name}}", &data);
113        assert!(body.contains("&lt;script&gt;"));
114        svc.send(MailMessage {
115            to: "a@b.com".into(),
116            subject: "Hi".into(),
117            html: body,
118            idempotency_key: Some("k1".into()),
119        })
120        .await
121        .unwrap();
122        let inbox = mem.inbox().await;
123        assert_eq!(inbox.len(), 1);
124    }
125}