Skip to main content

oauth_toolkit/
providers.rs

1//! Mail provider presets for desktop IMAP/SMTP XOAUTH2 flows, plus the
2//! canonical endpoint URL constants shared by all provider modules.
3//!
4//! Every preset exposes the authorization URL, token URL, and the
5//! IMAP/SMTP XOAUTH2 scope sets required to read and send mail.
6
7use serde::{Deserialize, Serialize};
8
9/// Canonical provider endpoint URLs — the single source of truth.
10///
11/// Other modules (`social`, mail presets) must reference these constants
12/// instead of repeating literal URLs.
13pub mod endpoints {
14    /// GitHub authorization endpoint.
15    pub const GITHUB_AUTHORIZE_URL: &str = "https://github.com/login/oauth/authorize";
16    /// GitHub token endpoint.
17    pub const GITHUB_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
18    /// GitHub user info endpoint.
19    pub const GITHUB_USER_INFO_URL: &str = "https://api.github.com/user";
20
21    /// Google authorization endpoint.
22    pub const GOOGLE_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
23    /// Google token endpoint.
24    pub const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
25    /// Google user info endpoint.
26    pub const GOOGLE_USER_INFO_URL: &str = "https://www.googleapis.com/oauth2/v2/userinfo";
27
28    /// Yahoo authorization endpoint.
29    pub const YAHOO_AUTHORIZE_URL: &str = "https://api.login.yahoo.com/oauth2/request_auth";
30    /// Yahoo token endpoint.
31    pub const YAHOO_TOKEN_URL: &str = "https://api.login.yahoo.com/oauth2/get_token";
32
33    /// AOL authorization endpoint (AOL shares Yahoo's OAuth2 stack).
34    pub const AOL_AUTHORIZE_URL: &str = "https://api.login.aol.com/oauth2/request_auth";
35    /// AOL token endpoint.
36    pub const AOL_TOKEN_URL: &str = "https://api.login.aol.com/oauth2/get_token";
37
38    /// Fastmail authorization endpoint.
39    pub const FASTMAIL_AUTHORIZE_URL: &str = "https://app.fastmail.com/oauth/authorize";
40    /// Fastmail token endpoint.
41    pub const FASTMAIL_TOKEN_URL: &str = "https://api.fastmail.com/oauth/token";
42}
43
44/// Mail provider endpoints and XOAUTH2 scope sets.
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub struct MailProvider {
47    /// Authorization endpoint.
48    pub auth_url: String,
49    /// Token endpoint.
50    pub token_url: String,
51    /// `OAuth2` client id (the desktop app's registered client).
52    pub client_id: String,
53    /// Scopes required for IMAP XOAUTH2.
54    pub imap_scopes: Vec<String>,
55    /// Scopes required for SMTP XOAUTH2.
56    pub smtp_scopes: Vec<String>,
57    /// Extra scopes included in the authorization request
58    /// (`openid`, `offline_access`, ...).
59    pub extra_scopes: Vec<String>,
60}
61
62impl MailProvider {
63    /// Gmail preset. The single `https://mail.google.com/` scope covers
64    /// both IMAP and SMTP XOAUTH2.
65    pub fn gmail(client_id: &str) -> Self {
66        Self {
67            auth_url: endpoints::GOOGLE_AUTHORIZE_URL.to_owned(),
68            token_url: endpoints::GOOGLE_TOKEN_URL.to_owned(),
69            client_id: client_id.to_owned(),
70            imap_scopes: vec!["https://mail.google.com/".into()],
71            smtp_scopes: vec!["https://mail.google.com/".into()],
72            extra_scopes: vec!["openid".into(), "email".into()],
73        }
74    }
75
76    /// Microsoft 365 / Outlook preset. `tenant` is the directory tenant
77    /// path segment (e.g. `common`, `consumers`, or a tenant id).
78    pub fn outlook(client_id: &str, tenant: &str) -> Self {
79        Self {
80            auth_url: format!("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize"),
81            token_url: format!("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"),
82            client_id: client_id.to_owned(),
83            imap_scopes: vec!["https://outlook.office.com/IMAP.AccessAsUser.All".into()],
84            smtp_scopes: vec!["https://outlook.office.com/SMTP.Send".into()],
85            extra_scopes: vec!["offline_access".into()],
86        }
87    }
88
89    /// Yahoo Mail preset.
90    pub fn yahoo(client_id: &str) -> Self {
91        Self {
92            auth_url: endpoints::YAHOO_AUTHORIZE_URL.to_owned(),
93            token_url: endpoints::YAHOO_TOKEN_URL.to_owned(),
94            client_id: client_id.to_owned(),
95            imap_scopes: vec!["mail-w".into()],
96            smtp_scopes: vec!["mail-w".into()],
97            extra_scopes: vec![],
98        }
99    }
100
101    /// AOL Mail preset (AOL shares Yahoo's OAuth2 stack).
102    pub fn aol(client_id: &str) -> Self {
103        Self {
104            auth_url: endpoints::AOL_AUTHORIZE_URL.to_owned(),
105            token_url: endpoints::AOL_TOKEN_URL.to_owned(),
106            client_id: client_id.to_owned(),
107            imap_scopes: vec!["mail-w".into()],
108            smtp_scopes: vec!["mail-w".into()],
109            extra_scopes: vec![],
110        }
111    }
112
113    /// Fastmail preset with dedicated IMAP and SMTP protocol scopes.
114    pub fn fastmail(client_id: &str) -> Self {
115        Self {
116            auth_url: endpoints::FASTMAIL_AUTHORIZE_URL.to_owned(),
117            token_url: endpoints::FASTMAIL_TOKEN_URL.to_owned(),
118            client_id: client_id.to_owned(),
119            imap_scopes: vec!["https://www.fastmail.com/dev/protocolIMAP".into()],
120            smtp_scopes: vec!["https://www.fastmail.com/dev/protocolSMTP".into()],
121            extra_scopes: vec![],
122        }
123    }
124
125    /// The scope list to send in the authorization request: the union of
126    /// IMAP, SMTP, and extra scopes, de-duplicated preserving order.
127    pub fn authorization_scopes(&self) -> Vec<String> {
128        let mut out: Vec<String> = Vec::new();
129        for scope in self
130            .imap_scopes
131            .iter()
132            .chain(self.smtp_scopes.iter())
133            .chain(self.extra_scopes.iter())
134        {
135            if !out.contains(scope) {
136                out.push(scope.clone());
137            }
138        }
139        out
140    }
141}
142
143#[cfg(test)]
144// Test code: unwrap/expect are the idiomatic way to assert setup success.
145#[allow(clippy::unwrap_used, clippy::expect_used)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn gmail_preset_shape() {
151        let g = MailProvider::gmail("g");
152        assert_eq!(g.auth_url, endpoints::GOOGLE_AUTHORIZE_URL);
153        assert!(g.token_url.contains("googleapis"));
154        let scopes = g.authorization_scopes();
155        assert!(scopes.contains(&"https://mail.google.com/".to_string()));
156        assert!(scopes.contains(&"openid".to_string()));
157        // dedup: mail.google.com appears in both imap and smtp sets
158        assert_eq!(
159            scopes
160                .iter()
161                .filter(|s| *s == "https://mail.google.com/")
162                .count(),
163            1
164        );
165    }
166
167    #[test]
168    fn fastmail_preset_split_scopes() {
169        let f = MailProvider::fastmail("f");
170        assert!(f.token_url.contains("fastmail"));
171        assert_eq!(
172            f.imap_scopes,
173            vec!["https://www.fastmail.com/dev/protocolIMAP"]
174        );
175        assert_eq!(
176            f.smtp_scopes,
177            vec!["https://www.fastmail.com/dev/protocolSMTP"]
178        );
179        let scopes = f.authorization_scopes();
180        assert_eq!(scopes.len(), 2);
181        assert!(scopes.contains(&"https://www.fastmail.com/dev/protocolIMAP".to_string()));
182        assert!(scopes.contains(&"https://www.fastmail.com/dev/protocolSMTP".to_string()));
183    }
184
185    #[test]
186    fn gmail_vs_fastmail_scope_shapes_differ() {
187        // Gmail: one umbrella scope; Fastmail: two protocol scopes.
188        let g = MailProvider::gmail("g");
189        let f = MailProvider::fastmail("f");
190        assert!(g.imap_scopes == g.smtp_scopes);
191        assert_ne!(f.imap_scopes, f.smtp_scopes);
192    }
193
194    #[test]
195    fn outlook_preset_tenant_aware() {
196        let o = MailProvider::outlook("o", "common");
197        assert!(o.auth_url.contains("login.microsoftonline.com/common"));
198        assert!(o.token_url.contains("login.microsoftonline.com/common"));
199        assert!(o.authorization_scopes().iter().any(|s| s.contains("IMAP")));
200        assert!(
201            o.authorization_scopes()
202                .iter()
203                .any(|s| s.contains("SMTP.Send"))
204        );
205        assert!(o.extra_scopes.contains(&"offline_access".to_string()));
206
207        let t = MailProvider::outlook("o", "9188040d-6c67-4c5b-b112-36a304b66dad");
208        assert!(t.auth_url.contains("9188040d"));
209    }
210
211    #[test]
212    fn yahoo_and_aol_presets() {
213        let y = MailProvider::yahoo("y");
214        assert!(y.auth_url.contains("login.yahoo.com"));
215        assert!(y.token_url.contains("login.yahoo.com"));
216        assert!(y.authorization_scopes().contains(&"mail-w".to_string()));
217
218        let a = MailProvider::aol("a");
219        assert!(a.auth_url.contains("login.aol.com"));
220        assert!(a.token_url.contains("login.aol.com"));
221        assert!(a.authorization_scopes().contains(&"mail-w".to_string()));
222    }
223
224    #[test]
225    fn gmail_shares_google_urls_with_social_module() {
226        // Dedupe regression: the mail preset and social login must agree
227        // on Google's endpoints.
228        assert_eq!(
229            MailProvider::gmail("x").auth_url,
230            crate::social::SocialProvider::Google.authorize_url()
231        );
232        assert_eq!(
233            MailProvider::gmail("x").token_url,
234            crate::social::SocialProvider::Google.token_url()
235        );
236    }
237
238    #[test]
239    fn authorization_scopes_order_preserved() {
240        let p = MailProvider {
241            auth_url: "https://x".into(),
242            token_url: "https://x".into(),
243            client_id: "c".into(),
244            imap_scopes: vec!["a".into(), "b".into()],
245            smtp_scopes: vec!["b".into(), "c".into()],
246            extra_scopes: vec!["d".into(), "a".into()],
247        };
248        assert_eq!(p.authorization_scopes(), vec!["a", "b", "c", "d"]);
249    }
250}