// Package msgraph is a thin Microsoft Graph mail client for Warmbly's // Outlook/Microsoft 365 mailboxes. It mirrors the shape of internal/client/goog // (the Gmail API client): a per-mailbox Client with OnMessage* callbacks, an // OAuth2 token source that persists refreshes, RAW MIME sending, delta-based // inbound sync, and warmup mailbox actions. It deliberately hand-rolls a small // REST surface (~a dozen endpoints) instead of pulling in the very large // official SDK, which would bloat the disposable worker binary and slow CI. package msgraph import ( "bytes" "context" "encoding/json" "io" "net/http" "sync" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/cache" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/pkg/stoken" "golang.org/x/oauth2" ) const ( // graphBase is the Microsoft Graph v1.0 root. All mail calls are made // against the signed-in user (/me) using the delegated token. graphBase = "https://graph.microsoft.com/v1.0" // Well-known mail folder ids Graph accepts directly in a path or as a // move destinationId, so we never have to resolve them to opaque ids. FolderInbox = "inbox" FolderJunk = "junkemail" ) // Client is a single Microsoft 365 mailbox reached over Graph. type Client struct { Email string FirstName string LastName string hc *http.Client Cache *cache.Cache // DeltaLinks holds the opaque per-folder delta cursor (well-known folder // name -> deltaLink URL). Seeded from persisted state on init and advanced // as sync runs; OnDelta persists each new value off the disposable worker. DeltaLinks map[string]string // folderIDs caches resolved folder ids (e.g. the created "Warmbly" folder) // so we don't re-list on every warmup action. folderIDs map[string]string mu sync.Mutex OnMessageAdd func(ctx context.Context, msg *models.EmailMessageData) error OnMessageRemove func(ctx context.Context, providerID string) error OnFlagsChange func(ctx context.Context, providerID string, seen bool) error OnDelta func(ctx context.Context, folder, deltaLink string) error OnTokenRefresh func(ctx context.Context, token *oauth2.Token) error } // Init builds the auto-refreshing OAuth2 HTTP client. It mirrors goog.Client.Init: // the token source reuses the current token until expiry, then refreshes through // cfg and persists the new token via OnTokenRefresh (the worker relays it back to // the control plane, which owns the encrypted credential store). func (c *Client) Init(ctx context.Context, token *oauth2.Token, cfg oauth2.Config) *errx.MailError { ts := cfg.TokenSource(ctx, token) ts = oauth2.ReuseTokenSource(token, ts) ts = stoken.New(ts, func(t *oauth2.Token) error { return c.OnTokenRefresh(context.Background(), t) }) c.hc = oauth2.NewClient(ctx, ts) if c.DeltaLinks == nil { c.DeltaLinks = map[string]string{} } c.folderIDs = map[string]string{} return nil } // do issues a single authenticated request. body may be nil. func (c *Client) do(ctx context.Context, method, url, contentType string, body []byte) (*http.Response, error) { var r io.Reader if body != nil { r = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, url, r) if err != nil { return nil, err } if contentType != "" { req.Header.Set("Content-Type", contentType) } return c.hc.Do(req) } // doJSON marshals in (if non-nil), issues the request, maps any non-2xx status to // a MailError, and decodes the response into out (if non-nil). func (c *Client) doJSON(ctx context.Context, method, url string, in, out any) error { var body []byte contentType := "" if in != nil { b, err := json.Marshal(in) if err != nil { return err } body = b contentType = "application/json" } resp, err := c.do(ctx, method, url, contentType, body) if err != nil { return errx.ErrMailServerUnreachable } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return HandleError(resp) } if out != nil { return json.NewDecoder(resp.Body).Decode(out) } _, _ = io.Copy(io.Discard, resp.Body) return nil }