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 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}