mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 08:01:16 +00:00
126 lines
4.1 KiB
Go
126 lines
4.1 KiB
Go
// 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
|
|
}
|