mailgun_rs/
lib.rs

1use reqwest::Error as ReqError;
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::fmt;
5use thiserror::Error;
6use typed_builder::TypedBuilder;
7
8const MESSAGES_ENDPOINT: &str = "messages";
9
10pub enum MailgunRegion {
11    US,
12    EU,
13}
14
15#[derive(Debug, Clone)]
16pub enum AttachmentType {
17    Attachment,
18    Inline,
19}
20
21#[derive(Debug, Clone, TypedBuilder)]
22pub struct Attachment {
23    #[builder(setter(into))]
24    pub path: String,
25    #[builder(default = AttachmentType::Attachment)]
26    pub attachment_type: AttachmentType,
27}
28
29impl From<String> for Attachment {
30    fn from(path: String) -> Self {
31        Attachment {
32            path,
33            attachment_type: AttachmentType::Attachment,
34        }
35    }
36}
37
38impl From<&str> for Attachment {
39    fn from(path: &str) -> Self {
40        Attachment {
41            path: path.to_string(),
42            attachment_type: AttachmentType::Attachment,
43        }
44    }
45}
46
47fn get_base_url(region: MailgunRegion) -> &'static str {
48    match region {
49        MailgunRegion::US => "https://api.mailgun.net/v3",
50        MailgunRegion::EU => "https://api.eu.mailgun.net/v3",
51    }
52}
53
54#[derive(Default, Debug)]
55pub struct Mailgun {
56    pub api_key: String,
57    pub domain: String,
58}
59
60#[derive(Debug, Error)]
61pub enum SendError {
62    #[error("reqwest error: {0}")]
63    Req(#[from] ReqError),
64
65    #[error("io error while reading `{path}`: {source}")]
66    IoWithPath {
67        path: String,
68        #[source]
69        source: std::io::Error,
70    },
71}
72
73pub type SendResult<T> = Result<T, SendError>;
74
75#[derive(Deserialize, Debug, PartialEq)]
76pub struct SendResponse {
77    pub message: String,
78    pub id: String,
79}
80
81impl Mailgun {
82    pub fn send(
83        &self,
84        region: MailgunRegion,
85        sender: &EmailAddress,
86        message: Message,
87        attachments: Option<Vec<Attachment>>,
88    ) -> SendResult<SendResponse> {
89        let client = reqwest::blocking::Client::new();
90        let mut params = message.params();
91        params.insert("from".to_string(), sender.to_string());
92
93        let mut form = reqwest::blocking::multipart::Form::new();
94
95        for (key, value) in params {
96            form = form.text(key, value);
97        }
98
99        for attachment in attachments.unwrap_or_default() {
100            let field_name = match attachment.attachment_type {
101                AttachmentType::Attachment => "attachment",
102                AttachmentType::Inline => "inline",
103            };
104
105            form =
106                form.file(field_name, &attachment.path)
107                    .map_err(|err| SendError::IoWithPath {
108                        path: attachment.path.clone(),
109                        source: err,
110                    })?;
111        }
112
113        let url = format!(
114            "{}/{}/{}",
115            get_base_url(region),
116            self.domain,
117            MESSAGES_ENDPOINT
118        );
119
120        let res = client
121            .post(url)
122            .basic_auth("api", Some(self.api_key.clone()))
123            .multipart(form)
124            .send()?
125            .error_for_status()?;
126
127        let parsed: SendResponse = res.json()?;
128        Ok(parsed)
129    }
130
131    pub async fn async_send(
132        &self,
133        region: MailgunRegion,
134        sender: &EmailAddress,
135        message: Message,
136        attachments: Option<Vec<Attachment>>,
137    ) -> SendResult<SendResponse> {
138        let client = reqwest::Client::new();
139        let mut params = message.params();
140        params.insert("from".to_string(), sender.to_string());
141
142        let mut form = reqwest::multipart::Form::new();
143
144        for (key, value) in params {
145            form = form.text(key, value);
146        }
147
148        for attachment in attachments.unwrap_or_default() {
149            let field_name = match attachment.attachment_type {
150                AttachmentType::Attachment => "attachment",
151                AttachmentType::Inline => "inline",
152            };
153
154            form = form
155                .file(field_name, &attachment.path)
156                .await
157                .map_err(|err| SendError::IoWithPath {
158                    path: attachment.path.clone(),
159                    source: err,
160                })?;
161        }
162
163        let url = format!(
164            "{}/{}/{}",
165            get_base_url(region),
166            self.domain,
167            MESSAGES_ENDPOINT
168        );
169
170        let res = client
171            .post(url)
172            .basic_auth("api", Some(self.api_key.clone()))
173            .multipart(form)
174            .send()
175            .await?
176            .error_for_status()?;
177
178        let parsed: SendResponse = res.json().await?;
179        Ok(parsed)
180    }
181}
182
183#[derive(TypedBuilder, Default, Debug, PartialEq, Eq, Clone)]
184pub struct Message {
185    #[builder(setter(into))]
186    pub to: Vec<EmailAddress>,
187    #[builder(default, setter(into))]
188    pub cc: Vec<EmailAddress>,
189    #[builder(default, setter(into))]
190    pub bcc: Vec<EmailAddress>,
191    #[builder(setter(into))]
192    pub subject: String,
193    #[builder(default, setter(into))]
194    pub text: String,
195    #[builder(default, setter(into))]
196    pub html: String,
197    #[builder(default, setter(into))]
198    pub template: String,
199    #[builder(default)]
200    pub template_vars: HashMap<String, String>,
201    #[builder(default)]
202    pub template_json: Option<serde_json::Value>,
203}
204
205impl Message {
206    fn params(self) -> HashMap<String, String> {
207        let mut params = HashMap::new();
208
209        Message::add_recipients("to", self.to, &mut params);
210        Message::add_recipients("cc", self.cc, &mut params);
211        Message::add_recipients("bcc", self.bcc, &mut params);
212
213        params.insert(String::from("subject"), self.subject);
214
215        params.insert(String::from("text"), self.text);
216        params.insert(String::from("html"), self.html);
217
218        // add template
219        if !self.template.is_empty() {
220            params.insert(String::from("template"), self.template);
221            if let Some(template_json) = self.template_json {
222                params.insert(
223                    String::from("h:X-Mailgun-Variables"),
224                    serde_json::to_string(&template_json).unwrap(),
225                );
226            } else {
227                params.insert(
228                    String::from("h:X-Mailgun-Variables"),
229                    serde_json::to_string(&self.template_vars).unwrap(),
230                );
231            }
232        }
233
234        params
235    }
236
237    fn add_recipients(
238        field: &str,
239        addresses: Vec<EmailAddress>,
240        params: &mut HashMap<String, String>,
241    ) {
242        if !addresses.is_empty() {
243            let joined = addresses
244                .iter()
245                .map(EmailAddress::to_string)
246                .collect::<Vec<String>>()
247                .join(",");
248            params.insert(field.to_owned(), joined);
249        }
250    }
251}
252
253#[derive(TypedBuilder, Debug, PartialEq, Eq, Clone)]
254pub struct EmailAddress {
255    name: Option<String>,
256    address: String,
257}
258
259impl EmailAddress {
260    pub fn address(address: &str) -> Self {
261        EmailAddress {
262            name: None,
263            address: address.to_string(),
264        }
265    }
266
267    pub fn name_address(name: &str, address: &str) -> Self {
268        EmailAddress {
269            name: Some(name.to_string()),
270            address: address.to_string(),
271        }
272    }
273}
274
275impl fmt::Display for EmailAddress {
276    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
277        match self.name {
278            Some(ref name) => write!(f, "{} <{}>", name, self.address),
279            None => write!(f, "{}", self.address),
280        }
281    }
282}
283
284impl From<&str> for EmailAddress {
285    fn from(address: &str) -> Self {
286        EmailAddress::address(address)
287    }
288}
289
290impl From<(&str, &str)> for EmailAddress {
291    fn from((name, address): (&str, &str)) -> Self {
292        EmailAddress::name_address(name, address)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn typed_builder_should_work() {
302        let message = Message::builder()
303            .to(vec!["example@example.com".into()])
304            .cc(vec![("Eren", "eren@redmc.me").into()])
305            .text("")
306            .html("<h1>Hello</h1>")
307            .subject("Hello")
308            .template("template")
309            .template_vars([("name".into(), "value".into())].iter().cloned().collect())
310            .build();
311        assert_eq!(
312            message,
313            Message {
314                to: vec![EmailAddress {
315                    name: None,
316                    address: "example@example.com".to_string()
317                }],
318                cc: vec![EmailAddress {
319                    name: Some("Eren".to_string()),
320                    address: "eren@redmc.me".to_string()
321                }],
322                bcc: vec![],
323                subject: "Hello".to_string(),
324                text: "".to_string(),
325                html: "<h1>Hello</h1>".to_string(),
326                template: "template".to_string(),
327                template_vars: [("name".into(), "value".into())].iter().cloned().collect(),
328                template_json: None,
329            }
330        );
331    }
332}