Skip to main content

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}