feat(mail): add Microsoft 365 Graph sync source with delta state
Adds a GET-only Microsoft Graph source to bin/mail/sync (client-credentials, delta query). Wires it into the CLI (--source m365), the worker pool and the compose stack: - m365.go: M365Client (token cache, delta pagination, message normalization, attachment download) + m365Source adapter (per-mailbox folders + delta link) - sync.go: M365Credentials in SyncConfig; Committer interface so delta links only advance after a fully successful run (no skips on failure) - cli.go: --source m365 with M365_/MS_ env passthrough - Dockerfile: mail-build stage produces /mail-sync into the index image - docker-entrypoint: mail-sync loop (sync -> import -> index, default 10s) - compose.yaml: mail-sync service (index image, kb-var volume, secrets ro)
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// M365Credentials holds a Microsoft Graph app registration with the Mail.Read
|
||||
// application permission. Client credentials are read from env/.env, never
|
||||
// committed.
|
||||
type M365Credentials struct {
|
||||
Tenant string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Users []string // mailbox addresses to sync, e.g. info@example.com
|
||||
}
|
||||
|
||||
// m365Token is the cached access token with expiry.
|
||||
type m365Token struct {
|
||||
AccessToken string
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
// M365Client talks to the Microsoft Graph API using the client-credentials
|
||||
// flow (app registration with Mail.Read application permission). GET-only:
|
||||
// messages are never marked as read or deleted.
|
||||
type M365Client struct {
|
||||
creds M365Credentials
|
||||
base string // graph base URL; default https://graph.microsoft.com
|
||||
tokenEndpoint string // login endpoint; default https://login.microsoftonline.com
|
||||
client *http.Client
|
||||
mu chan struct{}
|
||||
token *m365Token
|
||||
}
|
||||
|
||||
func NewM365Client(creds M365Credentials) (*M365Client, error) {
|
||||
if creds.Tenant == "" || creds.ClientID == "" || creds.ClientSecret == "" {
|
||||
return nil, errors.New("m365 needs tenant, client id and client secret")
|
||||
}
|
||||
c := &M365Client{
|
||||
creds: creds,
|
||||
base: "https://graph.microsoft.com",
|
||||
client: &http.Client{Timeout: 90 * time.Second},
|
||||
mu: make(chan struct{}, 1),
|
||||
}
|
||||
c.mu <- struct{}{}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// accessToken returns a fresh bearer token, refreshing via the Azure AD token
|
||||
// endpoint when the cached one is missing or about to expire (within 2 min).
|
||||
func (c *M365Client) accessToken(ctx context.Context) (string, error) {
|
||||
select {
|
||||
case <-c.mu:
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
defer func() { c.mu <- struct{}{} }()
|
||||
if c.token != nil && c.token.AccessToken != "" && time.Now().Before(c.token.Expiry.Add(-2*time.Minute)) {
|
||||
return c.token.AccessToken, nil
|
||||
}
|
||||
return c.refreshLocked(ctx)
|
||||
}
|
||||
|
||||
func (c *M365Client) refreshLocked(ctx context.Context) (string, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", c.creds.ClientID)
|
||||
form.Set("client_secret", c.creds.ClientSecret)
|
||||
form.Set("scope", "https://graph.microsoft.com/.default")
|
||||
|
||||
endpoint := c.tokenEndpoint
|
||||
if endpoint == "" {
|
||||
endpoint = fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", c.creds.Tenant)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("m365 token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
Desc string `json:"error_description"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &e)
|
||||
return "", fmt.Errorf("m365 token status %d: %s (%s)", resp.StatusCode, e.Error, truncate(e.Desc, 200))
|
||||
}
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", fmt.Errorf("m365 token parse: %w", err)
|
||||
}
|
||||
c.token = &m365Token{
|
||||
AccessToken: out.AccessToken,
|
||||
Expiry: time.Now().Add(time.Duration(out.ExpiresIn) * time.Second),
|
||||
}
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
|
||||
// deltaPage is one response page of the Graph delta query.
|
||||
type deltaPage struct {
|
||||
Value []struct {
|
||||
ID string `json:"id"`
|
||||
RemovedReason string `json:"@odata.removedReason"`
|
||||
} `json:"value"`
|
||||
NextLink string `json:"@odata.nextLink"`
|
||||
DeltaLink string `json:"@odata.deltaLink"`
|
||||
}
|
||||
|
||||
// ListDeltaIDs walks the inbox delta query and returns live message ids since
|
||||
// the previous deltaLink (or the full inbox when deltaLink is empty). Returns
|
||||
// the new deltaLink for the next run. GET-only; nothing is mutated server-side.
|
||||
func (c *M365Client) ListDeltaIDs(ctx context.Context, mailbox, deltaLink string, limit int) ([]string, string, error) {
|
||||
var (
|
||||
ids []string
|
||||
url string
|
||||
link = deltaLink
|
||||
)
|
||||
if link == "" {
|
||||
url = fmt.Sprintf("/v1.0/users/%s/mailFolders/inbox/messages/delta", pathEscape(mailbox))
|
||||
} else {
|
||||
url = link
|
||||
}
|
||||
for url != "" {
|
||||
var page deltaPage
|
||||
if err := c.getJSON(ctx, url, &page); err != nil {
|
||||
return ids, link, err
|
||||
}
|
||||
for _, m := range page.Value {
|
||||
if m.RemovedReason != "" {
|
||||
continue
|
||||
}
|
||||
if m.ID == "" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, m.ID)
|
||||
if limit > 0 && len(ids) >= limit {
|
||||
if page.DeltaLink != "" {
|
||||
link = page.DeltaLink
|
||||
}
|
||||
return ids, link, nil
|
||||
}
|
||||
}
|
||||
if page.DeltaLink != "" {
|
||||
link = page.DeltaLink
|
||||
url = ""
|
||||
break
|
||||
}
|
||||
url = page.NextLink
|
||||
}
|
||||
return ids, link, nil
|
||||
}
|
||||
|
||||
// GetMessage fetches a single message by id and normalizes it to the Message
|
||||
// contract. GET-only.
|
||||
func (c *M365Client) GetMessage(ctx context.Context, mailbox, id string) (*Message, error) {
|
||||
path := fmt.Sprintf("/v1.0/users/%s/messages/%s?$expand=attachments($select=id,name,contentType,size,isInline)",
|
||||
pathEscape(mailbox), pathEscape(id))
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
From m365Recipient `json:"from"`
|
||||
ToRecipients []m365Recipient `json:"toRecipients"`
|
||||
CCRecipients []m365Recipient `json:"ccRecipients"`
|
||||
BCCRecipients []m365Recipient `json:"bccRecipients"`
|
||||
ReceivedDateTime string `json:"receivedDateTime"`
|
||||
Body m365Body `json:"body"`
|
||||
BodyPreview string `json:"bodyPreview"`
|
||||
InternetMessageID string `json:"internetMessageId"`
|
||||
Attachments []m365Attachment `json:"attachments"`
|
||||
}
|
||||
if err := c.getJSON(ctx, path, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &Message{
|
||||
Source: "m365",
|
||||
ID: raw.ID,
|
||||
Folder: "m365",
|
||||
Subject: raw.Subject,
|
||||
From: formatRecipient(raw.From),
|
||||
To: formatRecipients(raw.ToRecipients),
|
||||
CC: formatRecipients(raw.CCRecipients),
|
||||
BCC: formatRecipients(raw.BCCRecipients),
|
||||
MimeMessageID: raw.InternetMessageID,
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw.ReceivedDateTime); err == nil {
|
||||
m.ReceivedAt = t
|
||||
}
|
||||
switch strings.ToLower(raw.Body.ContentType) {
|
||||
case "html":
|
||||
m.HTMLBody = raw.Body.Content
|
||||
if raw.BodyPreview != "" {
|
||||
m.TextBody = raw.BodyPreview
|
||||
}
|
||||
default:
|
||||
m.TextBody = raw.Body.Content
|
||||
if raw.BodyPreview != "" {
|
||||
m.HTMLBody = raw.BodyPreview
|
||||
}
|
||||
}
|
||||
for _, a := range raw.Attachments {
|
||||
if a.IsInline || a.ID == "" || a.Name == "" {
|
||||
continue
|
||||
}
|
||||
m.Attachments = append(m.Attachments, Attachment{
|
||||
FileID: a.ID,
|
||||
FileName: a.Name,
|
||||
StoredName: a.Name,
|
||||
Size: a.Size,
|
||||
ContentType: a.ContentType,
|
||||
})
|
||||
}
|
||||
m.HasAttachments = len(m.Attachments) > 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type m365Recipient struct {
|
||||
EmailAddress struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
} `json:"emailAddress"`
|
||||
}
|
||||
|
||||
type m365Body struct {
|
||||
ContentType string `json:"contentType"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type m365Attachment struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ContentType string `json:"contentType"`
|
||||
Size int64 `json:"size"`
|
||||
IsInline bool `json:"isInline"`
|
||||
ContentID string `json:"contentId"`
|
||||
}
|
||||
|
||||
func formatRecipients(rs []m365Recipient) string {
|
||||
var parts []string
|
||||
for _, r := range rs {
|
||||
if s := formatRecipient(r); s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func formatRecipient(r m365Recipient) string { a := r.EmailAddress.Address
|
||||
n := r.EmailAddress.Name
|
||||
switch {
|
||||
case n == "" || n == a:
|
||||
return a
|
||||
case a == "":
|
||||
return n
|
||||
default:
|
||||
return fmt.Sprintf("%s <%s>", n, a)
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadAttachment fetches an attachment's raw bytes via the /$value stream.
|
||||
func (c *M365Client) DownloadAttachment(ctx context.Context, mailbox, msgID, attID string) ([]byte, error) {
|
||||
path := fmt.Sprintf("/v1.0/users/%s/messages/%s/attachments/%s/$value",
|
||||
pathEscape(mailbox), pathEscape(msgID), pathEscape(attID))
|
||||
tok, err := c.accessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("m365 attachment: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("m365 attachment %s: status %d: %s", attID, resp.StatusCode, truncate(string(body), 300))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *M365Client) getJSON(ctx context.Context, path string, out any) error {
|
||||
tok, err := c.accessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := path
|
||||
if !strings.HasPrefix(u, "http") {
|
||||
u = c.base + u
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("m365 %s: %w", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("m365 %s: status %d: %s", path, resp.StatusCode, truncate(string(body), 300))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// m365Source adapts a mailbox to the Source worker-pool contract. Each mailbox
|
||||
// gets its own folder under var/mail/m365/<localpart>/ and a delta state file.
|
||||
type m365Source struct {
|
||||
c *M365Client
|
||||
mailbox string
|
||||
localpart string
|
||||
stateDir string
|
||||
pending string // delta link to persist on Commit()
|
||||
hasPending bool
|
||||
}
|
||||
|
||||
func (s *m365Source) Folder() string { return filepath.Join("m365", s.localpart) }
|
||||
|
||||
func (s *m365Source) ListIDs(ctx context.Context, limit int, cursor string) ([]string, string, error) {
|
||||
link, _ := os.ReadFile(filepath.Join(s.stateDir, s.localpart+".deltalink"))
|
||||
ids, newLink, err := s.c.ListDeltaIDs(ctx, s.mailbox, strings.TrimSpace(string(link)), limit)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// Buffer the new delta link; persist it only in Commit() after the full
|
||||
// batch downloaded, so a failed run stays retryable without gaps.
|
||||
if newLink != "" {
|
||||
s.pending = newLink
|
||||
s.hasPending = true
|
||||
}
|
||||
return ids, "", nil
|
||||
}
|
||||
|
||||
// Commit persists the buffered delta link. Called by the sync runner only when
|
||||
// every listed message downloaded successfully.
|
||||
func (s *m365Source) Commit() error {
|
||||
if !s.hasPending || s.pending == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(s.stateDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(s.stateDir, s.localpart+".deltalink"), []byte(s.pending), 0o644)
|
||||
}
|
||||
|
||||
func (s *m365Source) Get(ctx context.Context, id string) (*Message, error) {
|
||||
return s.c.GetMessage(ctx, s.mailbox, id)
|
||||
}
|
||||
|
||||
func (s *m365Source) DownloadAttachment(ctx context.Context, msg *Message, att Attachment) ([]byte, error) {
|
||||
return s.c.DownloadAttachment(ctx, s.mailbox, msg.ID, att.FileID)
|
||||
}
|
||||
|
||||
func pathEscape(s string) string {
|
||||
return url.PathEscape(s)
|
||||
}
|
||||
Reference in New Issue
Block a user