1use anyhow::Result;
11use url::Url;
12
13use crate::gmail::client::GmailClient;
14use crate::gmail::types::HistoryListResponse;
15use crate::utils::rate_limit::TokenBucket;
16
17pub const MAX_PAGE_LIMIT: usize = 500;
19
20pub const HARD_CAP: usize = 10_000;
23
24pub const HISTORY_LIST_COST_UNITS: u32 = 2;
27
28#[derive(Debug)]
30pub struct HistoryApi<'a> {
31 client: &'a GmailClient,
32}
33
34impl<'a> HistoryApi<'a> {
35 #[must_use]
37 pub fn new(client: &'a GmailClient) -> Self {
38 Self { client }
39 }
40
41 pub async fn list(
48 &self,
49 start_history_id: &str,
50 history_types: &[&str],
51 limit: usize,
52 page_token: Option<&str>,
53 ) -> Result<HistoryListResponse> {
54 if limit > MAX_PAGE_LIMIT {
55 return Err(anyhow::anyhow!(
56 "`limit` must be <= {MAX_PAGE_LIMIT} (Gmail history.list per-page cap; use \
57 `list_all` to auto-paginate)"
58 ));
59 }
60 let url = build_history_list_url(
61 self.client.base_url(),
62 start_history_id,
63 history_types,
64 limit,
65 page_token,
66 )?;
67 self.client
68 .get_parsed(url.as_str(), "Failed to parse history.list response")
69 .await
70 }
71
72 pub async fn list_all(
78 &self,
79 start_history_id: &str,
80 history_types: &[&str],
81 limit: usize,
82 ) -> Result<HistoryListResponse> {
83 self.paginate(
84 start_history_id,
85 history_types,
86 Some(effective_cap(limit)),
87 None,
88 )
89 .await
90 }
91
92 pub(crate) async fn list_all_unbounded(
109 &self,
110 start_history_id: &str,
111 history_types: &[&str],
112 limiter: &TokenBucket,
113 ) -> Result<HistoryListResponse> {
114 self.paginate(start_history_id, history_types, None, Some(limiter))
115 .await
116 }
117
118 async fn paginate(
122 &self,
123 start_history_id: &str,
124 history_types: &[&str],
125 cap: Option<usize>,
126 limiter: Option<&TokenBucket>,
127 ) -> Result<HistoryListResponse> {
128 let mut acc: Option<HistoryListResponse> = None;
129 let mut page_token: Option<String> = None;
130 loop {
131 let collected = acc.as_ref().map_or(0, |r| r.history.len());
132 let page_size = match cap {
133 Some(cap) => (cap - collected).min(MAX_PAGE_LIMIT),
134 None => MAX_PAGE_LIMIT,
135 };
136 if let Some(limiter) = limiter {
137 limiter.acquire(HISTORY_LIST_COST_UNITS).await;
138 }
139 let page = self
140 .list(
141 start_history_id,
142 history_types,
143 page_size,
144 page_token.as_deref(),
145 )
146 .await?;
147 let next_token = page.next_page_token.clone();
148 match acc.as_mut() {
149 Some(existing) => {
150 existing.history.extend(page.history);
151 existing.next_page_token = page.next_page_token;
152 existing.history_id = page.history_id;
153 }
154 None => acc = Some(page),
155 }
156 let collected = acc.as_ref().map_or(0, |r| r.history.len());
157 let cap_reached = cap.is_some_and(|cap| collected >= cap);
158 if cap_reached || next_token.is_none() {
159 break;
160 }
161 page_token = next_token;
162 }
163 let mut result = acc.unwrap_or_default();
164 if let Some(cap) = cap {
165 result.history.truncate(cap);
166 }
167 Ok(result)
168 }
169}
170
171fn build_history_list_url(
172 base_url: &str,
173 start_history_id: &str,
174 history_types: &[&str],
175 limit: usize,
176 page_token: Option<&str>,
177) -> Result<Url> {
178 let mut url = GmailClient::api_url(base_url, "/gmail/v1/users/me/history")?;
179 {
180 let mut pairs = url.query_pairs_mut();
181 pairs.append_pair("startHistoryId", start_history_id);
182 for history_type in history_types {
183 pairs.append_pair("historyTypes", history_type);
184 }
185 if limit > 0 {
186 pairs.append_pair("maxResults", &limit.to_string());
187 }
188 if let Some(token) = page_token {
189 pairs.append_pair("pageToken", token);
190 }
191 }
192 Ok(url)
193}
194
195fn effective_cap(limit: usize) -> usize {
198 if limit == 0 {
199 HARD_CAP
200 } else {
201 limit.min(HARD_CAP)
202 }
203}
204
205#[cfg(test)]
206#[allow(clippy::unwrap_used, clippy::expect_used)]
207mod tests {
208 use super::*;
209 use crate::gmail::auth::{GmailCredentials, GmailScope};
210 use crate::utils::secret::Secret;
211 use std::sync::atomic::{AtomicUsize, Ordering};
212
213 struct SequentialPages {
219 full_pages: usize,
220 calls: AtomicUsize,
221 }
222
223 impl wiremock::Respond for SequentialPages {
224 fn respond(&self, _req: &wiremock::Request) -> wiremock::ResponseTemplate {
225 let call = self.calls.fetch_add(1, Ordering::SeqCst);
226 if call < self.full_pages {
227 let page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
228 .map(|i| history_record_json(&format!("p{call}-h{i}")))
229 .collect();
230 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
231 "history": page,
232 "nextPageToken": format!("token-{}", call + 1),
233 }))
234 } else {
235 wiremock::ResponseTemplate::new(200)
236 .set_body_json(serde_json::json!({"history": Vec::<serde_json::Value>::new()}))
237 }
238 }
239 }
240
241 fn test_credentials() -> GmailCredentials {
242 GmailCredentials {
243 client_id: "client-1".to_string(),
244 client_secret: Secret::new("secret-1"),
245 refresh_token: Secret::new("refresh-1"),
246 scope: GmailScope::ReadOnly,
247 }
248 }
249
250 fn dead_client() -> GmailClient {
251 let mut client = GmailClient::new("http://127.0.0.1:1", &test_credentials()).unwrap();
255 crate::gmail::client::test_support::replace_session(
256 &mut client,
257 &test_credentials(),
258 "http://127.0.0.1:1",
259 );
260 client
261 }
262
263 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
264 wiremock::Mock::given(wiremock::matchers::method("POST"))
265 .and(wiremock::matchers::path("/token"))
266 .respond_with(
267 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
268 "access_token": "test-token",
269 "expires_in": 3600,
270 })),
271 )
272 .mount(server)
273 .await;
274
275 let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
276 crate::gmail::client::test_support::replace_session(
277 &mut client,
278 &test_credentials(),
279 &format!("{}/token", server.uri()),
280 );
281 client
282 }
283
284 fn history_record_json(id: &str) -> serde_json::Value {
285 serde_json::json!({"id": id})
286 }
287
288 fn page_body(
289 ids: &[&str],
290 next_token: Option<&str>,
291 final_history_id: Option<&str>,
292 ) -> serde_json::Value {
293 let history: Vec<serde_json::Value> =
294 ids.iter().map(|id| history_record_json(id)).collect();
295 let mut body = serde_json::json!({"history": history});
296 if let Some(token) = next_token {
297 body["nextPageToken"] = serde_json::json!(token);
298 }
299 if let Some(hid) = final_history_id {
300 body["historyId"] = serde_json::json!(hid);
301 }
302 body
303 }
304
305 #[test]
308 fn build_history_list_url_always_includes_start_history_id() {
309 let url =
310 build_history_list_url("https://gmail.googleapis.com", "1000", &[], 0, None).unwrap();
311 assert!(url
312 .query_pairs()
313 .any(|pair| pair == ("startHistoryId".into(), "1000".into())));
314 }
315
316 #[test]
317 fn build_history_list_url_repeats_history_types() {
318 let url = build_history_list_url(
319 "https://gmail.googleapis.com",
320 "1000",
321 &["messageAdded", "labelAdded"],
322 0,
323 None,
324 )
325 .unwrap();
326 let query: Vec<_> = url.query_pairs().collect();
327 assert!(query.contains(&("historyTypes".into(), "messageAdded".into())));
328 assert!(query.contains(&("historyTypes".into(), "labelAdded".into())));
329 }
330
331 #[test]
332 fn build_history_list_url_rejects_invalid_base_url() {
333 let err = build_history_list_url("not a url", "1000", &[], 0, None).unwrap_err();
334 assert!(err.to_string().contains("Invalid Gmail base URL"));
335 }
336
337 #[tokio::test]
340 async fn list_propagates_api_errors() {
341 let server = wiremock::MockServer::start().await;
342 let client = client_with_bootstrapped_token(&server).await;
343 wiremock::Mock::given(wiremock::matchers::method("GET"))
344 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
345 .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
346 .mount(&server)
347 .await;
348
349 let err = HistoryApi::new(&client)
350 .list("1000", &[], 10, None)
351 .await
352 .unwrap_err();
353 assert!(err.to_string().contains("400"));
354 }
355
356 #[tokio::test]
357 async fn list_propagates_404_not_found_for_expired_start_history_id() {
358 let server = wiremock::MockServer::start().await;
359 let client = client_with_bootstrapped_token(&server).await;
360 wiremock::Mock::given(wiremock::matchers::method("GET"))
361 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
362 .respond_with(
363 wiremock::ResponseTemplate::new(404).set_body_json(serde_json::json!({
364 "error": {"message": "Not Found", "errors": [{"reason": "notFound"}]}
365 })),
366 )
367 .mount(&server)
368 .await;
369
370 let err = HistoryApi::new(&client)
371 .list("1", &[], 10, None)
372 .await
373 .unwrap_err();
374 let msg = err.to_string();
375 assert!(msg.contains("404"));
376 assert!(msg.contains("notFound"));
377 }
378
379 #[tokio::test]
380 async fn list_rejects_limit_above_max_page_limit_client_side() {
381 let client = dead_client();
382 let err = HistoryApi::new(&client)
383 .list("1000", &[], MAX_PAGE_LIMIT + 1, None)
384 .await
385 .unwrap_err();
386 let msg = err.to_string();
387 assert!(msg.contains("limit"));
388 assert!(msg.contains("list_all"));
389 }
390
391 #[tokio::test]
392 async fn list_propagates_network_errors() {
393 let client = dead_client();
397 let err = HistoryApi::new(&client)
398 .list("1000", &[], 10, None)
399 .await
400 .unwrap_err();
401 assert!(err
402 .to_string()
403 .contains("Failed to obtain a Gmail access token"));
404 }
405
406 #[tokio::test]
407 async fn list_errors_on_malformed_response() {
408 let server = wiremock::MockServer::start().await;
409 let client = client_with_bootstrapped_token(&server).await;
410 wiremock::Mock::given(wiremock::matchers::method("GET"))
411 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
412 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
413 .mount(&server)
414 .await;
415
416 let err = HistoryApi::new(&client)
417 .list("1000", &[], 10, None)
418 .await
419 .unwrap_err();
420 assert!(err.to_string().contains("Failed to parse"));
421 }
422
423 #[tokio::test]
426 async fn list_all_single_page_when_no_next_token() {
427 let server = wiremock::MockServer::start().await;
428 let client = client_with_bootstrapped_token(&server).await;
429 wiremock::Mock::given(wiremock::matchers::method("GET"))
430 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
431 .respond_with(
432 wiremock::ResponseTemplate::new(200).set_body_json(page_body(
433 &["a", "b"],
434 None,
435 Some("2000"),
436 )),
437 )
438 .expect(1)
439 .mount(&server)
440 .await;
441
442 let result = HistoryApi::new(&client)
443 .list_all("1000", &[], 100)
444 .await
445 .unwrap();
446 assert_eq!(result.history.len(), 2);
447 assert_eq!(result.history_id.as_deref(), Some("2000"));
448 }
449
450 #[tokio::test]
451 async fn list_all_follows_next_page_token_to_exhaustion() {
452 let server = wiremock::MockServer::start().await;
453 let client = client_with_bootstrapped_token(&server).await;
454 wiremock::Mock::given(wiremock::matchers::method("GET"))
455 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
456 .and(wiremock::matchers::query_param_is_missing("pageToken"))
457 .respond_with(
458 wiremock::ResponseTemplate::new(200).set_body_json(page_body(
459 &["a", "b"],
460 Some("c1"),
461 None,
462 )),
463 )
464 .expect(1)
465 .mount(&server)
466 .await;
467 wiremock::Mock::given(wiremock::matchers::method("GET"))
468 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
469 .and(wiremock::matchers::query_param("pageToken", "c1"))
470 .respond_with(
471 wiremock::ResponseTemplate::new(200).set_body_json(page_body(
472 &["c"],
473 None,
474 Some("3000"),
475 )),
476 )
477 .expect(1)
478 .mount(&server)
479 .await;
480
481 let result = HistoryApi::new(&client)
482 .list_all("1000", &[], 0)
483 .await
484 .unwrap();
485 let ids: Vec<&str> = result.history.iter().map(|h| h.id.as_str()).collect();
486 assert_eq!(ids, ["a", "b", "c"]);
487 assert_eq!(result.history_id.as_deref(), Some("3000"));
488 }
489
490 #[tokio::test]
491 async fn list_all_stops_at_explicit_limit() {
492 let server = wiremock::MockServer::start().await;
493 let client = client_with_bootstrapped_token(&server).await;
494 wiremock::Mock::given(wiremock::matchers::method("GET"))
495 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
496 .respond_with(
497 wiremock::ResponseTemplate::new(200).set_body_json(page_body(
498 &["a", "b", "c"],
499 Some("more"),
500 None,
501 )),
502 )
503 .expect(1)
504 .mount(&server)
505 .await;
506
507 let result = HistoryApi::new(&client)
508 .list_all("1000", &[], 3)
509 .await
510 .unwrap();
511 assert_eq!(result.history.len(), 3);
512 }
513
514 #[tokio::test]
515 async fn list_all_truncates_to_hard_cap() {
516 let server = wiremock::MockServer::start().await;
517 let client = client_with_bootstrapped_token(&server).await;
518 let full_page: Vec<serde_json::Value> = (0..MAX_PAGE_LIMIT)
519 .map(|i| history_record_json(&format!("h{i}")))
520 .collect();
521 let body = serde_json::json!({"history": full_page, "nextPageToken": "always-more"});
522 wiremock::Mock::given(wiremock::matchers::method("GET"))
523 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
524 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
525 .mount(&server)
526 .await;
527
528 let result = HistoryApi::new(&client)
529 .list_all("1000", &[], 0)
530 .await
531 .unwrap();
532 assert_eq!(result.history.len(), HARD_CAP);
533 }
534
535 #[tokio::test]
536 async fn list_all_continues_past_empty_page_with_a_valid_next_page_token() {
537 let server = wiremock::MockServer::start().await;
538 let client = client_with_bootstrapped_token(&server).await;
539 wiremock::Mock::given(wiremock::matchers::method("GET"))
540 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
541 .and(wiremock::matchers::query_param_is_missing("pageToken"))
542 .respond_with(
543 wiremock::ResponseTemplate::new(200).set_body_json(page_body(
544 &[],
545 Some("p2"),
546 None,
547 )),
548 )
549 .expect(1)
550 .mount(&server)
551 .await;
552 wiremock::Mock::given(wiremock::matchers::method("GET"))
553 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
554 .and(wiremock::matchers::query_param("pageToken", "p2"))
555 .respond_with(
556 wiremock::ResponseTemplate::new(200).set_body_json(page_body(
557 &["a"],
558 None,
559 Some("9"),
560 )),
561 )
562 .expect(1)
563 .mount(&server)
564 .await;
565
566 let result = HistoryApi::new(&client)
567 .list_all("1000", &[], 0)
568 .await
569 .unwrap();
570 assert_eq!(result.history.len(), 1);
571 }
572
573 #[tokio::test]
574 async fn list_all_propagates_api_errors_on_first_page() {
575 let server = wiremock::MockServer::start().await;
576 let client = client_with_bootstrapped_token(&server).await;
577 wiremock::Mock::given(wiremock::matchers::method("GET"))
578 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
579 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("nope"))
580 .mount(&server)
581 .await;
582
583 let err = HistoryApi::new(&client)
584 .list_all("1000", &[], 0)
585 .await
586 .unwrap_err();
587 assert!(err.to_string().contains("403"));
588 }
589
590 #[tokio::test]
593 async fn list_all_unbounded_does_not_truncate_past_hard_cap() {
594 let server = wiremock::MockServer::start().await;
595 let client = client_with_bootstrapped_token(&server).await;
596 let full_pages = HARD_CAP / MAX_PAGE_LIMIT + 1;
600 wiremock::Mock::given(wiremock::matchers::method("GET"))
601 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
602 .respond_with(SequentialPages {
603 full_pages,
604 calls: AtomicUsize::new(0),
605 })
606 .mount(&server)
607 .await;
608
609 let limiter = TokenBucket::new(1_000_000, 1_000_000);
610 let result = HistoryApi::new(&client)
611 .list_all_unbounded("1000", &[], &limiter)
612 .await
613 .unwrap();
614
615 assert_eq!(result.history.len(), full_pages * MAX_PAGE_LIMIT);
616 assert!(result.history.len() > HARD_CAP);
617 }
618
619 #[tokio::test]
620 async fn list_all_unbounded_draws_the_limiter_once_per_page() {
621 let server = wiremock::MockServer::start().await;
622 let client = client_with_bootstrapped_token(&server).await;
623 let full_pages = 4;
624 wiremock::Mock::given(wiremock::matchers::method("GET"))
625 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
626 .respond_with(SequentialPages {
627 full_pages,
628 calls: AtomicUsize::new(0),
629 })
630 .mount(&server)
631 .await;
632
633 let limiter = TokenBucket::new(1_000_000, 0);
643 HistoryApi::new(&client)
644 .list_all_unbounded("1000", &[], &limiter)
645 .await
646 .unwrap();
647
648 let page_requests = 5;
649 let expected_spent = f64::from(page_requests * HISTORY_LIST_COST_UNITS);
650 assert_eq!(
654 limiter.available().await as i64,
655 (1_000_000.0 - expected_spent) as i64
656 );
657 }
658
659 #[tokio::test]
662 async fn list_parses_messages_added_with_label_ids() {
663 let server = wiremock::MockServer::start().await;
664 let client = client_with_bootstrapped_token(&server).await;
665 wiremock::Mock::given(wiremock::matchers::method("GET"))
666 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
667 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
668 "history": [{
669 "id": "10",
670 "messagesAdded": [{
671 "message": {"id": "m1", "threadId": "t1", "labelIds": ["INBOX", "UNREAD"]}
672 }]
673 }]
674 })))
675 .mount(&server)
676 .await;
677
678 let result = HistoryApi::new(&client)
679 .list("1", &[], 10, None)
680 .await
681 .unwrap();
682 let added = &result.history[0].messages_added[0].message;
683 assert_eq!(added.id, "m1");
684 assert_eq!(added.thread_id, "t1");
685 assert_eq!(added.label_ids, vec!["INBOX", "UNREAD"]);
686 }
687
688 #[tokio::test]
689 async fn list_parses_messages_deleted_and_label_changes() {
690 let server = wiremock::MockServer::start().await;
691 let client = client_with_bootstrapped_token(&server).await;
692 wiremock::Mock::given(wiremock::matchers::method("GET"))
693 .and(wiremock::matchers::path("/gmail/v1/users/me/history"))
694 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
695 "history": [{
696 "id": "10",
697 "messagesDeleted": [{"message": {"id": "m2", "threadId": "t2"}}],
698 "labelsAdded": [{"message": {"id": "m3", "threadId": "t3"}, "labelIds": ["IMPORTANT"]}],
699 "labelsRemoved": [{"message": {"id": "m3", "threadId": "t3"}, "labelIds": ["UNREAD"]}]
700 }]
701 })))
702 .mount(&server)
703 .await;
704
705 let result = HistoryApi::new(&client)
706 .list("1", &[], 10, None)
707 .await
708 .unwrap();
709 let record = &result.history[0];
710 assert_eq!(record.messages_deleted[0].message.id, "m2");
711 assert_eq!(record.labels_added[0].label_ids, vec!["IMPORTANT"]);
712 assert_eq!(record.labels_removed[0].label_ids, vec!["UNREAD"]);
713 }
714
715 #[test]
718 fn effective_cap_zero_is_hard_cap() {
719 assert_eq!(effective_cap(0), HARD_CAP);
720 }
721
722 #[test]
723 fn effective_cap_clamps_above_hard_cap() {
724 assert_eq!(effective_cap(HARD_CAP + 5), HARD_CAP);
725 }
726}