#![warn(missing_docs)]
#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
use crate::{GmailClient, MessageSummary, Result};
use google_gmail1::{
Gmail,
api::{ListMessagesResponse, Message as GmailMessage},
hyper_rustls::HttpsConnector,
hyper_util::client::legacy::connect::HttpConnector,
};
pub trait MessageList {
fn log_messages(
&mut self,
pre: &str,
post: &str,
) -> impl std::future::Future<Output = Result<()>> + Send;
fn list_messages(
&mut self,
next_page_token: Option<String>,
) -> impl std::future::Future<Output = Result<ListMessagesResponse>> + Send;
fn get_messages(&mut self, pages: u32) -> impl std::future::Future<Output = Result<()>> + Send;
fn hub(&self) -> Gmail<HttpsConnector<HttpConnector>>;
fn label_ids(&self) -> Vec<String>;
fn message_ids(&self) -> Vec<String>;
fn messages(&self) -> &Vec<MessageSummary>;
fn set_query(&mut self, query: &str);
fn add_labels_ids(&mut self, label_ids: &[String]);
fn add_labels(&mut self, labels: &[String]) -> Result<()>;
fn max_results(&self) -> u32;
fn set_max_results(&mut self, value: u32);
}
pub(crate) trait GmailService {
async fn list_messages_page(
&self,
label_ids: &[String],
query: &str,
max_results: u32,
page_token: Option<String>,
) -> Result<ListMessagesResponse>;
async fn get_message_metadata(&self, message_id: &str) -> Result<GmailMessage>;
}
impl GmailClient {
fn append_list_to_messages(out: &mut Vec<MessageSummary>, list: &ListMessagesResponse) {
if let Some(msgs) = &list.messages {
let mut list_ids: Vec<MessageSummary> = msgs
.iter()
.flat_map(|item| item.id.as_deref().map(MessageSummary::new))
.collect();
out.append(&mut list_ids);
}
}
}
impl GmailService for GmailClient {
async fn list_messages_page(
&self,
label_ids: &[String],
query: &str,
max_results: u32,
page_token: Option<String>,
) -> Result<ListMessagesResponse> {
let hub = self.hub();
let mut call = hub.users().messages_list("me").max_results(max_results);
if !label_ids.is_empty() {
for id in label_ids {
call = call.add_label_ids(id);
}
}
if !query.is_empty() {
call = call.q(query);
}
if let Some(token) = page_token {
call = call.page_token(&token);
}
let (_response, list) = call.doit().await.map_err(Box::new)?;
Ok(list)
}
async fn get_message_metadata(&self, message_id: &str) -> Result<GmailMessage> {
let hub = self.hub();
let (_res, m) = hub
.users()
.messages_get("me", message_id)
.add_scope("https://mail.google.com/")
.format("metadata")
.add_metadata_headers("subject")
.add_metadata_headers("date")
.doit()
.await
.map_err(Box::new)?;
Ok(m)
}
}
impl MessageList for GmailClient {
fn set_max_results(&mut self, value: u32) {
self.max_results = value;
}
fn max_results(&self) -> u32 {
self.max_results
}
fn add_labels(&mut self, labels: &[String]) -> Result<()> {
log::debug!("labels from command line: {labels:?}");
let mut label_ids = Vec::new();
for label in labels {
if let Some(id) = self.get_label_id(label) {
label_ids.push(id)
}
}
self.add_labels_ids(label_ids.as_slice());
Ok(())
}
fn add_labels_ids(&mut self, label_ids: &[String]) {
if !label_ids.is_empty() {
self.label_ids.extend(label_ids.iter().cloned());
}
}
fn set_query(&mut self, query: &str) {
self.query = query.to_string()
}
fn messages(&self) -> &Vec<MessageSummary> {
&self.messages
}
fn message_ids(&self) -> Vec<String> {
self.messages.iter().map(|m| m.id().to_string()).collect()
}
fn label_ids(&self) -> Vec<String> {
self.label_ids.clone()
}
fn hub(&self) -> Gmail<HttpsConnector<HttpConnector>> {
self.hub().clone()
}
async fn get_messages(&mut self, pages: u32) -> Result<()> {
let list = self.list_messages(None).await?;
match pages {
1 => {}
0 => {
let mut list = list;
let mut page = 1;
loop {
page += 1;
log::debug!("Processing page #{page}");
if list.next_page_token.is_none() {
break;
}
list = self.list_messages(list.next_page_token).await?;
}
}
_ => {
let mut list = list;
for page in 2..=pages {
log::debug!("Processing page #{page}");
if list.next_page_token.is_none() {
break;
}
list = self.list_messages(list.next_page_token).await?;
}
}
}
Ok(())
}
async fn list_messages(
&mut self,
next_page_token: Option<String>,
) -> Result<ListMessagesResponse> {
if !self.label_ids.is_empty() {
log::debug!("Setting labels for list: {:#?}", self.label_ids);
}
if !self.query.is_empty() {
log::debug!("Setting query string `{}`", self.query);
}
if next_page_token.is_some() {
log::debug!("Setting token for next page.");
}
let list = self
.list_messages_page(
&self.label_ids,
&self.query,
self.max_results,
next_page_token,
)
.await?;
log::trace!(
"Estimated {} messages.",
list.result_size_estimate.unwrap_or(0)
);
if list.result_size_estimate.unwrap_or(0) == 0 {
log::warn!("Search returned no messages.");
return Ok(list);
}
Self::append_list_to_messages(&mut self.messages, &list);
Ok(list)
}
async fn log_messages(&mut self, pre: &str, post: &str) -> Result<()> {
for i in 0..self.messages.len() {
let id = self.messages[i].id().to_string();
log::trace!("{id}");
let m = self.get_message_metadata(&id).await?;
let message = &mut self.messages[i];
log::trace!("Got the message: {m:?}");
let Some(payload) = m.payload else { continue };
let Some(headers) = payload.headers else {
continue;
};
for header in headers {
if let Some(name) = header.name {
match name.to_lowercase().as_str() {
"subject" => message.set_subject(header.value),
"date" => message.set_date(header.value),
_ => {}
}
} else {
continue;
}
}
log::info!("{pre}{}{post}", message.list_date_and_subject());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockList {
label_ids: Vec<String>,
query: String,
max_results: u32,
messages: Vec<MessageSummary>,
}
impl MockList {
fn new() -> Self {
Self {
label_ids: vec![],
query: String::new(),
max_results: 200,
messages: vec![],
}
}
fn push_msg(&mut self, id: &str) {
self.messages.push(MessageSummary::new(id));
}
}
impl MessageList for MockList {
async fn log_messages(&mut self, _pre: &str, _post: &str) -> Result<()> {
Ok(())
}
async fn list_messages(
&mut self,
_next_page_token: Option<String>,
) -> Result<ListMessagesResponse> {
Ok(ListMessagesResponse::default())
}
async fn get_messages(&mut self, _pages: u32) -> Result<()> {
Ok(())
}
fn hub(&self) -> Gmail<HttpsConnector<HttpConnector>> {
panic!("not used in tests")
}
fn label_ids(&self) -> Vec<String> {
self.label_ids.clone()
}
fn message_ids(&self) -> Vec<String> {
self.messages.iter().map(|m| m.id().to_string()).collect()
}
fn messages(&self) -> &Vec<MessageSummary> {
&self.messages
}
fn set_query(&mut self, query: &str) {
self.query = query.to_string();
}
fn add_labels_ids(&mut self, label_ids: &[String]) {
self.label_ids.extend_from_slice(label_ids);
}
fn add_labels(&mut self, _labels: &[String]) -> Result<()> {
Ok(())
}
fn max_results(&self) -> u32 {
self.max_results
}
fn set_max_results(&mut self, value: u32) {
self.max_results = value;
}
}
use std::collections::HashMap;
use std::sync::Mutex;
struct TestClient {
label_ids: Vec<String>,
query: String,
max_results: u32,
messages: Vec<MessageSummary>,
pages: Mutex<HashMap<Option<String>, ListMessagesResponse>>,
}
impl TestClient {
fn with_pages(map: HashMap<Option<String>, ListMessagesResponse>) -> Self {
Self {
label_ids: vec![],
query: String::new(),
max_results: 200,
messages: vec![],
pages: Mutex::new(map),
}
}
}
impl super::GmailService for TestClient {
async fn list_messages_page(
&self,
_label_ids: &[String],
_query: &str,
_max_results: u32,
page_token: Option<String>,
) -> Result<ListMessagesResponse> {
let map = self.pages.lock().unwrap();
Ok(map
.get(&page_token)
.cloned()
.unwrap_or_else(ListMessagesResponse::default))
}
async fn get_message_metadata(&self, _message_id: &str) -> Result<GmailMessage> {
Ok(GmailMessage::default())
}
}
impl MessageList for TestClient {
fn set_max_results(&mut self, value: u32) {
self.max_results = value;
}
fn max_results(&self) -> u32 {
self.max_results
}
fn add_labels(&mut self, _labels: &[String]) -> Result<()> {
Ok(())
}
fn add_labels_ids(&mut self, label_ids: &[String]) {
self.label_ids.extend_from_slice(label_ids);
}
fn set_query(&mut self, query: &str) {
self.query = query.to_string();
}
fn messages(&self) -> &Vec<MessageSummary> {
&self.messages
}
fn message_ids(&self) -> Vec<String> {
self.messages.iter().map(|m| m.id().to_string()).collect()
}
fn label_ids(&self) -> Vec<String> {
self.label_ids.clone()
}
fn hub(&self) -> Gmail<HttpsConnector<HttpConnector>> {
unimplemented!("not used in tests")
}
async fn get_messages(&mut self, pages: u32) -> Result<()> {
let mut list = self.list_messages(None).await?;
match pages {
1 => {}
0 => loop {
if list.next_page_token.is_none() {
break;
}
list = self.list_messages(list.next_page_token).await?;
},
_ => {
for _page in 2..=pages {
if list.next_page_token.is_none() {
break;
}
list = self.list_messages(list.next_page_token).await?;
}
}
}
Ok(())
}
async fn list_messages(
&mut self,
next_page_token: Option<String>,
) -> Result<ListMessagesResponse> {
let list = self
.list_messages_page(
&self.label_ids,
&self.query,
self.max_results,
next_page_token,
)
.await?;
if list.result_size_estimate.unwrap_or(0) == 0 {
return Ok(list);
}
if let Some(msgs) = &list.messages {
let mut list_ids: Vec<MessageSummary> = msgs
.iter()
.flat_map(|item| item.id.as_deref().map(MessageSummary::new))
.collect();
self.messages.append(&mut list_ids);
}
Ok(list)
}
async fn log_messages(&mut self, _pre: &str, _post: &str) -> Result<()> {
Ok(())
}
}
#[test]
fn set_query_updates_state() {
let mut ml = MockList::new();
ml.set_query("from:noreply@example.com");
ml.set_query("is:unread");
assert_eq!(ml.query, "is:unread");
}
#[test]
fn add_label_ids_accumulates() {
let mut ml = MockList::new();
ml.add_labels_ids(&["Label_1".into()]);
ml.add_labels_ids(&["Label_2".into(), "Label_3".into()]);
assert_eq!(ml.label_ids, vec!["Label_1", "Label_2", "Label_3"]);
}
#[test]
fn max_results_get_set() {
let mut ml = MockList::new();
assert_eq!(ml.max_results(), 200);
ml.set_max_results(123);
assert_eq!(ml.max_results(), 123);
}
#[test]
fn message_ids_maps_from_messages() {
let mut ml = MockList::new();
ml.push_msg("abc");
ml.push_msg("def");
assert_eq!(ml.message_ids(), vec!["abc", "def"]);
assert_eq!(ml.messages().len(), 2);
}
#[test]
fn append_list_to_messages_extracts_ids() {
use google_gmail1::api::Message;
let mut out = Vec::<MessageSummary>::new();
let list = ListMessagesResponse {
messages: Some(vec![
Message {
id: Some("m1".into()),
..Default::default()
},
Message {
id: None,
..Default::default()
},
Message {
id: Some("m2".into()),
..Default::default()
},
]),
..Default::default()
};
GmailClient::append_list_to_messages(&mut out, &list);
let ids: Vec<_> = out.iter().map(|m| m.id().to_string()).collect();
assert_eq!(ids, vec!["m1", "m2"]);
}
#[tokio::test]
async fn list_messages_across_pages_collects_ids() {
use google_gmail1::api::Message;
let page1 = ListMessagesResponse {
messages: Some(vec![
Message {
id: Some("a".into()),
..Default::default()
},
Message {
id: Some("b".into()),
..Default::default()
},
]),
next_page_token: Some("t2".into()),
result_size_estimate: Some(2),
};
let page2 = ListMessagesResponse {
messages: Some(vec![Message {
id: Some("c".into()),
..Default::default()
}]),
next_page_token: None,
result_size_estimate: Some(1),
};
let mut map = HashMap::new();
map.insert(None, page1);
map.insert(Some("t2".into()), page2);
let mut client = TestClient::with_pages(map);
client.set_max_results(2);
client.set_query("in:inbox");
client.get_messages(0).await.unwrap();
assert_eq!(client.message_ids(), vec!["a", "b", "c"]);
}
#[tokio::test]
async fn empty_first_page_returns_early() {
let page = ListMessagesResponse {
messages: None,
next_page_token: None,
result_size_estimate: Some(0),
};
let mut map = HashMap::new();
map.insert(None, page);
let mut client = TestClient::with_pages(map);
client.get_messages(0).await.unwrap();
assert!(client.message_ids().is_empty());
}
#[tokio::test]
async fn pages_param_gt1_but_no_next_token_stops() {
use google_gmail1::api::Message;
let first = ListMessagesResponse {
messages: Some(vec![Message {
id: Some("x".into()),
..Default::default()
}]),
next_page_token: None,
result_size_estimate: Some(1),
};
let mut map = HashMap::new();
map.insert(None, first);
let mut client = TestClient::with_pages(map);
client.get_messages(5).await.unwrap();
assert_eq!(client.message_ids(), vec!["x"]);
}
}