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: {0}")]
122 RetrieveAccessToken(#[from] OutlookAccessTokenError),
123
124 /// Failed request attempting to send Outlook MIME mail through Microsoft Graph API.
125 #[error(
126 "failed request attempting to send Outlook MIME mail through Microsoft Graph API: {0}"
127 )]
128 SendMailRequest(#[source] reqwest::Error),
129
130 /// Failed sending Outlook MIME mail through Microsoft Graph API.
131 #[error("failed sending Outlook MIME mail through Microsoft Graph API: {0}")]
132 SendMailResponse(#[source] reqwest::Error),
133
134 /// Failed retrieving response body from Microsoft Graph API.
135 /// (Crate feature `tracing` only.)
136 #[cfg(feature = "tracing")]
137 #[error("failed retrieving response body from Microsoft Graph API: {0}")]
138 SendMailResponseBody(#[source] reqwest::Error),
139}
140
141/// Error returned by [`OutlookMailer::new`] if an access token cannot be retrieved.
142#[derive(Debug, thiserror::Error)]
143pub enum OutlookAccessTokenError {
144 /// Failed sending OAuth2 client credentials grant access token request to Microsoft Identity service.
145 #[error("failed sending OAuth2 client credentials grant access token request to Microsoft Identity service: {0}")]
146 SendRequest(#[source] reqwest::Error),
147
148 /// Failed receiving OAuth2 client credentials grant access token response from Microsoft Identity service.
149 #[error("failed receiving OAuth2 client credentials grant access token response from Microsoft Identity service: {0}")]
150 ReceiveResponse(#[source] reqwest::Error),
151
152 /// Failed to parse OAuth2 client credentials grant access token response from Microsoft Identity service.
153 #[error("failed to parse OAuth2 client credentials grant access token response from Microsoft Identity service: {0}")]
154 ParseResponse(#[source] serde_json::Error),
155}
156
157/// An Outlook mailer client, implementing the [`async_mailer_core::Mailer`](https://docs.rs/async-mailer/latest/async_mailer/trait.Mailer.html)
158/// and [`async_mailer_core::DynMailer`](https://docs.rs/async-mailer/latest/async_mailer/trait.DynMailer.html) traits
159/// to be used as generic mailer or runtime-pluggable trait object.
160///
161/// Sends mail authenticated by OAuth2 client credentials grant via the Microsoft Graph API.
162#[derive(Clone, Debug)]
163pub struct OutlookMailer {
164 http_client: reqwest::Client,
165 access_token: SecretString,
166}
167
168impl OutlookMailer {
169 /// Create a new Outlook mailer client.
170 ///
171 /// # Errors
172 ///
173 /// Returns an [`OutlookMailerError::RetrieveAccessToken`] error
174 /// when the attempt to retrieve an access token from the Microsoft Identity Service fails:
175 ///
176 /// - Wrapping an [`OutlookAccessTokenError::SendRequest`] error if sending the token request fails.
177 /// - Wrapping an [`OutlookAccessTokenError::ReceiveResponse`] error if the response body cannot be received.
178 /// - Wrapping an [`OutlookAccessTokenError::ParseResponse`] error if the response body bytes cannot be parsed as JSON.
179 #[cfg_attr(feature = "tracing", instrument)]
180 pub async fn new(
181 tenant: String,
182 app_guid: String,
183 secret: SecretString,
184 ) -> Result<Self, OutlookMailerError> {
185 let http_client = reqwest::Client::new();
186
187 let access_token = Self::get_access_token(&tenant, &app_guid, &secret, http_client.clone())
188 .await
189 .map_err(OutlookMailerError::RetrieveAccessToken)?;
190
191 Ok(Self {
192 http_client,
193 access_token,
194 })
195 }
196
197 /// Create a new Outlook mailer client as dynamic `async_mailer::BoxMailer`.
198 ///
199 /// # Errors
200 ///
201 /// Returns an [`OutlookMailerError::RetrieveAccessToken`] error
202 /// when the attempt to retrieve an access token from the Microsoft Identity Service fails:
203 ///
204 /// - Wrapping an [`OutlookAccessTokenError::SendRequest`] error if sending the token request fails.
205 /// - Wrapping an [`OutlookAccessTokenError::ReceiveResponse`] error if the response body cannot be received.
206 /// - Wrapping an [`OutlookAccessTokenError::ParseResponse`] error if the response body bytes cannot be parsed as JSON.
207 #[cfg_attr(feature = "tracing", instrument)]
208 pub async fn new_box(
209 tenant: String,
210 app_guid: String,
211 secret: SecretString,
212 ) -> Result<BoxMailer, OutlookMailerError> {
213 Ok(Box::new(Self::new(tenant, app_guid, secret).await?))
214 }
215
216 /// Create a new Outlook mailer client as dynamic `async_mailer::ArcMailer`.
217 ///
218 /// # Errors
219 ///
220 /// Returns an [`OutlookMailerError::RetrieveAccessToken`] error
221 /// when the attempt to retrieve an access token from the Microsoft Identity Service fails:
222 ///
223 /// - Wrapping an [`OutlookAccessTokenError::SendRequest`] error if sending the token request fails.
224 /// - Wrapping an [`OutlookAccessTokenError::ReceiveResponse`] error if the response body cannot be received.
225 /// - Wrapping an [`OutlookAccessTokenError::ParseResponse`] error if the response body bytes cannot be parsed as JSON.
226 #[cfg_attr(feature = "tracing", instrument)]
227 pub async fn new_arc(
228 tenant: String,
229 app_guid: String,
230 secret: SecretString,
231 ) -> Result<ArcMailer, OutlookMailerError> {
232 Ok(Arc::new(Self::new(tenant, app_guid, secret).await?))
233 }
234
235 /// Retrieve an OAuth2 client credentials grant access token from the Microsoft Identity service.
236 ///
237 /// # Errors
238 ///
239 /// Returns an [`OutlookAccessTokenError::SendRequest`] error if sending the token request fails.
240 ///
241 /// Returns an [`OutlookAccessTokenError::ReceiveResponse`] error if the response body cannot be received.
242 ///
243 /// Returns an [`OutlookAccessTokenError::ParseResponse`] error if the response body bytes cannot be parsed as JSON.
244 #[cfg_attr(feature = "tracing", instrument)]
245 async fn get_access_token(
246 tenant_id: &str,
247 client_id: &str,
248 client_secret: &SecretString,
249 http_client: reqwest::Client,
250 ) -> Result<SecretString, OutlookAccessTokenError> {
251 let token_url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token");
252
253 let form_data = [
254 ("client_id", client_id),
255 ("client_secret", client_secret.expose_secret()),
256 ("grant_type", "client_credentials"),
257 ("scope", &["https://graph.microsoft.com/.default"].join(" ")),
258 ];
259
260 let response = http_client
261 .post(&token_url)
262 .form(&form_data)
263 .send()
264 .await
265 .map_err(OutlookAccessTokenError::SendRequest)?;
266
267 let response_data = response
268 .bytes()
269 .await
270 .map_err(OutlookAccessTokenError::ReceiveResponse)?;
271
272 let token_response: TokenResponse = serde_json::from_slice(&response_data)
273 .map_err(OutlookAccessTokenError::ParseResponse)?;
274
275 Ok(SecretString::from(token_response.access_token))
276 }
277}
278
279// == Mailer ==
280
281#[async_trait]
282impl Mailer for OutlookMailer {
283 type Error = OutlookMailerError;
284
285 /// Send the prepared MIME message via the Microsoft Graph API.
286 /// Statically typed [`Mailer`] implementation for direct
287 /// or generic (`impl Mailer` / `<M: Mailer>`) invocation without vtable dispatch.
288 ///
289 /// # Errors
290 ///
291 /// Returns an [`OutlookMailerError::SendMailRequest`] error if sending the mailing request to the
292 /// Microsoft Graph API fails.
293 ///
294 /// Returns an [`OutlookMailerError::SendMailResponse`] error if the Microsoft Graph API responds
295 /// with a non-success HTTP status code.
296 ///
297 /// Returns an [`OutlookMailerError::SendMailResponseBody`] error if the Microsoft Graph API reponse body
298 /// cannot be received.
299 /// (Crate feature `tracing` only: The response body is only received for logging.)
300 async fn send_mail(&self, message: Message<'_>) -> Result<(), Self::Error> {
301 // TODO: Token auto-refresh.
302
303 // Extract sender address necessary for Microsoft Graph API call.
304 let from_address = message.mail_from.email.to_string();
305
306 #[cfg(feature = "tracing")]
307 // Extract recipient addresses for tracing log output.
308 let recipient_addresses = {
309 let recipient_addresses = util::format_recipient_addresses(&message);
310
311 info!("Sending Outlook mail to {recipient_addresses}...");
312 recipient_addresses
313 };
314
315 // Encode the message body according to the MIME-mail API endpoint documentation:
316 // 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
317 // See also https://learn.microsoft.com/en-us/graph/outlook-send-mime-message
318 let message_base64 = base64_engine.encode(&message.body);
319
320 // Prepare the authorization header with OAuth 2.0 client credentials grant bearer token.
321 let mut headers = HeaderMap::new();
322 headers.insert(
323 AUTHORIZATION,
324 format!("Bearer {}", self.access_token.expose_secret())
325 .parse()
326 .unwrap(),
327 );
328 headers.insert(CONTENT_TYPE, "text/plain".parse().unwrap());
329
330 // Send the mail via Graph API.
331 let response = self
332 .http_client
333 .post(format!(
334 "https://graph.microsoft.com/v1.0/users/{from_address}/sendMail",
335 ))
336 .headers(headers)
337 .body(message_base64)
338 .send()
339 .await
340 .map_err(OutlookMailerError::SendMailRequest)?;
341
342 {
343 // Get result with empty ok or status code error
344 // before moving `response` to consume the body.
345 let success = response
346 .error_for_status_ref()
347 // Un-reference `response`, so we can move out of it with `response.text()`.
348 .map(|_| {});
349
350 #[cfg(feature = "tracing")]
351 {
352 match success {
353 Ok(()) => {
354 info!("Sent Outlook mail to {recipient_addresses}");
355 debug!(?response);
356 }
357
358 Err(ref error) => {
359 error!(
360 ?error,
361 "Failed to send Outlook mail to {recipient_addresses}"
362 );
363 error!(?response);
364 }
365 };
366
367 // Log the response JSON as plain text.
368 let response_text = response
369 .text()
370 .await
371 .map_err(OutlookMailerError::SendMailResponseBody)?;
372 match &success {
373 Ok(_) => debug!(response_text),
374 Err(_) => error!(response_text),
375 }
376 }
377
378 success
379 }
380 .map_err(OutlookMailerError::SendMailResponse)?;
381
382 Ok(())
383 }
384}
385
386// == DynMailer ==
387
388#[async_trait]
389impl DynMailer for OutlookMailer {
390 /// Send the prepared MIME message via the Microsoft Graph API.
391 /// Dynamically typed [`DynMailer`] implementation for trait object invocation via vtable dispatch.
392 ///
393 /// # Errors
394 ///
395 /// Returns a boxed, type-erased [`OutlookMailerError::SendMailRequest`] error if sending the mailing request to the
396 /// Microsoft Graph API fails.
397 ///
398 /// Returns a boxed, type-erased [`OutlookMailerError::SendMailResponse`] error if the Microsoft Graph API responds
399 /// with a non-success HTTP status code.
400 ///
401 /// Returns a boxed, type-erased [`OutlookMailerError::SendMailResponseBody`] error if the Microsoft Graph API reponse body
402 /// cannot be received.
403 /// (Crate feature `tracing` only: The response body is only received for logging.)
404 #[cfg_attr(feature = "tracing", instrument(skip(message)))]
405 async fn send_mail(&self, message: Message<'_>) -> Result<(), DynMailerError> {
406 Mailer::send_mail(self, message).await.map_err(Into::into)
407 }
408}
409
410/// The Microsoft Identity Service access token request JSON success response.
411#[derive(Debug, Deserialize)]
412struct TokenResponse {
413 // token_type: String,
414 // expires_in: i32,
415 // ext_expires_in: i32,
416 access_token: String,
417}