Keep up with a mailbox: the profile (current historyId), the
history feed for incremental sync, and push notifications through
Google Cloud Pub/Sub (watch, stop-watch, and parsing/verifying the
push request).
Sync loop
- Once: store
get-profile().historyId(or thehistoryIdfromwatch). - Each run (schedule or push):
set ::sync/sync-history(saved), handleset.added-message-ids/set.deleted-message-ids/set.history, then storeset.history-id. - If the stored id is too old Gmail answers 404
(
is-history-expired): do a full sync with::google::gmail::messages/list-all-messagesand store a freshhistoryId.
Push setup (one time, in Google Cloud)
- Create a Pub/Sub topic, e.g.
projects/my-project/topics/gmail. - Grant
gmail-api-push@system.gserviceaccount.comthe Pub/Sub Publisher role on it. - Create a push subscription on the topic whose endpoint is your Hot
webhook URL with
?token=<secret>appended; set the same secret as thegoogle.gmail.push.tokencontext variable. - Call
watch("projects/my-project/topics/gmail")for each mailbox, and again at least every 7 days (Google recommends daily) from a Hot schedule: a watch expires after 7 days.
Pushes say only "the mailbox changed up to historyId N": run
sync-history from the stored id. They can arrive late, twice, or
out of order.
Scopes. gmail.readonly for everything here (gmail.metadata
also works).
Values
MAX_HISTORY_PAGE
MAX_HISTORY_PAGE: Int 500
Functions
build-watch
fn (topic-name: Str, opts: Map): Map
The users.watch body (pure): {topicName} plus labelIds (e.g. ["INBOX"]) and labelFilterBehavior ("include" or "exclude") from opts (label-ids / label-filter-behavior, or Gmail's own names).
get-profile
fn (): Map
fn (opts: Map): Map
GET /users/me/profile - {emailAddress, messagesTotal, threadsTotal, historyId} for the authenticated user. Scope: gmail.readonly.
history-loop
fn (start: Str, opts: Map, page-token: Str?, acc: Vec, pages: Int): HistorySet
history-params
fn (start-history-id: Any, opts: Map): Map
The query parameters list-history sends (pure): startHistoryId, then your options: historyTypes (a Vec of messageAdded, messageDeleted, labelAdded, labelRemoved; sent repeated), labelId, maxResults (max 500), pageToken.
is-history-expired
fn (result: Result): Bool
True when a list-history / sync-history result is Gmail's 404 for a start id that is too old (typically older than about a week): do a full sync.
list-history
fn (start-history-id: Any): Map
fn (start-history-id: Any, opts: Map): Map
GET /users/me/history - one page of mailbox changes after
start-history-id: {history: [...], historyId, nextPageToken?}.
Each history record has id, messages, and messagesAdded,
messagesDeleted, labelsAdded, labelsRemoved. A 404 means the
id is too old (is-history-expired). Scope: gmail.readonly.
parse-push
fn (request: Map): PushNotification
Parse a Pub/Sub push request into a PushNotification (pure): the envelope {message: {data, messageId, publishTime, attributes}, subscription} with data the base64 JSON {emailAddress, historyId}. Returns an Err for a body that is not a push envelope. Does not authenticate: use verify-push.
record-ids
fn (records: Vec, key: Str): Vec
request-json
fn (request: Map): Any
stop-watch
fn (): Bool
fn (opts: Map): Bool
POST /users/me/stop - stop push notifications for this mailbox. Returns true. Scope: gmail.readonly.
summarize-history
fn (records: Vec, history-id: Str, pages: Int): HistorySet
Build a HistorySet from history records and the latest history id (pure).
sync-history
fn (start-history-id: Any): HistorySet
fn (start-history-id: Any, opts: Map): HistorySet
Read every page of history after start-history-id and return a
HistorySet with the id to store next. Uses maxResults: 500
unless set. On an expired id this returns the 404 Err (check
is-history-expired).
::sync ::google::gmail::sync
::messages ::google::gmail::messages
set ::sync/sync-history(saved-history-id)
cond {
::sync/is-history-expired(set) => { full-resync() }
=> {
new-mail map(set.added-message-ids, (id) { ::messages/get-email(id) })
set.history-id
}
}
verify-push
fn (request: Map): PushNotification
fn (request: Map, expected: Str): PushNotification
Verify and parse a Gmail push: returns the PushNotification, or an
Err when the ?token= secret on the push endpoint is missing or
wrong. The 1-arity form checks against the google.gmail.push.token
context variable and fails (halts) when it is unset, so an
unconfigured endpoint never accepts traffic. Answer 2xx quickly:
Pub/Sub retries anything else.
gmail-push
meta {webhook: {service: "gmail", path: "/push"}}
fn (request) {
note ::google::gmail::sync/verify-push(request)
cond {
is-err(note) => { ::hot::http/HttpResponse({status: 401, body: "bad token"}) }
=> {
send("gmail:changed", {email: note.email-address, history-id: note.history-id})
::hot::http/HttpResponse({status: 204})
}
}
}
google.gmail.push.token
verify-push-token
fn (request: Map, expected: Str?): Bool
True when the request's token query parameter equals expected (constant-time compare). Fails closed: an empty expected or a missing parameter is false. expected is masked in call records.
watch
fn (topic-name: Str): Map
fn (topic-name: Str, opts: Map): Map
POST /users/me/watch - publish this mailbox's changes to a Pub/Sub
topic (projects/<project>/topics/<topic>; see the namespace doc for
setup). Returns {historyId, expiration} (epoch ms, 7 days out):
store historyId as the sync start and renew before expiration.
Options: label-ids, label-filter-behavior. Scope:
gmail.readonly.
renew-gmail-watch
meta {schedule: "0 3 * * *"}
fn (event) {
::google::gmail::sync/watch("projects/my-project/topics/gmail", {label-ids: ["INBOX"], label-filter-behavior: "include"})
}
Types
HistorySet
HistorySet type {
history: Vec,
history-id: Str,
added-message-ids: Vec,
deleted-message-ids: Vec,
label-changed-message-ids: Vec,
pages: Int
}
Everything that changed since a history id.
history- the history records, in orderhistory-id- the mailbox's current history id: store it for the next syncadded-message-ids- messages added (new mail, sent mail, drafts), de-duplicated, excluding ones deleted later in the same setdeleted-message-ids- messages deleted permanently (trashing is alabelAddedofTRASH)label-changed-message-ids- messages whose labels changedpages- how many pages were read
PushNotification
PushNotification type {
email-address: Str?,
history-id: Str?,
message-id: Str?,
publish-time: Str?,
subscription: Str?,
attributes: Map
}
A Gmail change notification delivered by a Pub/Sub push subscription.
email-address- the mailbox that changedhistory-id- its history id after the change (a Str); sync from your stored id, not from this onemessage-id,publish-time,subscription,attributes- Pub/Sub envelope fields