Skip to main content

wangamail_rs/
client.rs

1use crate::auth::TokenProvider;
2use crate::error::{Error, Result};
3use crate::graph::SendMailRequest;
4use reqwest::Client;
5use std::sync::Arc;
6
7/// Default Microsoft Graph scope for client credentials.
8const DEFAULT_SCOPE: &str = "https://graph.microsoft.com/.default";
9
10/// Base URL for global Microsoft Graph.
11const DEFAULT_GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
12
13/// Builder for [`GraphMailClient`].
14///
15/// Required: [`tenant_id`](GraphMailClientBuilder::tenant_id), [`client_id`](GraphMailClientBuilder::client_id),
16/// [`client_secret`](GraphMailClientBuilder::client_secret). Optional overrides for sovereign clouds:
17/// [`token_url`](GraphMailClientBuilder::token_url), [`graph_base`](GraphMailClientBuilder::graph_base), [`scope`](GraphMailClientBuilder::scope).
18#[derive(Debug, Clone, Default)]
19pub struct GraphMailClientBuilder {
20    tenant_id: Option<String>,
21    client_id: Option<String>,
22    client_secret: Option<String>,
23    token_url: Option<String>,
24    graph_base: Option<String>,
25    scope: Option<String>,
26}
27
28impl GraphMailClientBuilder {
29    /// Create a new builder with no configuration set.
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Azure AD tenant ID (GUID or domain).
35    pub fn tenant_id(mut self, id: impl Into<String>) -> Self {
36        self.tenant_id = Some(id.into());
37        self
38    }
39
40    /// Application (client) ID from app registration.
41    pub fn client_id(mut self, id: impl Into<String>) -> Self {
42        self.client_id = Some(id.into());
43        self
44    }
45
46    /// Client secret from app registration.
47    pub fn client_secret(mut self, secret: impl Into<String>) -> Self {
48        self.client_secret = Some(secret.into());
49        self
50    }
51
52    /// Override token endpoint (e.g. for sovereign clouds). Default: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token`
53    pub fn token_url(mut self, url: impl Into<String>) -> Self {
54        self.token_url = Some(url.into());
55        self
56    }
57
58    /// Override Graph API base URL (e.g. for sovereign clouds). Default: `https://graph.microsoft.com/v1.0`
59    pub fn graph_base(mut self, base: impl Into<String>) -> Self {
60        self.graph_base = Some(base.into());
61        self
62    }
63
64    /// Override scope. Default: `https://graph.microsoft.com/.default`
65    pub fn scope(mut self, scope: impl Into<String>) -> Self {
66        self.scope = Some(scope.into());
67        self
68    }
69
70    /// Build the [`GraphMailClient`]. Fails if `tenant_id`, `client_id`, or `client_secret` are missing.
71    pub fn build(self) -> Result<GraphMailClient> {
72        let tenant_id = self
73            .tenant_id
74            .ok_or_else(|| Error::Config("tenant_id is required".into()))?;
75        let client_id = self
76            .client_id
77            .ok_or_else(|| Error::Config("client_id is required".into()))?;
78        let client_secret = self
79            .client_secret
80            .ok_or_else(|| Error::Config("client_secret is required".into()))?;
81
82        let token_url = self.token_url.unwrap_or_else(|| {
83            format!(
84                "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
85                tenant_id
86            )
87        });
88        let graph_base = self
89            .graph_base
90            .unwrap_or_else(|| DEFAULT_GRAPH_BASE.to_string())
91            .trim_end_matches('/')
92            .to_string();
93        let scope = self.scope.unwrap_or_else(|| DEFAULT_SCOPE.to_string());
94
95        let http_client = Client::builder()
96            .build()
97            .map_err(|e| Error::Config(format!("HTTP client: {e}")))?;
98
99        let token_provider = TokenProvider::new(
100            tenant_id,
101            client_id,
102            client_secret,
103            token_url,
104            scope,
105            http_client.clone(),
106        );
107
108        Ok(GraphMailClient {
109            http_client,
110            token_provider: Arc::new(token_provider),
111            graph_base,
112        })
113    }
114}
115
116/// Client for sending email via Microsoft Graph (app-only, client credentials).
117///
118/// Obtain an access token using the OAuth2 client credentials flow and call the Graph
119/// `POST /users/{id}/sendMail` API. Use [`GraphMailClient::builder`] to construct.
120#[derive(Clone)]
121pub struct GraphMailClient {
122    http_client: Client,
123    token_provider: Arc<TokenProvider>,
124    graph_base: String,
125}
126
127impl GraphMailClient {
128    /// Return a new builder for configuring and creating a [`GraphMailClient`].
129    #[must_use]
130    pub fn builder() -> GraphMailClientBuilder {
131        GraphMailClientBuilder::new()
132    }
133
134    /// Send an email as the given user (user id or userPrincipalName).
135    ///
136    /// The app must have **Mail.Send** application permission and admin consent.
137    /// The user must have a mailbox in Exchange Online.
138    pub async fn send_mail(&self, from_user: &str, request: SendMailRequest) -> Result<()> {
139        let token = self.token_provider.get_token().await?;
140        let url = format!(
141            "{}/users/{}/sendMail",
142            self.graph_base,
143            urlencoding::encode(from_user)
144        );
145
146        let res = self
147            .http_client
148            .post(&url)
149            .bearer_auth(&token)
150            .json(&request)
151            .send()
152            .await?;
153
154        let status = res.status();
155        if status.as_u16() == 202 {
156            return Ok(());
157        }
158
159        let body = res.text().await.unwrap_or_default();
160        Err(Error::Graph(format!(
161            "sendMail failed: {} {}",
162            status, body
163        )))
164    }
165}