mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-21 00:01:28 +00:00
229 lines
7.3 KiB
Go
229 lines
7.3 KiB
Go
package msgraph
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
func itoa(n int) string { return strconv.Itoa(n) }
|
|
|
|
// ErrStop is returned by a callback to end the walk now; the cursor stays
|
|
// where it was last persisted.
|
|
var ErrStop = errors.New("msgraph: stop")
|
|
|
|
// deltaSelect keeps delta pages light: we only need the id, read state, and the
|
|
// @removed marker to decide add/update vs remove. Full envelope + body + headers
|
|
// are hydrated per admitted message via FetchMessage.
|
|
const deltaSelect = "id,isRead"
|
|
|
|
// msgSelect is the property set fetched for each new message: enough to build a
|
|
// complete EmailMessageData (envelope, threading, body) plus internetMessageHeaders
|
|
// so the warmup verification token survives into sync.
|
|
const msgSelect = "id,internetMessageId,conversationId,subject,bodyPreview,isRead," +
|
|
"receivedDateTime,from,sender,toRecipients,ccRecipients,bccRecipients,replyTo,body,internetMessageHeaders"
|
|
|
|
// deltaPagesPerPass bounds one folder's walk per tick, like Gmail's
|
|
// historyPagesPerPass: a mailbox far behind, or held by fair use so its
|
|
// cursor cannot advance, is caught up over several ticks.
|
|
const deltaPagesPerPass = 20
|
|
|
|
// deltaPage is one page of a messages/delta response.
|
|
type deltaPage struct {
|
|
Value []GraphMessage `json:"value"`
|
|
NextLink string `json:"@odata.nextLink"`
|
|
DeltaLink string `json:"@odata.deltaLink"`
|
|
}
|
|
|
|
// listPage is one page of a folder listing (the backfill).
|
|
type listPage struct {
|
|
Value []GraphMessage `json:"value"`
|
|
NextLink string `json:"@odata.nextLink"`
|
|
}
|
|
|
|
// TrackedFolders are the well-known folders live sync follows: inbox and junk
|
|
// for placement, sent so a conversation shows both sides (as the Gmail and
|
|
// IMAP paths already do).
|
|
var TrackedFolders = []string{FolderInbox, FolderJunk, FolderSent}
|
|
|
|
// BackfillFolders are the folders the initial import walks. Junk is followed
|
|
// live for placement signals but its history is not worth importing.
|
|
var BackfillFolders = []string{FolderInbox, FolderSent, FolderArchive}
|
|
|
|
// Sync walks the delta stream for the tracked folders and drives the
|
|
// OnMessage* callbacks. It is the Graph equivalent of goog.FetchHistory and
|
|
// fits the disposable worker natively: no webhook endpoint, no subscription
|
|
// lifecycle, just a cursor the control plane persists via OnDelta.
|
|
func (c *Client) Sync(ctx context.Context) error {
|
|
for _, folder := range TrackedFolders {
|
|
if err := c.syncFolder(ctx, folder); err != nil {
|
|
if errors.Is(err, ErrStop) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) syncFolder(ctx context.Context, folder string) error {
|
|
next := c.DeltaLinks[folder]
|
|
|
|
// First run for this folder: there is no cursor yet. We page to the end
|
|
// purely to capture a deltaLink representing "now" and do NOT import the
|
|
// mailbox's existing history; the backfill does that under its budget.
|
|
// The walk is uncapped and nothing is persisted before the deltaLink: a
|
|
// saved intermediate nextLink would make the next pass treat the rest of
|
|
// the priming walk as live mail.
|
|
if next == "" {
|
|
next = graphBase + "/me/mailFolders/" + folder + "/messages/delta?$select=" + url.QueryEscape(deltaSelect)
|
|
for {
|
|
var pg deltaPage
|
|
if err := c.doJSON(ctx, "GET", next, nil, &pg); err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case pg.NextLink != "":
|
|
next = pg.NextLink
|
|
case pg.DeltaLink != "":
|
|
return c.saveCursor(ctx, folder, pg.DeltaLink)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// The persisted cursor only moves past a page once everything on it was
|
|
// stored. A page with a deferred message (fair use) pins the cursor there
|
|
// while the walk continues, so admitted messages further on still land
|
|
// and the deferred ones are re-offered next tick from the pinned link.
|
|
pinned := false
|
|
for page := 0; page < deltaPagesPerPass; page++ {
|
|
var pg deltaPage
|
|
if err := c.doJSON(ctx, "GET", next, nil, &pg); err != nil {
|
|
return err
|
|
}
|
|
|
|
complete := true
|
|
for i := range pg.Value {
|
|
stored, err := c.applyDelta(ctx, folder, &pg.Value[i])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !stored {
|
|
complete = false
|
|
}
|
|
}
|
|
if !complete {
|
|
pinned = true
|
|
}
|
|
|
|
switch {
|
|
case pg.NextLink != "":
|
|
if !pinned {
|
|
if err := c.saveCursor(ctx, folder, pg.NextLink); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
next = pg.NextLink
|
|
case pg.DeltaLink != "":
|
|
if !pinned {
|
|
return c.saveCursor(ctx, folder, pg.DeltaLink)
|
|
}
|
|
return nil
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) saveCursor(ctx context.Context, folder, link string) error {
|
|
c.DeltaLinks[folder] = link
|
|
if c.OnDelta != nil {
|
|
return c.OnDelta(ctx, folder, link)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// applyDelta turns one delta item into the right callback. Removed items
|
|
// (delete or move-out) fire OnMessageRemove; live items are offered through
|
|
// OnMessageSeen, which dedupes, hydrates and stores as budget allows and
|
|
// reports false when the message was left on the server.
|
|
func (c *Client) applyDelta(ctx context.Context, folder string, item *GraphMessage) (bool, error) {
|
|
if item.Removed != nil {
|
|
if c.OnMessageRemove != nil {
|
|
return true, c.OnMessageRemove(ctx, item.ID)
|
|
}
|
|
return true, nil
|
|
}
|
|
if c.OnMessageSeen == nil {
|
|
return true, nil
|
|
}
|
|
return c.OnMessageSeen(ctx, folder, item.ID, item.IsRead)
|
|
}
|
|
|
|
// FetchMessage hydrates a single message with the full property set. It
|
|
// returns nil, nil when Graph no longer has the message (deleted between the
|
|
// delta item and now), which is a skip for the caller, not a mailbox error.
|
|
func (c *Client) FetchMessage(ctx context.Context, folder, id string) (*GraphMessage, error) {
|
|
u := graphBase + "/me/messages/" + url.PathEscape(id) + "?$select=" + url.QueryEscape(msgSelect)
|
|
resp, err := c.do(ctx, "GET", u, "", nil)
|
|
if err != nil {
|
|
return nil, errx.ErrMailServerUnreachable
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil, nil
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, HandleError(resp)
|
|
}
|
|
var msg GraphMessage
|
|
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &msg, nil
|
|
}
|
|
|
|
// ListMessagesSince is one page of the backfill for a folder: full messages
|
|
// received on or after since, newest first, plus the link to the next page
|
|
// ("" when exhausted). next is "" for the first page.
|
|
func (c *Client) ListMessagesSince(ctx context.Context, folder string, since time.Time, next string, top int) ([]*GraphMessage, string, error) {
|
|
if next == "" {
|
|
q := url.Values{}
|
|
q.Set("$filter", "receivedDateTime ge "+since.UTC().Format("2006-01-02T15:04:05Z"))
|
|
q.Set("$orderby", "receivedDateTime desc")
|
|
q.Set("$top", itoa(top))
|
|
q.Set("$select", msgSelect)
|
|
next = graphBase + "/me/mailFolders/" + folder + "/messages?" + q.Encode()
|
|
}
|
|
var pg listPage
|
|
if err := c.doJSON(ctx, "GET", next, nil, &pg); err != nil {
|
|
return nil, "", err
|
|
}
|
|
out := make([]*GraphMessage, 0, len(pg.Value))
|
|
for i := range pg.Value {
|
|
out = append(out, &pg.Value[i])
|
|
}
|
|
return out, pg.NextLink, nil
|
|
}
|
|
|
|
// ToEmailData maps a hydrated message; folder adds the junk placement flag.
|
|
func (m *GraphMessage) ToEmailData(folder string) *models.EmailMessageData {
|
|
data := m.toEmailData()
|
|
if folder == FolderJunk {
|
|
data.Flags = append(data.Flags, "\\Junk")
|
|
}
|
|
return data
|
|
}
|