Skip to main content

omni_dev/gmail/
error.rs

1//! Error types for Gmail operations.
2
3use std::fmt;
4
5use thiserror::Error;
6
7/// Which OAuth2 grant produced Google's `invalid_grant` response.
8///
9/// Google's response body is identical for both causes, so the
10/// distinguishing message is chosen from *which call* got the error, not
11/// from anything in the response body itself.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum GrantContext {
14    /// The initial `authorization_code` → token exchange.
15    CodeExchange,
16    /// A `refresh_token` → access-token renewal.
17    Refresh,
18}
19
20impl fmt::Display for GrantContext {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::CodeExchange => write!(
24                f,
25                "the authorization code was invalid, already used, expired (codes are \
26                 single-use and valid only a few minutes), or the PKCE code_verifier did not \
27                 match the code_challenge sent at the start of login. Run \
28                 `omni-dev gmail auth login` again."
29            ),
30            Self::Refresh => write!(
31                f,
32                "this almost always means either (1) your Gmail OAuth client is in \"Testing\" \
33                 publishing status, where refresh tokens expire after 7 days — publish it to \
34                 \"In production\" in Google Cloud Console to avoid this, or (2) access was \
35                 revoked. Run `omni-dev gmail auth login` again to re-authenticate."
36            ),
37        }
38    }
39}
40
41/// Errors that can occur during Gmail operations.
42#[derive(Error, Debug)]
43pub enum GmailError {
44    /// Gmail credentials are not configured.
45    #[error("Gmail credentials not configured. Run `omni-dev gmail auth login`")]
46    CredentialsNotFound,
47
48    /// A Gmail API request failed.
49    #[error("Gmail API request failed: HTTP {status}: {body}")]
50    ApiRequestFailed {
51        /// HTTP status code.
52        status: u16,
53        /// Response body text (or an extracted `error.message`/`reason`
54        /// summary when the body is Gmail's JSON error envelope).
55        body: String,
56        /// The `error.errors[0].reason` field from Gmail's JSON error
57        /// envelope, if the body parsed as that shape. Populated directly by
58        /// `GmailClient::response_to_error` — not re-derived from `body`.
59        reason: Option<String>,
60    },
61
62    /// The OAuth callback's `state` did not match the value generated at the
63    /// start of login.
64    #[error(
65        "OAuth state mismatch: the browser callback did not present the expected state \
66         value; aborting login for safety"
67    )]
68    StateMismatch,
69
70    /// Google's `?error=` redirect (e.g. the user clicked "Cancel").
71    #[error("Google denied the authorization request: {0}")]
72    AuthorizationDenied(String),
73
74    /// No browser callback arrived within the timeout.
75    #[error(
76        "Timed out after {0}s waiting for the browser sign-in callback; re-run \
77         `omni-dev gmail auth login`"
78    )]
79    CallbackTimeout(u64),
80
81    /// The browser's callback request could not be parsed.
82    #[error(
83        "The browser's sign-in callback was malformed or missing the `code`/`state` parameters"
84    )]
85    MalformedCallback,
86
87    /// Google rejected a code exchange or refresh with `invalid_grant`.
88    #[error("Google rejected the request (invalid_grant): {0}")]
89    InvalidGrant(GrantContext),
90
91    /// Google's token response was missing a required field.
92    #[error("Gmail OAuth token response was malformed: missing `{0}`")]
93    MalformedTokenResponse(&'static str),
94
95    /// Google's token response carried no Gmail scope at all — e.g. the
96    /// Gmail permission was left unticked on the consent screen.
97    #[error(
98        "Google did not grant a Gmail scope (received: {0}).\n  On the consent screen, tick the \
99         Gmail permission — restricted scopes are not granted by default. Re-run \
100         `omni-dev gmail auth login`."
101    )]
102    NoGmailScopeGranted(String),
103
104    /// The configured browser launch command was invalid.
105    #[error("Invalid browser launch command: {0}")]
106    InvalidBrowserCommand(String),
107}
108
109impl GmailError {
110    /// Builds an [`AuthorizationDenied`](Self::AuthorizationDenied) from
111    /// Google's `error`/`error_description` redirect parameters.
112    ///
113    /// Kept as a constructor (not a conditional format string) so the
114    /// "does an optional description exist" branching lives in one place
115    /// instead of inside the `#[error(...)]` macro, matching this
116    /// codebase's convention of plain field interpolation in
117    /// `#[error(...)]` attributes.
118    #[must_use]
119    pub(crate) fn authorization_denied(reason: &str, description: Option<&str>) -> Self {
120        let detail = match description {
121            Some(d) if !d.is_empty() => format!("{reason} ({d})"),
122            _ => reason.to_string(),
123        };
124        Self::AuthorizationDenied(detail)
125    }
126
127    /// The Gmail-specific `reason` field of an
128    /// [`ApiRequestFailed`](Self::ApiRequestFailed), if
129    /// `GmailClient::response_to_error` found one.
130    pub(crate) fn reason(&self) -> Option<&str> {
131        match self {
132            Self::ApiRequestFailed { reason, .. } => reason.as_deref(),
133            _ => None,
134        }
135    }
136}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used, clippy::expect_used)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn credentials_not_found_display() {
145        let err = GmailError::CredentialsNotFound;
146        assert!(err.to_string().contains("not configured"));
147        assert!(err.to_string().contains("gmail auth login"));
148    }
149
150    #[test]
151    fn api_request_failed_display() {
152        let err = GmailError::ApiRequestFailed {
153            status: 403,
154            body: "insufficientPermissions".to_string(),
155            reason: None,
156        };
157        let msg = err.to_string();
158        assert!(msg.contains("403"));
159        assert!(msg.contains("insufficientPermissions"));
160    }
161
162    #[test]
163    fn state_mismatch_display_mentions_state() {
164        assert!(GmailError::StateMismatch.to_string().contains("state"));
165    }
166
167    #[test]
168    fn reason_returns_the_structured_field() {
169        let err = GmailError::ApiRequestFailed {
170            status: 404,
171            body: "Not Found (reason: notFound)".to_string(),
172            reason: Some("notFound".to_string()),
173        };
174        assert_eq!(err.reason(), Some("notFound"));
175    }
176
177    #[test]
178    fn reason_is_none_when_the_structured_field_is_absent() {
179        let err = GmailError::ApiRequestFailed {
180            status: 500,
181            body: "Internal Server Error".to_string(),
182            reason: None,
183        };
184        assert_eq!(err.reason(), None);
185    }
186
187    #[test]
188    fn reason_ignores_a_reason_like_substring_embedded_in_the_body() {
189        let err = GmailError::ApiRequestFailed {
190            status: 400,
191            body: "Message already explains itself (reason: not the real one)".to_string(),
192            reason: None,
193        };
194        assert_eq!(err.reason(), None);
195    }
196
197    #[test]
198    fn reason_is_none_for_non_api_request_failed_variants() {
199        assert_eq!(GmailError::StateMismatch.reason(), None);
200    }
201
202    #[test]
203    fn authorization_denied_includes_description_when_present() {
204        let err = GmailError::authorization_denied("access_denied", Some("user declined"));
205        let msg = err.to_string();
206        assert!(msg.contains("access_denied"));
207        assert!(msg.contains("user declined"));
208        assert!(msg.contains('('));
209    }
210
211    #[test]
212    fn authorization_denied_omits_parens_when_absent() {
213        let err = GmailError::authorization_denied("access_denied", None);
214        let msg = err.to_string();
215        assert!(msg.contains("access_denied"));
216        assert!(!msg.contains('('));
217    }
218
219    #[test]
220    fn authorization_denied_omits_parens_when_description_empty() {
221        let err = GmailError::authorization_denied("access_denied", Some(""));
222        let msg = err.to_string();
223        assert!(!msg.contains('('));
224    }
225
226    #[test]
227    fn callback_timeout_display_includes_seconds() {
228        let err = GmailError::CallbackTimeout(120);
229        assert!(err.to_string().contains("120"));
230    }
231
232    #[test]
233    fn malformed_callback_display() {
234        assert!(GmailError::MalformedCallback
235            .to_string()
236            .to_lowercase()
237            .contains("malformed"));
238    }
239
240    #[test]
241    fn invalid_grant_code_exchange_display_mentions_pkce_and_expired_code() {
242        let err = GmailError::InvalidGrant(GrantContext::CodeExchange);
243        let msg = err.to_string();
244        assert!(msg.contains("PKCE"));
245        assert!(msg.contains("expired"));
246        assert!(msg.contains("auth login"));
247    }
248
249    #[test]
250    fn invalid_grant_refresh_display_mentions_7_days_and_testing_mode() {
251        let err = GmailError::InvalidGrant(GrantContext::Refresh);
252        let msg = err.to_string();
253        assert!(msg.contains("7 days"));
254        assert!(msg.contains("Testing"));
255        assert!(msg.contains("auth login"));
256    }
257
258    #[test]
259    fn grant_context_variants_produce_distinct_messages() {
260        let code = GrantContext::CodeExchange.to_string();
261        let refresh = GrantContext::Refresh.to_string();
262        assert_ne!(code, refresh);
263    }
264
265    #[test]
266    fn malformed_token_response_names_the_missing_field() {
267        let err = GmailError::MalformedTokenResponse("refresh_token");
268        assert!(err.to_string().contains("refresh_token"));
269    }
270
271    #[test]
272    fn invalid_browser_command_display_includes_detail() {
273        let err = GmailError::InvalidBrowserCommand("empty command".to_string());
274        assert!(err.to_string().contains("empty command"));
275    }
276
277    #[test]
278    fn no_gmail_scope_granted_display_names_the_received_scopes() {
279        let err = GmailError::NoGmailScopeGranted("openid, email, profile".to_string());
280        let msg = err.to_string();
281        assert!(msg.contains("openid, email, profile"));
282        assert!(msg.contains("consent screen"));
283        assert!(msg.contains("auth login"));
284    }
285}