Skip to main content

omni_dev/gmail/
labels_api.rs

1//! Gmail Labels API wrapper.
2//!
3//! No pagination — Gmail's `labels.list` returns every label in one call.
4//! Label *creation* is not in this issue's endpoint list; the CLI's
5//! `label add`/`remove` map to
6//! [`MessagesApi::batch_modify`](crate::gmail::messages_api::MessagesApi::batch_modify),
7//! not to anything here.
8
9use anyhow::Result;
10use url::Url;
11
12use crate::gmail::client::GmailClient;
13use crate::gmail::types::{Label, LabelListResponse};
14
15/// Labels API façade.
16#[derive(Debug)]
17pub struct LabelsApi<'a> {
18    client: &'a GmailClient,
19}
20
21impl<'a> LabelsApi<'a> {
22    /// Wraps an existing [`GmailClient`] for label operations.
23    #[must_use]
24    pub fn new(client: &'a GmailClient) -> Self {
25        Self { client }
26    }
27
28    /// Lists every label on the mailbox.
29    pub async fn list(&self) -> Result<LabelListResponse> {
30        let url = build_labels_list_url(self.client.base_url())?;
31        self.client
32            .get_parsed(url.as_str(), "Failed to parse labels.list response")
33            .await
34    }
35
36    /// Fetches a single label by id.
37    pub async fn get(&self, id: &str) -> Result<Label> {
38        let url = build_label_get_url(self.client.base_url(), id)?;
39        self.client
40            .get_parsed(url.as_str(), "Failed to parse labels.get response")
41            .await
42    }
43}
44
45fn build_labels_list_url(base_url: &str) -> Result<Url> {
46    GmailClient::api_url(base_url, "/gmail/v1/users/me/labels")
47}
48
49fn build_label_get_url(base_url: &str, id: &str) -> Result<Url> {
50    GmailClient::api_url(base_url, &format!("/gmail/v1/users/me/labels/{id}"))
51}
52
53#[cfg(test)]
54#[allow(clippy::unwrap_used, clippy::expect_used)]
55mod tests {
56    use super::*;
57    use crate::gmail::auth::{GmailCredentials, GmailScope};
58    use crate::utils::secret::Secret;
59
60    fn test_credentials() -> GmailCredentials {
61        GmailCredentials {
62            client_id: "client-1".to_string(),
63            client_secret: Secret::new("secret-1"),
64            refresh_token: Secret::new("refresh-1"),
65            scope: GmailScope::ReadOnly,
66        }
67    }
68
69    fn dead_client() -> GmailClient {
70        // Routes the session's token endpoint to the same dead address —
71        // otherwise `GmailSession` would try to refresh against the real
72        // Google token endpoint before the API call is ever attempted.
73        let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
74        crate::gmail::client::test_support::replace_session(
75            &mut client,
76            &test_credentials(),
77            "http://127.0.0.1:1",
78        );
79        client
80    }
81
82    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
83        wiremock::Mock::given(wiremock::matchers::method("POST"))
84            .and(wiremock::matchers::path("/token"))
85            .respond_with(
86                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
87                    "access_token": "test-token",
88                    "expires_in": 3600,
89                })),
90            )
91            .mount(server)
92            .await;
93
94        let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
95        crate::gmail::client::test_support::replace_session(
96            &mut client,
97            &test_credentials(),
98            &format!("{}/token", server.uri()),
99        );
100        client
101    }
102
103    // ── URL builders (pure) ──────────────────────────────────────────
104
105    #[test]
106    fn build_labels_list_url_is_exact() {
107        let url = build_labels_list_url("https://gmail.googleapis.com").unwrap();
108        assert_eq!(
109            url.as_str(),
110            "https://gmail.googleapis.com/gmail/v1/users/me/labels"
111        );
112    }
113
114    #[test]
115    fn build_label_get_url_interpolates_id() {
116        let url = build_label_get_url("https://gmail.googleapis.com", "Label_1").unwrap();
117        assert_eq!(
118            url.as_str(),
119            "https://gmail.googleapis.com/gmail/v1/users/me/labels/Label_1"
120        );
121    }
122
123    #[test]
124    fn build_label_get_url_percent_encodes_special_characters_in_user_label_ids() {
125        let url = build_label_get_url("https://gmail.googleapis.com", "a b/c").unwrap();
126        assert!(!url.as_str().contains(' '));
127    }
128
129    #[test]
130    fn build_urls_reject_invalid_base_url() {
131        assert!(build_labels_list_url("not a url").is_err());
132        assert!(build_label_get_url("not a url", "id").is_err());
133    }
134
135    // ── list ─────────────────────────────────────────────────────────
136
137    #[tokio::test]
138    async fn list_parses_labels_with_and_without_color() {
139        let server = wiremock::MockServer::start().await;
140        let client = client_with_bootstrapped_token(&server).await;
141        wiremock::Mock::given(wiremock::matchers::method("GET"))
142            .and(wiremock::matchers::path("/gmail/v1/users/me/labels"))
143            .respond_with(
144                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
145                    "labels": [
146                        {"id": "INBOX", "name": "INBOX", "type": "system"},
147                        {
148                            "id": "Label_1",
149                            "name": "Finance",
150                            "type": "user",
151                            "color": {"textColor": "#000000", "backgroundColor": "#ffffff"},
152                        },
153                    ]
154                })),
155            )
156            .expect(1)
157            .mount(&server)
158            .await;
159
160        let result = LabelsApi::new(&client).list().await.unwrap();
161        assert_eq!(result.labels.len(), 2);
162        assert!(result.labels[0].is_system());
163        assert!(result.labels[1].color.is_some());
164    }
165
166    #[tokio::test]
167    async fn list_propagates_api_errors() {
168        let server = wiremock::MockServer::start().await;
169        let client = client_with_bootstrapped_token(&server).await;
170        wiremock::Mock::given(wiremock::matchers::method("GET"))
171            .and(wiremock::matchers::path("/gmail/v1/users/me/labels"))
172            .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
173            .mount(&server)
174            .await;
175
176        let err = LabelsApi::new(&client).list().await.unwrap_err();
177        assert!(err.to_string().contains("403"));
178    }
179
180    #[tokio::test]
181    async fn list_propagates_network_errors() {
182        // `dead_client()` also points the session's token endpoint at the
183        // dead address, so the failure surfaces during token acquisition
184        // before the labels.list request is ever attempted.
185        let client = dead_client();
186        let err = LabelsApi::new(&client).list().await.unwrap_err();
187        assert!(err
188            .to_string()
189            .contains("Failed to obtain a Gmail access token"));
190    }
191
192    // ── get ───────────────────────────────────────────────────────────
193
194    #[tokio::test]
195    async fn get_builds_correct_url_and_parses_a_single_label() {
196        let server = wiremock::MockServer::start().await;
197        let client = client_with_bootstrapped_token(&server).await;
198        wiremock::Mock::given(wiremock::matchers::method("GET"))
199            .and(wiremock::matchers::path(
200                "/gmail/v1/users/me/labels/Label_1",
201            ))
202            .respond_with(
203                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
204                    "id": "Label_1",
205                    "name": "Finance",
206                    "type": "user",
207                })),
208            )
209            .expect(1)
210            .mount(&server)
211            .await;
212
213        let label = LabelsApi::new(&client).get("Label_1").await.unwrap();
214        assert_eq!(label.name, "Finance");
215    }
216}