This commit is contained in:
2026-08-10 21:15:19 +01:00
parent d7ca591b76
commit 4da942786e
97 changed files with 105039 additions and 3370 deletions
+407
View File
@@ -0,0 +1,407 @@
package accounts
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/oauth2"
)
// graphAPIBase is the real Microsoft Graph v1.0 base URL —
// GraphAPIProvider.BaseURL defaults to this in NewGraphAPIProvider, and
// tests override the field directly to point at a fake server, same
// pattern as GmailAPIProvider.BaseURL.
const graphAPIBase = "https://graph.microsoft.com/v1.0/me"
// GraphAPIProvider implements MailProvider against the real Microsoft
// Graph API, replacing the earlier IMAP+XOAUTH2 transport for
// db.ProviderM365 accounts (see internal/accounts/link.go's ProviderFor).
// Graph has real mail folders (unlike Gmail's labels), so this maps onto
// MailProvider far more directly — folder IDs and message IDs are exactly
// what they already look like elsewhere in this package.
type GraphAPIProvider struct {
BaseURL string // overridable for tests; defaults to graphAPIBase
account *db.LinkedAccount
mk *crypto.MasterKey
database *db.DB
oauthConfig *oauth2.Config
}
func NewGraphAPIProvider(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfig *oauth2.Config) *GraphAPIProvider {
return &GraphAPIProvider{BaseURL: graphAPIBase, account: account, mk: mk, database: database, oauthConfig: oauthConfig}
}
func (p *GraphAPIProvider) accessToken(ctx context.Context) (string, error) {
cred, err := refreshedCredential(ctx, p.database, p.mk, p.account, p.oauthConfig)
if err != nil {
return "", err
}
return cred.AccessToken, nil
}
// request performs an authenticated Graph API call and returns the raw
// response body — callers JSON-decode it themselves (or, for GetMessage's
// $value endpoint, use the raw MIME bytes directly).
func (p *GraphAPIProvider) request(ctx context.Context, method, path string, body any) ([]byte, error) {
token, err := p.accessToken(ctx)
if err != nil {
return nil, err
}
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
reqBody = bytes.NewReader(b)
}
req, err := oauth2.NewAuthedRequest(ctx, method, p.BaseURL+path, token, reqBody)
if err != nil {
return nil, err
}
if reqBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("graph api request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading graph api response: %w", err)
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("graph api %s %s: status %d: %s", method, path, resp.StatusCode, truncateBody(respBody, 200))
}
return respBody, nil
}
func (p *GraphAPIProvider) do(ctx context.Context, method, path string, body, out any) error {
respBody, err := p.request(ctx, method, path, body)
if err != nil {
return err
}
if out != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("parsing graph api response: %w", err)
}
}
return nil
}
// graphFolderType maps Graph's wellKnownName values (or, failing that, the
// display name) to this package's Folder.Type vocabulary.
func graphFolderType(wellKnownName, displayName string) string {
switch wellKnownName {
case "inbox":
return "inbox"
case "sentitems":
return "sent"
case "drafts":
return "drafts"
case "deleteditems":
return "trash"
case "junkemail":
return "junk"
}
switch strings.ToLower(displayName) {
case "inbox":
return "inbox"
case "sent items":
return "sent"
case "drafts":
return "drafts"
case "deleted items":
return "trash"
case "junk email":
return "junk"
default:
return "custom"
}
}
type graphFolder struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
WellKnownName string `json:"wellKnownName"`
TotalItemCount int `json:"totalItemCount"`
UnreadItemCount int `json:"unreadItemCount"`
}
func (p *GraphAPIProvider) ListFolders(ctx context.Context) ([]Folder, error) {
var resp struct {
Value []graphFolder `json:"value"`
}
if err := p.do(ctx, http.MethodGet, "/mailFolders?$top=100", nil, &resp); err != nil {
return nil, err
}
folders := make([]Folder, 0, len(resp.Value))
for _, f := range resp.Value {
folders = append(folders, Folder{
ID: f.ID, DisplayName: f.DisplayName, Type: graphFolderType(f.WellKnownName, f.DisplayName),
UnreadCount: f.UnreadItemCount, TotalCount: f.TotalItemCount,
})
}
return folders, nil
}
type graphRecipient struct {
EmailAddress struct {
Address string `json:"address"`
Name string `json:"name"`
} `json:"emailAddress"`
}
type graphMessage struct {
ID string `json:"id"`
Subject string `json:"subject"`
From graphRecipient `json:"from"`
ToRecipients []graphRecipient `json:"toRecipients"`
ReceivedDateTime string `json:"receivedDateTime"`
IsRead bool `json:"isRead"`
Flag struct {
FlagStatus string `json:"flagStatus"`
} `json:"flag"`
}
// graphMessageToHeader translates Graph's isRead/flag fields into this
// package's IMAP-style flag-string convention (\Seen present means read —
// matching gmailLabelsToFlags' convention exactly, so the webmail API sees
// the same shape regardless of which provider a message came from).
func graphMessageToHeader(m graphMessage, folderID string) MessageHeader {
to := make([]string, 0, len(m.ToRecipients))
for _, r := range m.ToRecipients {
to = append(to, r.EmailAddress.Address)
}
h := MessageHeader{
ID: m.ID, FolderID: folderID,
From: m.From.EmailAddress.Address, To: strings.Join(to, ", "),
Subject: m.Subject, Date: m.ReceivedDateTime,
}
if m.IsRead {
h.Flags = append(h.Flags, "\\Seen")
}
if m.Flag.FlagStatus == "flagged" {
h.Flags = append(h.Flags, "\\Flagged")
}
return h
}
func (p *GraphAPIProvider) ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
limit := opts.Limit
if limit <= 0 {
limit = 50
}
var resp struct {
Value []graphMessage `json:"value"`
}
path := fmt.Sprintf("/mailFolders/%s/messages?$top=%d&$select=subject,from,toRecipients,receivedDateTime,isRead,flag", folderID, limit)
if err := p.do(ctx, http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
// Graph's list call returns headers directly — no per-message follow-up
// call needed, unlike Gmail's list-then-metadata shape.
headers := make([]MessageHeader, 0, len(resp.Value))
for _, m := range resp.Value {
headers = append(headers, graphMessageToHeader(m, folderID))
}
return headers, nil
}
func (p *GraphAPIProvider) GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error) {
// $value returns the raw MIME message body directly (message/rfc822),
// not JSON — request() gives us the bytes as-is.
raw, err := p.request(ctx, http.MethodGet, "/messages/"+messageID+"/$value", nil)
if err != nil {
return nil, err
}
var meta graphMessage
if err := p.do(ctx, http.MethodGet, "/messages/"+messageID+"?$select=subject,from,toRecipients,receivedDateTime,isRead,flag", nil, &meta); err != nil {
return nil, err
}
h := graphMessageToHeader(meta, folderID)
h.SizeBytes = int64(len(raw))
return &FullMessage{MessageHeader: h, Raw: raw}, nil
}
func (p *GraphAPIProvider) SendMessage(ctx context.Context, msg *OutgoingMessage) error {
toRecipients := make([]map[string]any, 0, len(msg.To))
for _, addr := range msg.To {
toRecipients = append(toRecipients, map[string]any{"emailAddress": map[string]string{"address": addr}})
}
ccRecipients := make([]map[string]any, 0, len(msg.CC))
for _, addr := range msg.CC {
ccRecipients = append(ccRecipients, map[string]any{"emailAddress": map[string]string{"address": addr}})
}
body := map[string]any{
"message": map[string]any{
"subject": msg.Subject,
"body": map[string]string{"contentType": "Text", "content": msg.Body},
"toRecipients": toRecipients,
"ccRecipients": ccRecipients,
},
}
_, err := p.request(ctx, http.MethodPost, "/sendMail", body)
return err
}
func (p *GraphAPIProvider) SetFlags(ctx context.Context, folderID, messageID string, flags []string) error {
want := map[string]bool{}
for _, f := range flags {
want[f] = true
}
flagStatus := "notFlagged"
if want["\\Flagged"] {
flagStatus = "flagged"
}
body := map[string]any{
"isRead": want["\\Seen"],
"flag": map[string]string{"flagStatus": flagStatus},
}
_, err := p.request(ctx, http.MethodPatch, "/messages/"+messageID, body)
return err
}
func (p *GraphAPIProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
// Graph's move creates a new message resource in the destination
// folder (a new ID) — this interface's Move only reports success/
// failure, so the new ID (present in the response) is intentionally
// discarded rather than threaded back through a signature that has no
// way to return it to callers.
_, err := p.request(ctx, http.MethodPost, "/messages/"+messageID+"/move", map[string]string{"destinationId": destFolderID})
return err
}
// Delete removes the message — Graph's default DELETE lands it in Deleted
// Items rather than purging it, matching the same soft-delete convention
// as GmailAPIProvider.Delete's trash.
func (p *GraphAPIProvider) Delete(ctx context.Context, folderID, messageID string) error {
_, err := p.request(ctx, http.MethodDelete, "/messages/"+messageID, nil)
return err
}
// Sync does a full re-list, the same depth as IMAPProvider.Sync and
// GmailAPIProvider.Sync (no CONDSTORE/QRESYNC or history diffing there
// either). Real Graph delta-query support (mailFolders/{id}/messages/delta)
// is deferred: nothing consumes SyncResult.NewCursor yet (no sync worker
// exists), and a delta query's cursor is scoped per-folder, not global the
// way Gmail's historyId is — building it now would be untested, unused
// code with no clear place to store a per-folder cursor in the current
// schema (LinkedAccount.SyncState is a single string).
func (p *GraphAPIProvider) Sync(ctx context.Context, _ string) (*SyncResult, error) {
folders, err := p.ListFolders(ctx)
if err != nil {
return nil, err
}
var all []MessageHeader
for _, f := range folders {
msgs, err := p.ListMessages(ctx, f.ID, ListOpts{})
if err != nil {
continue
}
all = append(all, msgs...)
}
return &SyncResult{NewMessages: all}, nil
}
// parseGraphDateTime parses Graph's dateTimeTimeZone shape — a naive
// timestamp (variable-precision fractional seconds, no offset) plus a
// separate IANA/Windows timeZone name. Falls back to UTC if the zone name
// can't be resolved rather than failing the whole request over a display
// timezone.
func parseGraphDateTime(dateTime, timeZone string) time.Time {
loc, err := time.LoadLocation(timeZone)
if err != nil {
loc = time.UTC
}
t, err := time.ParseInLocation("2006-01-02T15:04:05.9999999", dateTime, loc)
if err != nil {
return time.Time{}
}
return t
}
type graphDateTimeTZ struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// ListEvents implements CalendarProvider against Graph's calendarView,
// which — unlike a plain /events listing — expands recurring events into
// individual occurrences within the window, matching what a calendar UI
// actually wants to render.
func (p *GraphAPIProvider) ListEvents(ctx context.Context, from, to time.Time) ([]CalendarEvent, error) {
path := fmt.Sprintf("/calendarView?startDateTime=%s&endDateTime=%s",
url.QueryEscape(from.Format(time.RFC3339)), url.QueryEscape(to.Format(time.RFC3339)))
var resp struct {
Value []struct {
ID string `json:"id"`
Subject string `json:"subject"`
BodyPreview string `json:"bodyPreview"`
Location struct {
DisplayName string `json:"displayName"`
} `json:"location"`
Start graphDateTimeTZ `json:"start"`
End graphDateTimeTZ `json:"end"`
IsAllDay bool `json:"isAllDay"`
} `json:"value"`
}
if err := p.do(ctx, http.MethodGet, path, nil, &resp); err != nil {
return nil, err
}
events := make([]CalendarEvent, 0, len(resp.Value))
for _, it := range resp.Value {
events = append(events, CalendarEvent{
ID: it.ID, Summary: it.Subject, Location: it.Location.DisplayName, Description: it.BodyPreview,
Start: parseGraphDateTime(it.Start.DateTime, it.Start.TimeZone),
End: parseGraphDateTime(it.End.DateTime, it.End.TimeZone),
AllDay: it.IsAllDay,
})
}
return events, nil
}
// ListContacts implements ContactProvider against Graph's /me/contacts.
func (p *GraphAPIProvider) ListContacts(ctx context.Context) ([]Contact, error) {
var resp struct {
Value []struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
EmailAddresses []struct {
Address string `json:"address"`
} `json:"emailAddresses"`
BusinessPhones []string `json:"businessPhones"`
MobilePhone string `json:"mobilePhone"`
} `json:"value"`
}
if err := p.do(ctx, http.MethodGet, "/contacts", nil, &resp); err != nil {
return nil, err
}
contacts := make([]Contact, 0, len(resp.Value))
for _, c := range resp.Value {
contact := Contact{ID: c.ID, Name: c.DisplayName, Phones: append([]string{}, c.BusinessPhones...)}
if c.MobilePhone != "" {
contact.Phones = append(contact.Phones, c.MobilePhone)
}
for _, e := range c.EmailAddresses {
contact.Emails = append(contact.Emails, e.Address)
}
contacts = append(contacts, contact)
}
return contacts, nil
}