async_mailer_outlook/lib.rs
1//! An Outlook mailer, usable either stand-alone or as either generic `Mailer` or dynamic `dyn DynMailer`.
2//!
3//! **Preferably, use [`async-mailer`](https://docs.rs/async-mailer), which re-exports from this crate,
4//! rather than using `async-mailer-outlook` directly.**
5//!
6//! You can control the re-exported mailer implementations,
7//! as well as [`tracing`](https://docs.rs/crate/tracing) support,
8//! via [`async-mailer` feature toggles](https://docs.rs/crate/async-mailer/latest/features).
9//!
10//! # Examples
11//!
12//! ## Using the statically typed `Mailer`:
13//!
14//! ```no_run
15//! # async fn test() -> Result<(), Box<dyn std::error::Error>> {
16//! // Both `async_mailer::OutlookMailer` and `async_mailer::SmtpMailer` implement `Mailer`
17//! // and can be used with `impl Mailer` or `<M: Mailer>` bounds.
18//!
19//! # use async_mailer_outlook::OutlookMailer;
20//! let mailer = OutlookMailer::new(
21//! "<Microsoft Identity service tenant>".into(),
22//! "<OAuth2 app GUID>".into(),
23//! secrecy::SecretString::from("<OAuth2 app secret>")
24//! ).await?;
25//!
26//! // An alternative `SmtpMailer` can be found at `async-mailer-smtp`.
27//! // Further alternative mailers can be implemented by third parties.
28//!
29//! // Build a message using the re-exported `mail_builder::MessageBuilder'.
30//! //
31//! // For blazingly fast rendering of beautiful HTML mail,
32//! // I recommend combining `askama` with `mrml`.
33//!
34//! # use async_mailer_core::mail_send::smtp::message::IntoMessage;
35//! let message = async_mailer_core::mail_send::mail_builder::MessageBuilder::new()
36//! .from(("From Name", "from@example.com"))
37//! .to("to@example.com")
38//! .subject("Subject")
39//! .text_body("Mail body")
40//! .into_message()?;
41//!
42//! // Send the message using the statically typed `Mailer`.
43//!
44//! # use async_mailer_core::Mailer;
45//! mailer.send_mail(message).await?;
46//! # Ok(())
47//! # }
48//! ```
49//!
50//! ## Using the dynamically typed `DynMailer`:
51//!
52//! ```no_run
53//! # async fn test() -> Result<(), async_mailer_core::DynMailerError> {
54//! // Both `async_mailer::OutlookMailer` and `async_mailer::SmtpMailer`
55//! // implement `DynMailer` and can be used as trait objects.
56//! //
57//! // Here they are used as `BoxMailer`, which is an alias to `Box<dyn DynMailer>`.
58//!
59//! # use async_mailer_core::BoxMailer;
60//! # use async_mailer_outlook::OutlookMailer;
61//! let mailer: BoxMailer = OutlookMailer::new_box( // Or `OUtlookMailer::new_arc()`.
62//! "<Microsoft Identity service tenant>".into(),
63//! "<OAuth2 app GUID>".into(),
64//! secrecy::SecretString::from("<OAuth2 app secret>")
65//! ).await?;
66//!
67//! // An alternative `SmtpMailer` can be found at `async-mailer-smtp`.
68//! // Further alternative mailers can be implemented by third parties.
69//!
70//! // The trait object is `Send` and `Sync` and may be stored e.g. as part of your server state.
71//!
72//! // Build a message using the re-exported `mail_builder::MessageBuilder'.
73//! //
74//! // For blazingly fast rendering of beautiful HTML mail,
75//! // I recommend combining `askama` with `mrml`.
76//!
77//! # use async_mailer_core::mail_send::smtp::message::IntoMessage;
78//! let message = async_mailer_core::mail_send::mail_builder::MessageBuilder::new()
79//! .from(("From Name", "from@example.com"))
80//! .to("to@example.com")
81//! .subject("Subject")
82//! .text_body("Mail body")
83//! .into_message()?;
84//!
85//! // Send the message using the implementation-agnostic `dyn DynMailer`.
86//!
87//! mailer.send_mail(message).await?;
88//! # Ok(())
89//! # }
90//! ```
91//!
92//! # Feature flags
93//!
94//! - `tracing`: Enable debug and error logging using the [`tracing`](https://docs.rs/crate/tracing) crate.
95//! All relevant functions are instrumented.
96//!
97//! Default: `tracing`.
98//!
99//! ## Roadmap
100//!
101//! Access token auto-refresh is planned to be implemented on the [`OutlookMailer`].
102
103use std::sync::Arc;
104
105use async_trait::async_trait;
106use base64::{engine::general_purpose::STANDARD as base64_engine, Engine as _};
107use reqwest::header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE};
108use secrecy::{ExposeSecret, SecretString};
109use serde::Deserialize;
110
111#[cfg(feature = "tracing")]
112use tracing::{debug, error, info, instrument};
113
114use async_mailer_core::mail_send::smtp::message::Message;
115use async_mailer_core::{util, ArcMailer, BoxMailer, DynMailer, DynMailerError, Mailer};
116
117/// Error returned by [`OutlookMailer::new`] and [`OutlookMailer::send_mail`].
118#[derive(Debug, thiserror::Error)]
119pub enum OutlookMailerError {
120 /// Failed to retrieve Microsoft Graph API access token.
121 #[error("failed to retrieve Microsoft Graph API access token")]
122 RetrieveAccessToken(#[from] OutlookAccessTokenError),
123
124 /// Failed request attempting to send Outlook MIME mail through Microsoft Graph API.
125 #[error("failed request attempting to send Outlook MIME mail through Microsoft Graph API")]
126 SendMailRequest(reqwest::Error),
127
128 /// Failed sending Outlook MIME mail through Microsoft Graph API.
129 #[error("failed sending Outlook MIME mail through Microsoft Graph API")]
130 SendMailResponse(reqwest::Error),
131
132 /// Failed retrieving response body from Microsoft Graph API.
133 /// (Crate feature `tracing` only.)
134 #[cfg(feature = "tracing")]
135 #[error("failed retrieving response body from Microsoft Graph API")]
136 SendMailResponseBody(reqwest::Error),
137}
138
139/// Error returned by [`OutlookMailer::new`] if an access token cannot be retrieved.
140#[derive(Debug, thiserror::Error)]
141pub enum OutlookAccessTokenError {
142 /// Failed sending OAuth2 client credentials grant access token request to Microsoft Identity service.
143 #[error("failed sending OAuth2 client credentials grant access token request to Microsoft Identity service")]
144 SendRequest(reqwest::Error),
145
146 /// Failed receiving OAuth2 client credentials grant access token response from Microsoft Identity service.
147 #[error("failed receiving OAuth2 client credentials grant access token response from Microsoft Identity service")]
148 ReceiveResponse(reqwest::Error),
149
150 /// Failed to parse OAuth2 client credentials grant access token response from Microsoft Identity service.
151 #[error("failed to parse OAuth2 client credentials grant access token response from Microsoft Identity service")]
152 ParseResponse(serde_json::Error),
153}
154
155/// An Outlook mailer client, implementing the [`async_mailer_core::Mailer`](https://docs.rs/async-mailer/latest/async_mailer/trait.Mailer.html)
156/// and [`async_mailer_core::DynMailer`](https://docs.rs/async-mailer/latest/async_mailer/trait.DynMailer.html) traits
157/// to be used as generic mailer or runtime-pluggable trait object.
158///
159/// Sends mail authenticated by OAuth2 client credentials grant via the Microsoft Graph API.
160#[derive(Clone, Debug)]
161pub struct OutlookMailer {
162 http_client: reqwest::Client,
163 access_token: SecretString,
164}
165
166impl OutlookMailer {
167 /// Create a new Outlook mailer client.
168 ///
169 /// # Errors
170 ///
171 /// Returns an [`OutlookMailerError::RetrieveAccessToken`] error
172 /// when the attempt to retrieve an access token from the Microsoft Identity Service fails:
173 ///
174 /// - Wrapping an [`OutlookAccessTokenError::SendRequest`] error if sending the token request fails.
175 /// - Wrapping an [`OutlookAccessTokenError::ReceiveResponse`] error if the response body cannot be received.
176 /// - Wrapping an [`OutlookAccessTokenError::ParseResponse`] error if the response body bytes cannot be parsed as JSON.
177 #[cfg_attr(feature = "tracing", instrument)]
178 pub async fn new(
179 tenant: String,
180 app_guid: String,
181 secret: SecretString,
182 ) -> Result<Self, OutlookMailerError> {
183 let http_client = reqwest::Client::new();
184
185 let access_token = Self::get_access_token(&tenant, &app_guid, &secret, http_client.clone())
186 .await
187 .map_err(OutlookMailerError::RetrieveAccessToken)?;
188
189 Ok(Self {
190 http_client,
191 access_token,
192 })
193 }
194
195 /// Create a new Outlook mailer client as dynamic `async_mailer::BoxMailer`.
196 ///
197 /// # Errors
198 ///
199 /// Returns an [`OutlookMailerError::RetrieveAccessToken`] error
200 /// when the attempt to retrieve an access token from the Microsoft Identity Service fails:
201 ///
202 /// - Wrapping an [`OutlookAccessTokenError::SendRequest`] error if sending the token request fails.
203 /// - Wrapping an [`OutlookAccessTokenError::ReceiveResponse`] error if the response body cannot be received.
204 /// - Wrapping an [`OutlookAccessTokenError::ParseResponse`] error if the response body bytes cannot be parsed as JSON.
205 #[cfg_attr(feature = "tracing", instrument)]
206 pub async fn new_box(
207 tenant: String,
208 app_guid: String,
209 secret: SecretString,
210 ) -> Result<BoxMailer, OutlookMailerError> {
211 Ok(Box::new(Self::new(tenant, app_guid, secret).await?))
212 }
213
214 /// Create a new Outlook mailer client as dynamic `async_mailer::ArcMailer`.
215 ///
216 /// # Errors
217 ///
218 /// Returns an [`OutlookMailerError::RetrieveAccessToken`] error
219 /// when the attempt to retrieve an access token from the Microsoft Identity Service fails:
220 ///
221 /// - Wrapping an [`OutlookAccessTokenError::SendRequest`] error if sending the token request fails.
222 /// - Wrapping an [`OutlookAccessTokenError::ReceiveResponse`] error if the response body cannot be received.
223 /// - Wrapping an [`OutlookAccessTokenError::ParseResponse`] error if the response body bytes cannot be parsed as JSON.
224 #[cfg_attr(feature = "tracing", instrument)]
225 pub async fn new_arc(
226 tenant: String,
227 app_guid: String,
228 secret: SecretString,
229 ) -> Result<ArcMailer, OutlookMailerError> {
230 Ok(Arc::new(Self::new(tenant, app_guid, secret).await?))
231 }
232
233 /// Retrieve an OAuth2 client credentials grant access token from the Microsoft Identity service.
234 ///
235 /// # Errors
236 ///
237 /// Returns an [`OutlookAccessTokenError::SendRequest`] error if sending the token request fails.
238 ///
239 /// Returns an [`OutlookAccessTokenError::ReceiveResponse`] error if the response body cannot be received.
240 ///
241 /// Returns an [`OutlookAccessTokenError::ParseResponse`] error if the response body bytes cannot be parsed as JSON.
242 #[cfg_attr(feature = "tracing", instrument)]
243 async fn get_access_token(
244 tenant_id: &str,
245 client_id: &str,
246 client_secret: &SecretString,
247 http_client: reqwest::Client,
248 ) -> Result<SecretString, OutlookAccessTokenError> {
249 let token_url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token");
250
251 let form_data = [
252 ("client_id", client_id),
253 ("client_secret", client_secret.expose_secret()),
254 ("grant_type", "client_credentials"),
255 ("scope", &["https://graph.microsoft.com/.default"].join(" ")),
256 ];
257
258 let response = http_client
259 .post(&token_url)
260 .form(&form_data)
261 .send()
262 .await
263 .map_err(OutlookAccessTokenError::SendRequest)?;
264
265 let response_data = response
266 .bytes()
267 .await
268 .map_err(OutlookAccessTokenError::ReceiveResponse)?;
269
270 let token_response: TokenResponse = serde_json::from_slice(&response_data)
271 .map_err(OutlookAccessTokenError::ParseResponse)?;
272
273 Ok(SecretString::from(token_response.access_token))
274 }
275}
276
277// == Mailer ==
278
279#[async_trait]
280impl Mailer for OutlookMailer {
281 type Error = OutlookMailerError;
282
283 /// Send the prepared MIME message via the Microsoft Graph API.
284 /// Statically typed [`Mailer`] implementation for direct
285 /// or generic (`impl Mailer` / `<M: Mailer>`) invocation without vtable dispatch.
286 ///
287 /// # Errors
288 ///
289 /// Returns an [`OutlookMailerError::SendMailRequest`] error if sending the mailing request to the
290 /// Microsoft Graph API fails.
291 ///
292 /// Returns an [`OutlookMailerError::SendMailResponse`] error if the Microsoft Graph API responds
293 /// with a non-success HTTP status code.
294 ///
295 /// Returns an [`OutlookMailerError::SendMailResponseBody`] error if the Microsoft Graph API reponse body
296 /// cannot be received.
297 /// (Crate feature `tracing` only: The response body is only received for logging.)
298 async fn send_mail(&self, message: Message<'_>) -> Result<(), Self::Error> {
299 // TODO: Token auto-refresh.
300
301 // Extract sender address necessary for Microsoft Graph API call.
302 let from_address = message.mail_from.email.to_string();
303
304 #[cfg(feature = "tracing")]
305 // Extract recipient addresses for tracing log output.
306 let recipient_addresses = {
307 let recipient_addresses = util::format_recipient_addresses(&message);
308
309 info!("Sending Outlook mail to {recipient_addresses}...");
310 recipient_addresses
311 };
312
313 // Encode the message body according to the MIME-mail API endpoint documentation:
314 // https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0&tabs=http#example-4-send-a-new-message-using-mime-format
315 // See also https://learn.microsoft.com/en-us/graph/outlook-send-mime-message
316 let message_base64 = base64_engine.encode(&message.body);
317
318 // Prepare the authorization header with OAuth 2.0 client credentials grant bearer token.
319 let mut headers = HeaderMap::new();
320 headers.insert(
321 AUTHORIZATION,
322 format!("Bearer {}", self.access_token.expose_secret())
323 .parse()
324 .unwrap(),
325 );
326 headers.insert(CONTENT_TYPE, "text/plain".parse().unwrap());
327
328 // Send the mail via Graph API.
329 let response = self
330 .http_client
331 .post(format!(
332 "https://graph.microsoft.com/v1.0/users/{from_address}/sendMail",
333 ))
334 .headers(headers)
335 .body(message_base64)
336 .send()
337 .await
338 .map_err(OutlookMailerError::SendMailRequest)?;
339
340 {
341 // Get result with empty ok or status code error
342 // before moving `response` to consume the body.
343 let success = response
344 .error_for_status_ref()
345 // Un-reference `response`, so we can move out of it with `response.text()`.
346 .map(|_| {});
347
348 #[cfg(feature = "tracing")]
349 {
350 match success {
351 Ok(()) => {
352 info!("Sent Outlook mail to {recipient_addresses}");
353 debug!(?response);
354 }
355
356 Err(ref error) => {
357 error!(
358 ?error,
359 "Failed to send Outlook mail to {recipient_addresses}"
360 );
361 error!(?response);
362 }
363 };
364
365 // Log the response JSON as plain text.
366 let response_text = response
367 .text()
368 .await
369 .map_err(OutlookMailerError::SendMailResponseBody)?;
370 match &success {
371 Ok(_) => debug!(response_text),
372 Err(_) => error!(response_text),
373 }
374 }
375
376 success
377 }
378 .map_err(OutlookMailerError::SendMailResponse)?;
379
380 Ok(())
381 }
382}
383
384// == DynMailer ==
385
386#[async_trait]
387impl DynMailer for OutlookMailer {
388 /// Send the prepared MIME message via the Microsoft Graph API.
389 /// Dynamically typed [`DynMailer`] implementation for trait object invocation via vtable dispatch.
390 ///
391 /// # Errors
392 ///
393 /// Returns a boxed, type-erased [`OutlookMailerError::SendMailRequest`] error if sending the mailing request to the
394 /// Microsoft Graph API fails.
395 ///
396 /// Returns a boxed, type-erased [`OutlookMailerError::SendMailResponse`] error if the Microsoft Graph API responds
397 /// with a non-success HTTP status code.
398 ///
399 /// Returns a boxed, type-erased [`OutlookMailerError::SendMailResponseBody`] error if the Microsoft Graph API reponse body
400 /// cannot be received.
401 /// (Crate feature `tracing` only: The response body is only received for logging.)
402 #[cfg_attr(feature = "tracing", instrument(skip(message)))]
403 async fn send_mail(&self, message: Message<'_>) -> Result<(), DynMailerError> {
404 Mailer::send_mail(self, message).await.map_err(Into::into)
405 }
406}
407
408/// The Microsoft Identity Service access token request JSON success response.
409#[derive(Debug, Deserialize)]
410struct TokenResponse {
411 // token_type: String,
412 // expires_in: i32,
413 // ext_expires_in: i32,
414 access_token: String,
415}