408 lines
13 KiB
Go
408 lines
13 KiB
Go
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
|
||
|
|
}
|