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#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct GmailCodeConfig {
14 pub access_token: String,
16 pub base_url: String,
18 pub user_id: String,
20 pub user_agent: Option<String>,
22 pub connect_timeout: Duration,
24 pub request_timeout: Duration,
26}
27
28impl GmailCodeConfig {
29 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#[derive(Debug, Clone)]
65pub struct GmailCodeConfigBuilder {
66 config: GmailCodeConfig,
67}
68
69impl GmailCodeConfigBuilder {
70 #[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 #[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 #[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 #[must_use]
93 pub const fn connect_timeout(mut self, value: Duration) -> Self {
94 self.config.connect_timeout = value;
95 self
96 }
97
98 #[must_use]
100 pub const fn request_timeout(mut self, value: Duration) -> Self {
101 self.config.request_timeout = value;
102 self
103 }
104
105 pub fn build(self) -> GmailCodeResult<GmailCodeConfig> {
107 self.config.validate()?;
108 Ok(self.config)
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct GmailCodeQuery {
115 pub query: Option<String>,
117 pub from: Option<String>,
119 pub subject: Option<String>,
121 pub newer_than: Option<String>,
123 pub unread: bool,
125 pub max_results: u32,
127 pub label_ids: Vec<String>,
129}
130
131impl GmailCodeQuery {
132 #[must_use]
134 pub fn new() -> Self {
135 Self::default()
136 }
137
138 #[must_use]
140 pub fn query(mut self, value: impl Into<String>) -> Self {
141 self.query = Some(value.into());
142 self
143 }
144
145 #[must_use]
147 pub fn from(mut self, value: impl Into<String>) -> Self {
148 self.from = Some(value.into());
149 self
150 }
151
152 #[must_use]
154 pub fn subject(mut self, value: impl Into<String>) -> Self {
155 self.subject = Some(value.into());
156 self
157 }
158
159 #[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 #[must_use]
168 pub const fn unread(mut self, value: bool) -> Self {
169 self.unread = value;
170 self
171 }
172
173 #[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 #[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}