Skip to main content

omni_dev/gmail/
profile_api.rs

1//! Gmail Profile API wrapper.
2//!
3//! `users.getProfile` is a single unpaginated call — no list/cursor shape
4//! needed, unlike the other façades. Extracted out of
5//! `src/cli/gmail/auth.rs`'s previously-private `ProfileResponse` because
6//! `gmail sync` needs the same endpoint for two fields `auth status` never
7//! used: `email_address` (mandatory account-identity validation) and
8//! `history_id` (the reconciliation watermark snapshot).
9
10use anyhow::Result;
11use serde::Deserialize;
12
13use crate::gmail::client::GmailClient;
14
15/// The mailbox's current profile, as returned by `users.getProfile`.
16#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
17pub struct Profile {
18    /// The authenticated account's email address.
19    #[serde(rename = "emailAddress")]
20    pub email_address: String,
21    /// Total number of messages in the mailbox.
22    #[serde(rename = "messagesTotal")]
23    pub messages_total: i64,
24    /// Total number of threads in the mailbox.
25    #[serde(rename = "threadsTotal")]
26    pub threads_total: i64,
27    /// The mailbox's current `historyId`.
28    #[serde(rename = "historyId")]
29    pub history_id: String,
30}
31
32/// Profile API façade.
33#[derive(Debug)]
34pub struct ProfileApi<'a> {
35    client: &'a GmailClient,
36}
37
38impl<'a> ProfileApi<'a> {
39    /// Wraps an existing [`GmailClient`] for profile operations.
40    #[must_use]
41    pub fn new(client: &'a GmailClient) -> Self {
42        Self { client }
43    }
44
45    /// Fetches the authenticated account's profile.
46    pub async fn get(&self) -> Result<Profile> {
47        let url = GmailClient::api_url(self.client.base_url(), "/gmail/v1/users/me/profile")?;
48        self.client
49            .get_parsed(url.as_str(), "Failed to parse users.getProfile response")
50            .await
51    }
52}
53
54#[cfg(test)]
55#[allow(clippy::unwrap_used, clippy::expect_used)]
56mod tests {
57    use super::*;
58    use crate::gmail::auth::{GmailCredentials, GmailScope};
59    use crate::utils::secret::Secret;
60
61    fn test_credentials() -> GmailCredentials {
62        GmailCredentials {
63            client_id: "client-1".to_string(),
64            client_secret: Secret::new("secret-1"),
65            refresh_token: Secret::new("refresh-1"),
66            scope: GmailScope::ReadOnly,
67        }
68    }
69
70    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
71        wiremock::Mock::given(wiremock::matchers::method("POST"))
72            .and(wiremock::matchers::path("/token"))
73            .respond_with(
74                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
75                    "access_token": "test-token",
76                    "expires_in": 3600,
77                })),
78            )
79            .mount(server)
80            .await;
81
82        let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
83        crate::gmail::client::test_support::replace_session(
84            &mut client,
85            &test_credentials(),
86            &format!("{}/token", server.uri()),
87        );
88        client
89    }
90
91    #[tokio::test]
92    async fn get_parses_profile_fields() {
93        let server = wiremock::MockServer::start().await;
94        let client = client_with_bootstrapped_token(&server).await;
95        wiremock::Mock::given(wiremock::matchers::method("GET"))
96            .and(wiremock::matchers::path("/gmail/v1/users/me/profile"))
97            .respond_with(
98                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
99                    "emailAddress": "user@example.com",
100                    "messagesTotal": 100,
101                    "threadsTotal": 42,
102                    "historyId": "123456",
103                })),
104            )
105            .expect(1)
106            .mount(&server)
107            .await;
108
109        let profile = ProfileApi::new(&client).get().await.unwrap();
110        assert_eq!(profile.email_address, "user@example.com");
111        assert_eq!(profile.messages_total, 100);
112        assert_eq!(profile.threads_total, 42);
113        assert_eq!(profile.history_id, "123456");
114    }
115
116    #[tokio::test]
117    async fn get_propagates_api_errors() {
118        let server = wiremock::MockServer::start().await;
119        let client = client_with_bootstrapped_token(&server).await;
120        wiremock::Mock::given(wiremock::matchers::method("GET"))
121            .and(wiremock::matchers::path("/gmail/v1/users/me/profile"))
122            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
123            .mount(&server)
124            .await;
125
126        let err = ProfileApi::new(&client).get().await.unwrap_err();
127        assert!(err.to_string().contains("403"));
128    }
129
130    #[tokio::test]
131    async fn get_rejects_invalid_base_url() {
132        let client = GmailClient::new("not a url", &test_credentials()).unwrap();
133        let err = ProfileApi::new(&client).get().await.unwrap_err();
134        assert!(err.to_string().contains("Invalid Gmail base URL"));
135    }
136}