Skip to main content

az_gmail_code/
config.rs

1use crate::{GmailCodeError, GmailCodeResult};
2use std::time::Duration;
3
4const DEFAULT_GMAIL_API_BASE_URL: &str = "https://gmail.googleapis.com/gmail/v1/";
5const DEFAULT_USER_ID: &str = "me";
6const DEFAULT_USER_AGENT: &str = "az-gmail-code/2026.5";
7const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
8const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
9const DEFAULT_MAX_RESULTS: u32 = 10;
10
11/// Configuration for authorized Gmail API requests.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct GmailCodeConfig {
14    /// Gmail API OAuth access token for a mailbox controlled by the caller.
15    pub access_token: String,
16    /// Gmail API base URL. Keep the default for production use.
17    pub base_url: String,
18    /// Gmail `userId` path segment. `me` is the normal value for OAuth callers.
19    pub user_id: String,
20    /// HTTP user agent sent by this client.
21    pub user_agent: Option<String>,
22    /// TCP connect timeout.
23    pub connect_timeout: Duration,
24    /// Total request timeout.
25    pub request_timeout: Duration,
26}
27
28impl GmailCodeConfig {
29    /// Starts a config builder with the required Gmail OAuth access token.
30    pub fn builder(access_token: impl Into<String>) -> GmailCodeConfigBuilder {
31        GmailCodeConfigBuilder {
32            config: Self {
33                access_token: access_token.into(),
34                base_url: DEFAULT_GMAIL_API_BASE_URL.to_owned(),
35                user_id: DEFAULT_USER_ID.to_owned(),
36                user_agent: Some(DEFAULT_USER_AGENT.to_owned()),
37                connect_timeout: Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS),
38                request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
39            },
40        }
41    }
42
43    pub(crate) fn validate(&self) -> GmailCodeResult<()> {
44        if self.access_token.trim().is_empty() {
45            return Err(GmailCodeError::InvalidConfig(
46                "access_token cannot be blank".to_owned(),
47            ));
48        }
49        if self.base_url.trim().is_empty() {
50            return Err(GmailCodeError::InvalidConfig(
51                "base_url cannot be blank".to_owned(),
52            ));
53        }
54        if self.user_id.trim().is_empty() {
55            return Err(GmailCodeError::InvalidConfig(
56                "user_id cannot be blank".to_owned(),
57            ));
58        }
59        Ok(())
60    }
61}
62
63/// Builder for [`GmailCodeConfig`].
64#[derive(Debug, Clone)]
65pub struct GmailCodeConfigBuilder {
66    config: GmailCodeConfig,
67}
68
69impl GmailCodeConfigBuilder {
70    /// Overrides the Gmail API base URL, primarily for tests.
71    #[must_use]
72    pub fn base_url(mut self, value: impl Into<String>) -> Self {
73        self.config.base_url = value.into();
74        self
75    }
76
77    /// Overrides the Gmail `userId` path segment.
78    #[must_use]
79    pub fn user_id(mut self, value: impl Into<String>) -> Self {
80        self.config.user_id = value.into();
81        self
82    }
83
84    /// Overrides or clears the HTTP user agent.
85    #[must_use]
86    pub fn user_agent(mut self, value: Option<impl Into<String>>) -> Self {
87        self.config.user_agent = value.map(Into::into);
88        self
89    }
90
91    /// Sets the TCP connect timeout.
92    #[must_use]
93    pub const fn connect_timeout(mut self, value: Duration) -> Self {
94        self.config.connect_timeout = value;
95        self
96    }
97
98    /// Sets the total request timeout.
99    #[must_use]
100    pub const fn request_timeout(mut self, value: Duration) -> Self {
101        self.config.request_timeout = value;
102        self
103    }
104
105    /// Validates and returns the config.
106    pub fn build(self) -> GmailCodeResult<GmailCodeConfig> {
107        self.config.validate()?;
108        Ok(self.config)
109    }
110}
111
112/// Search parameters for finding Gmail verification-code messages.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct GmailCodeQuery {
115    /// Raw Gmail search expression appended after structured filters.
116    pub query: Option<String>,
117    /// Optional sender filter.
118    pub from: Option<String>,
119    /// Optional subject filter.
120    pub subject: Option<String>,
121    /// Optional Gmail `newer_than:` value such as `10m`, `2h`, or `7d`.
122    pub newer_than: Option<String>,
123    /// Restricts results to unread messages when true.
124    pub unread: bool,
125    /// Maximum number of messages to inspect.
126    pub max_results: u32,
127    /// Gmail label ids, for example `INBOX`.
128    pub label_ids: Vec<String>,
129}
130
131impl GmailCodeQuery {
132    /// Creates a query with a conservative result limit.
133    #[must_use]
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Sets raw Gmail query syntax.
139    #[must_use]
140    pub fn query(mut self, value: impl Into<String>) -> Self {
141        self.query = Some(value.into());
142        self
143    }
144
145    /// Adds a Gmail `from:` filter.
146    #[must_use]
147    pub fn from(mut self, value: impl Into<String>) -> Self {
148        self.from = Some(value.into());
149        self
150    }
151
152    /// Adds a Gmail `subject:` filter.
153    #[must_use]
154    pub fn subject(mut self, value: impl Into<String>) -> Self {
155        self.subject = Some(value.into());
156        self
157    }
158
159    /// Adds a Gmail `newer_than:` filter, for example `10m`, `2h`, or `7d`.
160    #[must_use]
161    pub fn newer_than(mut self, value: impl Into<String>) -> Self {
162        self.newer_than = Some(value.into());
163        self
164    }
165
166    /// Restricts results to unread messages when true.
167    #[must_use]
168    pub const fn unread(mut self, value: bool) -> Self {
169        self.unread = value;
170        self
171    }
172
173    /// Sets the maximum number of messages to inspect. Values are clamped to `1..=100`.
174    #[must_use]
175    pub const fn max_results(mut self, value: u32) -> Self {
176        self.max_results = clamp_max_results(value);
177        self
178    }
179
180    /// Adds a Gmail label id filter, such as `INBOX`.
181    #[must_use]
182    pub fn label_id(mut self, value: impl Into<String>) -> Self {
183        self.label_ids.push(value.into());
184        self
185    }
186
187    pub(crate) fn gmail_q(&self) -> String {
188        let mut parts = Vec::new();
189        push_filter(&mut parts, "from", self.from.as_deref());
190        push_filter(&mut parts, "subject", self.subject.as_deref());
191        push_filter(&mut parts, "newer_than", self.newer_than.as_deref());
192        if self.unread {
193            parts.push("is:unread".to_owned());
194        }
195        if let Some(query) = trimmed(self.query.as_deref()) {
196            parts.push(query.to_owned());
197        }
198        parts.join(" ")
199    }
200}
201
202impl Default for GmailCodeQuery {
203    fn default() -> Self {
204        Self {
205            query: None,
206            from: None,
207            subject: None,
208            newer_than: Some("10m".to_owned()),
209            unread: false,
210            max_results: DEFAULT_MAX_RESULTS,
211            label_ids: vec!["INBOX".to_owned()],
212        }
213    }
214}
215
216const fn clamp_max_results(value: u32) -> u32 {
217    if value == 0 {
218        1
219    } else if value > 100 {
220        100
221    } else {
222        value
223    }
224}
225
226fn push_filter(parts: &mut Vec<String>, name: &str, value: Option<&str>) {
227    if let Some(value) = trimmed(value) {
228        parts.push(format!("{name}:{}", quote_query_value(value)));
229    }
230}
231
232fn trimmed(value: Option<&str>) -> Option<&str> {
233    value.and_then(|value| {
234        let value = value.trim();
235        if value.is_empty() { None } else { Some(value) }
236    })
237}
238
239fn quote_query_value(value: &str) -> String {
240    if value
241        .chars()
242        .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '(' | ')'))
243    {
244        format!("\"{}\"", value.replace('"', "\\\""))
245    } else {
246        value.to_owned()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::{GmailCodeConfig, GmailCodeQuery};
253
254    #[test]
255    fn query_builder_combines_structured_filters() {
256        let query = GmailCodeQuery::new()
257            .from("security@example.com")
258            .subject("login code")
259            .newer_than("2h")
260            .unread(true)
261            .query("category:primary");
262
263        assert_eq!(
264            query.gmail_q(),
265            r#"from:security@example.com subject:"login code" newer_than:2h is:unread category:primary"#
266        );
267    }
268
269    #[test]
270    fn max_results_is_clamped_to_gmail_limit() {
271        assert_eq!(GmailCodeQuery::new().max_results(0).max_results, 1);
272        assert_eq!(GmailCodeQuery::new().max_results(150).max_results, 100);
273    }
274
275    #[test]
276    fn blank_token_is_rejected_before_network_io() {
277        let error = GmailCodeConfig::builder("  ")
278            .build()
279            .expect_err("blank token should fail");
280
281        assert!(error.to_string().contains("access_token"));
282    }
283}