ft_sys_shared/email/
mod.rs

1#[cfg(feature = "host-only")]
2mod sqlite;
3#[cfg(feature = "host-only")]
4pub use sqlite::EmailBind;
5
6#[derive(Debug, thiserror::Error)]
7pub enum SendEmailError {
8    #[error("email not allowed: {0}")]
9    EmailNotAllowed(String),
10}
11
12#[derive(Debug, thiserror::Error)]
13pub enum CancelEmailError {
14    #[error("unknown handle")]
15    UnknownHandle,
16}
17
18/// add an email to the offline email queue, so that the email can be sent later. these emails
19/// get picked up by the email worker.
20///
21/// # Arguments
22///
23/// * `from` - [EmailAddress]
24/// * `to` - [smallvec::SmallVec<EmailAddress, 1>]
25/// * `cc`, `bcc` - [smallvec::SmallVec<EmailAddress, 0>]
26/// * `mkind` - mkind is any string, used for product analytics, etc. the value should be dotted,
27///   e.g., x.y.z to capture hierarchy. ideally you should use `marketing.` as the prefix for all
28///   marketing related emails, and anything else for transaction mails, so your mailer can
29///   use appropriate channels. `/<app-url>/mail/<mkind>/` is the endpoint where the email content
30///   is fetched from.
31/// * `content`: [EmailContent]
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct Email {
34    pub from: EmailAddress,
35    pub to: smallvec::SmallVec<EmailAddress, 1>,
36    pub reply_to: Option<smallvec::SmallVec<EmailAddress, 1>>,
37    pub cc: smallvec::SmallVec<EmailAddress, 0>,
38    pub bcc: smallvec::SmallVec<EmailAddress, 0>,
39    pub mkind: String,
40    pub content: EmailContent,
41}
42
43impl Email {
44    pub fn merge_context(
45        &self,
46        context: Option<serde_json::Map<String, serde_json::Value>>,
47    ) -> Result<serde_json::Map<String, serde_json::Value>, serde_json::Error> {
48        let mut context = context.unwrap_or_default();
49        context.insert("from".to_string(), serde_json::to_value(&self.from)?);
50        context.insert("to".to_string(), serde_json::to_value(&self.to)?);
51        if let Some(ref reply_to) = self.reply_to {
52            context.insert("reply_to".to_string(), serde_json::to_value(reply_to)?);
53        }
54        context.insert("cc".to_string(), serde_json::to_value(&self.cc)?);
55        context.insert("bcc".to_string(), serde_json::to_value(&self.bcc)?);
56        context.insert("mkind".to_string(), serde_json::to_value(&self.mkind)?);
57        Ok(context)
58    }
59}
60
61/// The content of the email to send. Most fastn apps *should prefer* [EmailContent::FromMKind] as
62/// that allows end users of the fastn app to configure the email easily. The
63/// [EmailContent::Rendered] variant is allowed if you want to generate emails though some other
64/// mechanism.
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
66pub enum EmailContent {
67    Rendered(RenderedEmail),
68    /// You can pass context data to [EmailContent::FromMKind] to be used when rendering the email
69    /// content. The `context` is passed to `/<app-url>/mail/<mkind>/` as request data, and can be
70    /// used by the templating layer to include in the subject/html/text content of the mail.
71    FromMKind {
72        context: Option<serde_json::Map<String, serde_json::Value>>,
73    },
74}
75
76#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
77pub struct RenderedEmail {
78    subject: String,
79    #[serde(rename = "html")]
80    body_html: String,
81    #[serde(rename = "text")]
82    body_text: String,
83}
84
85impl Default for EmailContent {
86    fn default() -> Self {
87        EmailContent::FromMKind { context: None }
88    }
89}
90
91impl Email {
92    pub fn new(from: EmailAddress, to: EmailAddress, mkind: &str, content: EmailContent) -> Self {
93        Email {
94            from,
95            to: smallvec::smallvec![to],
96            reply_to: None,
97            cc: smallvec::smallvec![],
98            bcc: smallvec::smallvec![],
99            mkind: mkind.to_string(),
100            content,
101        }
102    }
103}
104
105#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
106pub struct EmailAddress {
107    pub name: Option<String>,
108    pub email: String,
109}
110
111impl std::fmt::Display for EmailAddress {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        let str = match self.name {
114            Some(ref name) => format!("{name} <{}>", self.email),
115            None => self.email.to_string(),
116        };
117        write!(f, "{}", str)
118    }
119}
120
121impl From<(String, String)> for EmailAddress {
122    fn from((name, email): (String, String)) -> Self {
123        // todo: validate email?
124        EmailAddress {
125            name: Some(name),
126            email,
127        }
128    }
129}
130
131impl From<String> for EmailAddress {
132    fn from(email: String) -> Self {
133        let email = email.trim().to_string();
134
135        // handle both cases where the name is present and where its just email address
136        if let Some(i) = email.find('<') {
137            let name = email[..i].to_string();
138            let email = email[i + 1..].to_string();
139            EmailAddress {
140                name: Some(name),
141                email,
142            }
143        } else {
144            EmailAddress { name: None, email }
145        }
146    }
147}
148
149/// [ft_sdk::send_mail()] returns an [EmailHandle], which can be used to cancel the email during the
150/// web request. this is useful in case you want to do a cleanup in case a transaction fails, etc.
151#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
152pub struct EmailHandle(String);
153
154#[cfg(feature = "host-only")]
155impl EmailHandle {
156    #[doc(hidden)]
157    pub fn new(handle: String) -> Self {
158        Self(handle)
159    }
160
161    #[doc(hidden)]
162    pub fn inner(&self) -> &str {
163        &self.0
164    }
165}