mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-12 08:02:31 +00:00
397 lines
12 KiB
Go
397 lines
12 KiB
Go
package wmail
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/warmbly/warmbly/internal/client/goog"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// googleBackfillPage is how many ids one messages.list call returns. Small
|
|
// enough that a page interrupted by pacing costs little to re-list.
|
|
const googleBackfillPage = 100
|
|
|
|
// SyncGoogle walks Gmail's history from this mailbox's checkpoint and advances
|
|
// it, then moves the backfill along under its budget. Runs serially from
|
|
// StartSyncWorker, so LastHistoryID needs no locking.
|
|
func (w *WMail) SyncGoogle(ctx context.Context) *errx.MailError {
|
|
w.beginTick()
|
|
stats := &tickStats{}
|
|
w.googleTick = stats
|
|
w.googleFolders = nil
|
|
if !w.retryUnmap(ctx) {
|
|
return nil
|
|
}
|
|
|
|
newHistoryID, err := w.GoogleData.Client.FetchHistory(ctx, w.GoogleData.LastHistoryID)
|
|
if newHistoryID != 0 && newHistoryID != w.GoogleData.LastHistoryID {
|
|
// Advance the in-memory cursor before persisting. The next tick reads
|
|
// this field, so leaving it stale re-walks the window just processed,
|
|
// and leaving it at zero re-bootstraps past everything that arrived.
|
|
w.GoogleData.LastHistoryID = newHistoryID
|
|
if perr := w.NewHistoryID(newHistoryID); perr != nil {
|
|
w.CaptureError(perr)
|
|
}
|
|
}
|
|
if err != nil {
|
|
var errMail *errx.MailError
|
|
if errors.As(err, &errMail) {
|
|
return errMail
|
|
}
|
|
w.CaptureError(err)
|
|
return nil
|
|
}
|
|
|
|
if !stats.aborted {
|
|
if merr := w.googleBackfill(ctx, stats); merr != nil {
|
|
return merr
|
|
}
|
|
}
|
|
if !stats.aborted {
|
|
// The pass's own work is done; only a refusal the owner has to act
|
|
// on is worth failing it for.
|
|
if merr := w.googleReconcileFolders(ctx, time.Now(), stats); merr != nil && merr.Type == errx.MailErrorCritical {
|
|
return merr
|
|
}
|
|
}
|
|
w.endTick(stats)
|
|
return nil
|
|
}
|
|
|
|
// onGoogleMessageAdded is the history feed's offer of one added message. It
|
|
// dedupes by Gmail id (the only identifier remove and label events carry),
|
|
// classifies, and hydrates only what is admitted. false leaves the message
|
|
// on the server and pins the checkpoint before it.
|
|
func (w *WMail) onGoogleMessageAdded(ctx context.Context, id, threadID string) (bool, error) {
|
|
stats := w.googleTick
|
|
if stats == nil {
|
|
stats = &tickStats{}
|
|
}
|
|
if stats.aborted {
|
|
return false, goog.ErrStop
|
|
}
|
|
known, err := w.EmailMessageMapRepository.Get(ctx, w.UserID, w.ID, id)
|
|
if err != nil {
|
|
w.controlPlaneError(err, stats)
|
|
return false, goog.ErrStop
|
|
}
|
|
if known != nil {
|
|
return true, nil
|
|
}
|
|
if w.observeLive(ctx, []string{id}, stats) {
|
|
return false, goog.ErrStop
|
|
}
|
|
|
|
lane, cached := w.laneCache.get(id)
|
|
var msg *models.EmailMessageData
|
|
if !cached {
|
|
// Classification needs the headers, so a first sight is hydrated in
|
|
// full; the lane is remembered so a deferred message is not fetched
|
|
// again on every pass while it waits.
|
|
msg, err = w.GoogleData.Client.GetMessage(ctx, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if msg == nil {
|
|
return true, nil // gone between the history event and now
|
|
}
|
|
if msg.ThreadID == "" {
|
|
msg.ThreadID = threadID
|
|
}
|
|
lane = w.laneOf(ctx, id, msg, false)
|
|
}
|
|
if !w.admit(ctx, lane, stats) {
|
|
return false, nil
|
|
}
|
|
if msg == nil {
|
|
msg, err = w.GoogleData.Client.GetMessage(ctx, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if msg == nil {
|
|
return true, nil
|
|
}
|
|
}
|
|
w.laneCache.forget(id)
|
|
if err := w.googleStore(ctx, msg); err != nil {
|
|
w.controlPlaneError(err, stats)
|
|
return false, goog.ErrStop
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (w *WMail) googleStore(ctx context.Context, msg *models.EmailMessageData) error {
|
|
msg.ID = newMessageID()
|
|
now := time.Now()
|
|
data := &models.EmailMessageStoreData{
|
|
ID: msg.ID,
|
|
EmailID: w.ID,
|
|
Mailbox: 0,
|
|
Folder: msg.Folder,
|
|
ThreadID: msg.ThreadID,
|
|
MessageID: msg.MessageID,
|
|
GmailID: msg.GmailID,
|
|
ParentID: msg.ParentID,
|
|
UID: msg.UID,
|
|
ModSeq: msg.ModSeq,
|
|
Flags: msg.Flags,
|
|
BCC: msg.BCC,
|
|
CC: msg.CC,
|
|
FromAddr: msg.From,
|
|
InReplyTo: msg.InReplyTo,
|
|
ReplyTo: msg.ReplyTo,
|
|
ToAddr: msg.To,
|
|
Subject: msg.Subject,
|
|
Size: msg.Size,
|
|
InternalDate: msg.InternalDate,
|
|
SentDate: msg.Date,
|
|
Snippet: msg.Snippet,
|
|
BodyText: SearchText(msg.BodyPlain, msg.BodyHTML),
|
|
Seen: models.SeenFromFlags(msg.Flags),
|
|
UpdatedAt: now,
|
|
CreatedAt: now,
|
|
}
|
|
// The map is keyed by the Gmail message id: it is the only identifier the
|
|
// history feed reports on remove/label events, so add must key the same
|
|
// way for those lookups to ever match.
|
|
return w.storeNew(ctx, msg, data, msg.GmailID)
|
|
}
|
|
|
|
// googleBackfill imports the mailbox's recent history newest first, one
|
|
// messages.list page at a time, resuming from the saved page token. The query
|
|
// excludes what the IMAP path also skips: trash and spam (their history would
|
|
// eat the message budget that belongs to real conversations; live sync still
|
|
// files new mail into those scopes) plus chats. Drafts are imported, matching
|
|
// IMAP, so the Drafts scope is not empty of everything written before connect.
|
|
func (w *WMail) googleBackfill(ctx context.Context, stats *tickStats) *errx.MailError {
|
|
st := &w.tracker.state
|
|
if st.BackfillStatus == models.SyncBackfillComplete {
|
|
return nil
|
|
}
|
|
policy := w.gov.Policy()
|
|
w.tracker.startBackfill(time.Now(), policy.BackfillDays)
|
|
q := fmt.Sprintf("after:%d -in:trash -in:spam -in:chats", st.BackfillSince.Unix())
|
|
|
|
for !stats.aborted && !stats.laneDenied(LaneBackfill) {
|
|
if st.BackfillSynced >= policy.BackfillMessages {
|
|
w.tracker.completeBackfill(time.Now())
|
|
return nil
|
|
}
|
|
ids, next, err := w.GoogleData.Client.ListMessages(ctx, q, st.BackfillCursor.PageToken, googleBackfillPage)
|
|
if err != nil {
|
|
var errMail *errx.MailError
|
|
if errors.As(err, &errMail) {
|
|
return errMail
|
|
}
|
|
w.CaptureError(err)
|
|
return nil
|
|
}
|
|
for _, id := range ids {
|
|
if st.BackfillSynced >= policy.BackfillMessages {
|
|
w.tracker.completeBackfill(time.Now())
|
|
return nil
|
|
}
|
|
known, err := w.EmailMessageMapRepository.Get(ctx, w.UserID, w.ID, id)
|
|
if err != nil {
|
|
return w.controlPlaneError(err, stats)
|
|
}
|
|
if known != nil {
|
|
continue
|
|
}
|
|
if !w.admit(ctx, LaneBackfill, stats) {
|
|
// Pacing: the page token is not advanced, and the ids already
|
|
// stored are skipped as known when the page is re-listed.
|
|
return nil
|
|
}
|
|
msg, err := w.GoogleData.Client.GetMessage(ctx, id)
|
|
if err != nil {
|
|
var errMail *errx.MailError
|
|
if errors.As(err, &errMail) {
|
|
return errMail
|
|
}
|
|
w.CaptureError(err)
|
|
return nil
|
|
}
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
if err := w.googleStore(ctx, msg); err != nil {
|
|
return w.controlPlaneError(err, stats)
|
|
}
|
|
st.BackfillSynced++
|
|
w.tracker.mark()
|
|
}
|
|
st.BackfillCursor.PageToken = next
|
|
w.tracker.mark()
|
|
if next == "" {
|
|
w.tracker.completeBackfill(time.Now())
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconcileElsewhere is every folder a stored Gmail message can be moved back
|
|
// to the inbox from.
|
|
var reconcileElsewhere = []string{models.FolderArchive, models.FolderSpam, models.FolderTrash}
|
|
|
|
// googleReconcileFolders repairs stored mail the history feed did not move:
|
|
// changes from before the sync followed labels, and any a checkpoint Gmail
|
|
// expired skipped over. Inbox rows missing from Gmail's inbox are looked up;
|
|
// every other row only moves when Gmail lists it in the inbox. A pass that
|
|
// fails is tried again after GmailFolderReconcileRetry.
|
|
func (w *WMail) googleReconcileFolders(ctx context.Context, now time.Time, stats *tickStats) *errx.MailError {
|
|
if w.SyncContext == nil || now.Sub(w.googleReconciledAt) < config.GmailFolderReconcileInterval {
|
|
return nil
|
|
}
|
|
w.googleReconciledAt = now.Add(config.GmailFolderReconcileRetry - config.GmailFolderReconcileInterval)
|
|
|
|
inboxRows, err := w.SyncContext.ListProviderFolderMessages(ctx, w.UserID, w.ID, []string{models.FolderInbox}, config.GmailFolderReconcileMessages)
|
|
if err != nil {
|
|
return w.controlPlaneError(err, stats)
|
|
}
|
|
otherRows, err := w.SyncContext.ListProviderFolderMessages(ctx, w.UserID, w.ID, reconcileElsewhere, config.GmailFolderReconcileMessages)
|
|
if err != nil {
|
|
return w.controlPlaneError(err, stats)
|
|
}
|
|
if len(inboxRows) == 0 && len(otherRows) == 0 {
|
|
w.googleReconciledAt = now
|
|
return nil
|
|
}
|
|
|
|
// Each set comes newest first, and the listing has to reach the oldest row
|
|
// of either; a day of slack covers Gmail reading after: against its own
|
|
// calendar.
|
|
var oldest time.Time
|
|
for _, rows := range [][]repository.ProviderFolderMessage{inboxRows, otherRows} {
|
|
if len(rows) > 0 && (oldest.IsZero() || rows[len(rows)-1].InternalDate.Before(oldest)) {
|
|
oldest = rows[len(rows)-1].InternalDate
|
|
}
|
|
}
|
|
q := ""
|
|
if after := oldest.Add(-24 * time.Hour); after.Unix() > 0 {
|
|
q = fmt.Sprintf("after:%d", after.Unix())
|
|
}
|
|
inInbox := make(map[string]struct{})
|
|
token := ""
|
|
for page := 0; page < config.GmailFolderReconcilePages; page++ {
|
|
ids, next, err := w.GoogleData.Client.ListLabelMessages(ctx, goog.Inbox, q, token, 500)
|
|
if err != nil {
|
|
return w.googleReconcileError(err)
|
|
}
|
|
for _, id := range ids {
|
|
inInbox[id] = struct{}{}
|
|
}
|
|
if next == "" {
|
|
break
|
|
}
|
|
token = next
|
|
}
|
|
|
|
for _, m := range otherRows {
|
|
if _, ok := inInbox[m.ProviderID]; !ok {
|
|
continue
|
|
}
|
|
if err := w.emitFolder(m.ID, models.FolderInbox); err != nil {
|
|
return w.controlPlaneError(err, stats)
|
|
}
|
|
}
|
|
|
|
if w.googleInboxChecked == nil {
|
|
w.googleInboxChecked = make(map[string]time.Time)
|
|
}
|
|
for id, at := range w.googleInboxChecked {
|
|
if now.Sub(at) >= config.GmailFolderReconcileRecheck {
|
|
delete(w.googleInboxChecked, id)
|
|
}
|
|
}
|
|
lookups := 0
|
|
for _, m := range inboxRows {
|
|
if _, ok := inInbox[m.ProviderID]; ok {
|
|
continue
|
|
}
|
|
if _, checked := w.googleInboxChecked[m.ProviderID]; checked {
|
|
continue
|
|
}
|
|
if lookups >= config.GmailFolderReconcileLookups {
|
|
break
|
|
}
|
|
lookups++
|
|
labels, found, err := w.GoogleData.Client.MessageLabels(ctx, m.ProviderID)
|
|
if err != nil {
|
|
if gmailRetryable(err) {
|
|
return w.googleReconcileError(err)
|
|
}
|
|
// One message Gmail refuses must not hold up every row after it.
|
|
log.Debug().Err(err).Str("email_id", w.ID.String()).Str("gmail_id", m.ProviderID).Msg("gmail folder reconciliation: lookup refused")
|
|
w.googleInboxChecked[m.ProviderID] = now
|
|
continue
|
|
}
|
|
if !found {
|
|
// Gone from Gmail, which is what the live feed's delete reports.
|
|
if err := w.onEvent(models.JobEventTypeRemoveEmail, &models.JobEventRemoveEmail{
|
|
UserID: w.UserID,
|
|
EmailID: w.ID,
|
|
ID: m.ID,
|
|
}); err != nil {
|
|
return w.controlPlaneError(err, stats)
|
|
}
|
|
w.googleInboxChecked[m.ProviderID] = now
|
|
continue
|
|
}
|
|
folder := goog.Folder(labels)
|
|
if folder == models.FolderInbox {
|
|
w.googleInboxChecked[m.ProviderID] = now
|
|
continue
|
|
}
|
|
if err := w.emitFolder(m.ID, folder); err != nil {
|
|
return w.controlPlaneError(err, stats)
|
|
}
|
|
}
|
|
w.googleReconciledAt = now
|
|
return nil
|
|
}
|
|
|
|
func (w *WMail) emitFolder(id uuid.UUID, folder string) error {
|
|
return w.onEvent(models.JobEventTypeFolderUpdate, &models.JobEventFolderUpdate{
|
|
UserID: w.UserID,
|
|
EmailID: w.ID,
|
|
ID: id,
|
|
Folder: folder,
|
|
})
|
|
}
|
|
|
|
// googleReconcileError returns a Gmail failure to the caller, which fails the
|
|
// tick only when it is critical; the rest are captured unless transient.
|
|
func (w *WMail) googleReconcileError(err error) *errx.MailError {
|
|
var errMail *errx.MailError
|
|
if !errors.As(err, &errMail) {
|
|
w.CaptureError(err)
|
|
return nil
|
|
}
|
|
if errMail.Type != errx.MailErrorCritical && !gmailRetryable(err) {
|
|
w.CaptureError(err)
|
|
}
|
|
return errMail
|
|
}
|
|
|
|
// NewHistoryID persists the mailbox's Gmail history checkpoint. UserID and
|
|
// EmailID address the row: email_history_ids is keyed (user_id, email_id) with
|
|
// a foreign key to users, so omitting them sends a zero UUID and the write is
|
|
// rejected outright rather than landing on the wrong row.
|
|
func (w *WMail) NewHistoryID(historyID uint64) error {
|
|
return w.onEvent(models.JobEventTypeHistoryIDUpdate, &models.JobEventHistoryIDUpdate{
|
|
UserID: w.UserID,
|
|
EmailID: w.ID,
|
|
HistoryID: historyID,
|
|
})
|
|
}
|