Skip to main content

io_gmail/v1/
history_poll.rs

1//! Infinite polling watch coroutine built on the history API.
2//!
3//! Baselines the history cursor via `users.getProfile`, then polls
4//! `users.history.list` on a timer (yielding `WantsSleep`) and emits
5//! one raw `GmailHistoryDiff` per tick.
6//!
7//! Gmail sync guide: <https://developers.google.com/gmail/api/guides/sync>
8
9use core::{convert::Infallible, mem, time::Duration};
10
11use alloc::{string::String, vec::Vec};
12
13use io_http::rfc6750::bearer::HttpAuthBearer;
14use log::{debug, trace};
15use thiserror::Error;
16
17use crate::{
18    coroutine::*,
19    v1::rest::history::{
20        GmailHistoryLabel,
21        list::{GmailHistoryList, GmailHistoryListParams},
22    },
23    v1::rest::messages::{GmailMessage, GmailMessageFormat, GmailMessageId, get::GmailMessageGet},
24    v1::rest::users::get_profile::GmailProfileGet,
25    v1::send::GmailSendError,
26};
27
28const POLL_SECONDS: u64 = 30;
29
30/// Errors that can occur during the watch.
31#[derive(Debug, Error)]
32pub enum GmailHistoryPollError {
33    /// One of the underlying Gmail exchanges failed.
34    #[error(transparent)]
35    Send(#[from] GmailSendError),
36}
37
38/// One tick's worth of mailbox changes, Gmail-native.
39///
40/// Consumers translate it into their own change representation; io-gmail
41/// does not interpret it further.
42#[derive(Clone, Debug, Default)]
43pub struct GmailHistoryDiff {
44    /// The history cursor after this diff, to persist for resuming.
45    pub history_id: String,
46    /// Messages added to the mailbox since the last tick.
47    pub added: Vec<GmailMessage>,
48    /// Messages removed from the mailbox since the last tick.
49    pub removed: Vec<GmailMessageId>,
50    /// Label additions on individual messages.
51    pub labels_added: Vec<GmailHistoryLabel>,
52    /// Label removals on individual messages.
53    pub labels_removed: Vec<GmailHistoryLabel>,
54}
55
56/// I/O request or event yielded by the watch.
57#[derive(Debug)]
58pub enum GmailHistoryPollYield {
59    /// The watch wants bytes read from the stream.
60    WantsRead,
61    /// The watch wants the given bytes written to the stream.
62    WantsWrite(Vec<u8>),
63    /// Asks the caller to sleep until the next poll.
64    WantsSleep(Duration),
65    /// One tick's worth of changes; the watch then goes back to sleep.
66    Diff(GmailHistoryDiff),
67}
68
69/// I/O-free coroutine watching a mailbox by polling `users.history.list`.
70///
71/// Never completes successfully (its return type is `Infallible`): it
72/// yields one [`GmailHistoryDiff`] per tick and re-baselines itself when
73/// the server reports an expired history cursor.
74pub struct GmailHistoryPoll {
75    state: State,
76    auth: HttpAuthBearer,
77    user_id: String,
78    mailbox: String,
79    history_id: Option<String>,
80}
81
82impl GmailHistoryPoll {
83    /// Builds the watch over the given mailbox label, baselining the
84    /// history cursor first.
85    pub fn new(
86        auth: &HttpAuthBearer,
87        user_id: &str,
88        mailbox: &str,
89    ) -> Result<Self, GmailHistoryPollError> {
90        debug!("prepare gmail poll history");
91        trace!("user_id: {user_id:?}");
92        trace!("mailbox: {mailbox:?}");
93
94        let profile = GmailProfileGet::new(auth, user_id)?;
95        Ok(Self {
96            state: State::Baseline(profile),
97            auth: auth.clone(),
98            user_id: user_id.into(),
99            mailbox: mailbox.into(),
100            history_id: None,
101        })
102    }
103
104    fn list_history(&self, page_token: Option<&str>) -> Result<GmailHistoryList, GmailSendError> {
105        let params = GmailHistoryListParams {
106            start_history_id: self.history_id.as_deref().unwrap_or_default(),
107            label_id: Some(&self.mailbox),
108            history_types: &[],
109            max_results: None,
110            page_token,
111        };
112        GmailHistoryList::new(&self.auth, &self.user_id, &params)
113    }
114
115    fn get_message(&self, id: &str) -> Result<GmailMessageGet, GmailSendError> {
116        GmailMessageGet::new(
117            &self.auth,
118            &self.user_id,
119            id,
120            GmailMessageFormat::Metadata,
121            &[],
122        )
123    }
124
125    fn finalize(&mut self, cycle: Cycle) -> GmailHistoryDiff {
126        let history_id = cycle
127            .new_history_id
128            .or_else(|| self.history_id.clone())
129            .unwrap_or_default();
130        self.history_id = Some(history_id.clone());
131        self.state = State::Sleeping;
132        GmailHistoryDiff {
133            history_id,
134            added: cycle.added,
135            removed: cycle.removed,
136            labels_added: cycle.labels_added,
137            labels_removed: cycle.labels_removed,
138        }
139    }
140}
141
142impl GmailCoroutine for GmailHistoryPoll {
143    type Yield = GmailHistoryPollYield;
144    type Return = Result<Infallible, GmailHistoryPollError>;
145
146    fn resume(&mut self, bytes: Option<&[u8]>) -> GmailCoroutineState<Self::Yield, Self::Return> {
147        let mut bytes = bytes;
148        loop {
149            match mem::replace(&mut self.state, State::Done) {
150                State::Baseline(mut profile) => match profile.resume(bytes.take()) {
151                    GmailCoroutineState::Yielded(GmailYield::WantsRead) => {
152                        self.state = State::Baseline(profile);
153                        return GmailCoroutineState::Yielded(GmailHistoryPollYield::WantsRead);
154                    }
155                    GmailCoroutineState::Yielded(GmailYield::WantsWrite(out)) => {
156                        self.state = State::Baseline(profile);
157                        return GmailCoroutineState::Yielded(GmailHistoryPollYield::WantsWrite(
158                            out,
159                        ));
160                    }
161                    GmailCoroutineState::Complete(Err(err)) => {
162                        return GmailCoroutineState::Complete(Err(err.into()));
163                    }
164                    GmailCoroutineState::Complete(Ok(out)) => {
165                        self.history_id = out.response.history_id;
166                        self.state = State::Sleeping;
167                    }
168                },
169                State::Sleeping => {
170                    let list = match self.list_history(None) {
171                        Ok(list) => list,
172                        Err(err) => return GmailCoroutineState::Complete(Err(err.into())),
173                    };
174                    self.state = State::Listing {
175                        list,
176                        cycle: Cycle::default(),
177                    };
178                    return GmailCoroutineState::Yielded(GmailHistoryPollYield::WantsSleep(
179                        Duration::from_secs(POLL_SECONDS),
180                    ));
181                }
182                State::Listing {
183                    mut list,
184                    mut cycle,
185                } => match list.resume(bytes.take()) {
186                    GmailCoroutineState::Yielded(GmailYield::WantsRead) => {
187                        self.state = State::Listing { list, cycle };
188                        return GmailCoroutineState::Yielded(GmailHistoryPollYield::WantsRead);
189                    }
190                    GmailCoroutineState::Yielded(GmailYield::WantsWrite(out)) => {
191                        self.state = State::Listing { list, cycle };
192                        return GmailCoroutineState::Yielded(GmailHistoryPollYield::WantsWrite(
193                            out,
194                        ));
195                    }
196                    GmailCoroutineState::Complete(Err(err)) => {
197                        if err.status() == Some(404) {
198                            debug!("history cursor expired, re-baselining");
199                            let profile = match GmailProfileGet::new(&self.auth, &self.user_id) {
200                                Ok(profile) => profile,
201                                Err(err) => {
202                                    return GmailCoroutineState::Complete(Err(err.into()));
203                                }
204                            };
205                            self.history_id = None;
206                            self.state = State::Baseline(profile);
207                            continue;
208                        }
209                        return GmailCoroutineState::Complete(Err(err.into()));
210                    }
211                    GmailCoroutineState::Complete(Ok(out)) => {
212                        let response = out.response;
213
214                        for record in &response.history {
215                            for message in &record.messages_added {
216                                cycle.added_ids.push(message.message.id.clone());
217                            }
218                            for message in &record.messages_deleted {
219                                cycle.removed.push(GmailMessageId {
220                                    id: message.message.id.clone(),
221                                    thread_id: message.message.thread_id.clone(),
222                                });
223                            }
224                            for label in &record.labels_added {
225                                cycle.labels_added.push(label.clone());
226                            }
227                            for label in &record.labels_removed {
228                                cycle.labels_removed.push(label.clone());
229                            }
230                        }
231
232                        if let Some(token) = response.next_page_token {
233                            let list = match self.list_history(Some(&token)) {
234                                Ok(list) => list,
235                                Err(err) => {
236                                    return GmailCoroutineState::Complete(Err(err.into()));
237                                }
238                            };
239                            self.state = State::Listing { list, cycle };
240                            continue;
241                        }
242
243                        cycle.new_history_id = response.history_id;
244
245                        if cycle.added_ids.is_empty() {
246                            let diff = self.finalize(cycle);
247                            return GmailCoroutineState::Yielded(GmailHistoryPollYield::Diff(diff));
248                        }
249
250                        let ids = mem::take(&mut cycle.added_ids);
251                        let current = match self.get_message(&ids[0]) {
252                            Ok(get) => get,
253                            Err(err) => return GmailCoroutineState::Complete(Err(err.into())),
254                        };
255                        self.state = State::Fetching {
256                            ids,
257                            index: 0,
258                            current,
259                            cycle,
260                        };
261                    }
262                },
263                State::Fetching {
264                    ids,
265                    index,
266                    mut current,
267                    mut cycle,
268                } => match current.resume(bytes.take()) {
269                    GmailCoroutineState::Yielded(GmailYield::WantsRead) => {
270                        self.state = State::Fetching {
271                            ids,
272                            index,
273                            current,
274                            cycle,
275                        };
276                        return GmailCoroutineState::Yielded(GmailHistoryPollYield::WantsRead);
277                    }
278                    GmailCoroutineState::Yielded(GmailYield::WantsWrite(out)) => {
279                        self.state = State::Fetching {
280                            ids,
281                            index,
282                            current,
283                            cycle,
284                        };
285                        return GmailCoroutineState::Yielded(GmailHistoryPollYield::WantsWrite(
286                            out,
287                        ));
288                    }
289                    GmailCoroutineState::Complete(result) => {
290                        match result {
291                            Ok(out) => cycle.added.push(out.response),
292                            // NOTE: a just-added message may already be gone
293                            // by the time we fetch it; skip it rather than
294                            // tearing the watch down.
295                            Err(err) => trace!("skipping message get: {err}"),
296                        }
297
298                        let index = index + 1;
299                        if index < ids.len() {
300                            let current = match self.get_message(&ids[index]) {
301                                Ok(get) => get,
302                                Err(err) => {
303                                    return GmailCoroutineState::Complete(Err(err.into()));
304                                }
305                            };
306                            self.state = State::Fetching {
307                                ids,
308                                index,
309                                current,
310                                cycle,
311                            };
312                        } else {
313                            let diff = self.finalize(cycle);
314                            return GmailCoroutineState::Yielded(GmailHistoryPollYield::Diff(diff));
315                        }
316                    }
317                },
318                // SAFETY: every arm reassigns `state` before yielding or
319                // continuing, so the watch never rests in `Done`.
320                State::Done => unreachable!("gmail watch resumed in terminal state"),
321            }
322        }
323    }
324}
325
326#[derive(Default)]
327struct Cycle {
328    added_ids: Vec<String>,
329    added: Vec<GmailMessage>,
330    removed: Vec<GmailMessageId>,
331    labels_added: Vec<GmailHistoryLabel>,
332    labels_removed: Vec<GmailHistoryLabel>,
333    new_history_id: Option<String>,
334}
335
336enum State {
337    Baseline(GmailProfileGet),
338    Sleeping,
339    Listing {
340        list: GmailHistoryList,
341        cycle: Cycle,
342    },
343    Fetching {
344        ids: Vec<String>,
345        index: usize,
346        current: GmailMessageGet,
347        cycle: Cycle,
348    },
349    Done,
350}