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}