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 }