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
+116 -7
View File
@@ -1,8 +1,12 @@
package accounts
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"gomail/internal/crypto"
"gomail/internal/db"
@@ -66,6 +70,71 @@ func wellKnownIMAPHost(provider db.LinkedAccountProvider) (imapHost string, imap
}
}
// FetchOAuth2Email looks up the real email address for a just-authorized
// account via the provider's userinfo/profile endpoint — replacing the
// earlier ?email= query-param stand-in (see webmail's oauthCallback). This
// is the account's real identity, so a failure here is returned as an
// error rather than silently falling back to a placeholder.
func FetchOAuth2Email(ctx context.Context, provider, accessToken string) (string, error) {
var endpoint string
switch provider {
case "google":
endpoint = "https://openidconnect.googleapis.com/v1/userinfo"
case "microsoft":
endpoint = "https://graph.microsoft.com/v1.0/me"
default:
return "", fmt.Errorf("unknown provider %q", provider)
}
req, err := oauth2.NewAuthedRequest(ctx, http.MethodGet, endpoint, accessToken, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("userinfo request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading userinfo response: %w", err)
}
if resp.StatusCode >= 300 {
return "", fmt.Errorf("userinfo endpoint returned status %d", resp.StatusCode)
}
if provider == "google" {
var r struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", fmt.Errorf("parsing userinfo response: %w", err)
}
if r.Email == "" {
return "", fmt.Errorf("userinfo response had no email")
}
return r.Email, nil
}
// microsoft
var r struct {
Mail string `json:"mail"`
UserPrincipalName string `json:"userPrincipalName"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", fmt.Errorf("parsing /me response: %w", err)
}
if r.Mail != "" {
return r.Mail, nil
}
if r.UserPrincipalName != "" {
// Some Graph account types return a null `mail` field —
// userPrincipalName is a documented, reliable fallback.
return r.UserPrincipalName, nil
}
return "", fmt.Errorf("/me response had no mail or userPrincipalName")
}
// LinkOAuth2Account registers a Gmail or M365 account, storing the OAuth2
// tokens (from a completed authorization code exchange) encrypted the same
// way as everything else. Mail access goes through IMAP+OAuth2 (XOAUTH2),
@@ -115,6 +184,49 @@ func LinkOAuth2Account(database *db.DB, mk *crypto.MasterKey, userID, displayNam
return account, nil
}
// refreshedCredential decrypts account's stored OAuth2 credential and, if
// expired, transparently refreshes it via cfg and persists the new token —
// shared by every OAuth2-authenticated provider (IMAPProvider,
// GmailAPIProvider, GraphAPIProvider) so "is this expired, and if so
// refresh and persist" lives in exactly one place instead of once per
// provider. database may be nil (refresh then happens in-memory only, for
// the rare caller that doesn't have persistence available); cfg may be nil
// only if the credential is known not to be expired yet.
func refreshedCredential(ctx context.Context, database *db.DB, mk *crypto.MasterKey, account *db.LinkedAccount, cfg *oauth2.Config) (*OAuth2Credential, error) {
plain, err := crypto.Decrypt(mk, account.ID, "linked-account-cred", account.CredentialEnc)
if err != nil {
return nil, fmt.Errorf("decrypting stored OAuth2 credential: %w", err)
}
var cred OAuth2Credential
if err := json.Unmarshal(plain, &cred); err != nil {
return nil, fmt.Errorf("parsing stored OAuth2 credential: %w", err)
}
if !time.Now().UTC().After(cred.ExpiresAt) {
return &cred, nil
}
if cfg == nil {
return nil, fmt.Errorf("access token expired and no oauth2.Config available to refresh it")
}
newTok, err := cfg.RefreshToken(ctx, cred.RefreshToken)
if err != nil {
return nil, fmt.Errorf("refreshing OAuth2 token: %w", err)
}
cred.AccessToken = newTok.AccessToken
cred.RefreshToken = newTok.RefreshToken
cred.ExpiresAt = newTok.ExpiresAt
if database != nil {
if updated, err := json.Marshal(cred); err == nil {
if encUpdated, encErr := crypto.Encrypt(mk, account.ID, "linked-account-cred", updated); encErr == nil {
database.Exec(`UPDATE linked_accounts SET credential_enc = ?, oauth_expires_at = ? WHERE id = ?`,
encUpdated, cred.ExpiresAt, account.ID)
}
}
}
return &cred, nil
}
// ProviderFor returns the right MailProvider implementation for a linked
// account row — the single place that decides which backend handles which
// provider string, so callers (webmail API, sync workers) never need a
@@ -129,13 +241,10 @@ func ProviderFor(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.D
switch account.Provider {
case db.ProviderIMAP:
return NewIMAPProvider(account, mk), nil
case db.ProviderGmail, db.ProviderM365:
providerKey := "google"
if account.Provider == db.ProviderM365 {
providerKey = "microsoft"
}
cfg := oauthConfigs[providerKey]
return NewIMAPProviderOAuth2(account, mk, database, cfg), nil
case db.ProviderGmail:
return NewGmailAPIProvider(account, mk, database, oauthConfigs["google"]), nil
case db.ProviderM365:
return NewGraphAPIProvider(account, mk, database, oauthConfigs["microsoft"]), nil
default:
return nil, fmt.Errorf("provider %q not supported", account.Provider)
}
+42 -1
View File
@@ -5,7 +5,10 @@
// actually lives.
package accounts
import "context"
import (
"context"
"time"
)
type Folder struct {
ID string `json:"id"` // provider-native folder identifier (IMAP mailbox name, etc.)
@@ -65,3 +68,41 @@ type MailProvider interface {
Delete(ctx context.Context, folderID, messageID string) error
Sync(ctx context.Context, since string) (*SyncResult, error)
}
// CalendarEvent and Contact are read-only views onto a linked account's
// own calendar/contacts, native-API only (Gmail Calendar API + Google
// People API, Microsoft Graph) — there is no IMAP-equivalent fallback for
// these, unlike mail. Local calendars/contacts are unaffected: they keep
// being served by internal/dav's CalDAV/CardDAV server, entirely separate
// from this.
type CalendarEvent struct {
ID string `json:"id"`
Summary string `json:"summary"`
Location string `json:"location"`
Description string `json:"description"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
AllDay bool `json:"all_day"`
}
type Contact struct {
ID string `json:"id"`
Name string `json:"name"`
Emails []string `json:"emails"`
Phones []string `json:"phones"`
}
// CalendarProvider and ContactProvider are deliberately separate from
// MailProvider — GoMailProvider (local calendar/contacts already exist
// via CalDAV/CardDAV) and IMAPProvider (generic IMAP has no calendar/
// contacts concept at all) don't implement either. Callers type-assert
// (`p, ok := provider.(CalendarProvider)`) and report "not supported"
// rather than a fake empty result — see internal/webmail/api.go's
// calendarEvents/contacts handlers.
type CalendarProvider interface {
ListEvents(ctx context.Context, from, to time.Time) ([]CalendarEvent, error)
}
type ContactProvider interface {
ListContacts(ctx context.Context) ([]Contact, error)
}
+421
View File
@@ -0,0 +1,421 @@
package accounts
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/oauth2"
)
// gmailAPIBase is the real Gmail API v1 base URL — GmailAPIProvider.BaseURL
// defaults to this in NewGmailAPIProvider, and tests override the field
// directly to point at a fake server, the same way oauth2.Config's own
// AuthURL/TokenURL fields are overridden in this project's existing tests.
const gmailAPIBase = "https://gmail.googleapis.com/gmail/v1/users/me"
const gmailCalendarAPIBase = "https://www.googleapis.com/calendar/v3"
const gmailPeopleAPIBase = "https://people.googleapis.com/v1"
// GmailAPIProvider implements MailProvider against the real Gmail API,
// replacing the earlier IMAP+XOAUTH2 transport for db.ProviderGmail
// accounts (see internal/accounts/link.go's ProviderFor). Gmail is
// label-based, not folder-based — every method below translates between
// this package's folder/flag vocabulary and Gmail's labels internally, so
// callers (webmail API, unified inbox) never need to know the difference.
type GmailAPIProvider struct {
BaseURL string // overridable for tests; defaults to gmailAPIBase
CalendarBaseURL string // overridable for tests; defaults to gmailCalendarAPIBase
PeopleBaseURL string // overridable for tests; defaults to gmailPeopleAPIBase
account *db.LinkedAccount
mk *crypto.MasterKey
database *db.DB
oauthConfig *oauth2.Config
}
func NewGmailAPIProvider(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfig *oauth2.Config) *GmailAPIProvider {
return &GmailAPIProvider{
BaseURL: gmailAPIBase, CalendarBaseURL: gmailCalendarAPIBase, PeopleBaseURL: gmailPeopleAPIBase,
account: account, mk: mk, database: database, oauthConfig: oauthConfig,
}
}
// accessToken returns a valid (transparently refreshed if needed) access
// token via the shared refreshedCredential helper used by every
// OAuth2-authenticated provider.
func (p *GmailAPIProvider) 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
}
// do performs an authenticated Gmail API request and decodes a successful
// JSON response into out (pass nil to discard the body, e.g. for
// trash/modify calls whose response this provider doesn't need).
func (p *GmailAPIProvider) do(ctx context.Context, method, path string, body, out any) error {
return p.doURL(ctx, method, p.BaseURL+path, body, out)
}
// doURL is like do but takes a full URL — used for Calendar API v3 /
// People API v1, which live on different hosts than the Gmail API base
// (p.BaseURL), unlike everything else this provider calls.
func (p *GmailAPIProvider) doURL(ctx context.Context, method, url string, body, out any) error {
token, err := p.accessToken(ctx)
if err != nil {
return err
}
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request body: %w", err)
}
reqBody = bytes.NewReader(b)
}
req, err := oauth2.NewAuthedRequest(ctx, method, url, token, reqBody)
if err != nil {
return err
}
if reqBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("gmail api request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading gmail api response: %w", err)
}
if resp.StatusCode >= 300 {
return fmt.Errorf("gmail api %s %s: status %d: %s", method, url, resp.StatusCode, truncateBody(respBody, 200))
}
if out != nil {
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("parsing gmail api response: %w", err)
}
}
return nil
}
func truncateBody(b []byte, n int) string {
if len(b) > n {
return string(b[:n]) + "..."
}
return string(b)
}
// gmailLabelType maps Gmail's system label IDs to this package's Folder.Type
// vocabulary — different strings than IMAP's Drafts/Junk, so this doesn't
// reuse provider_gomail.go's folderType.
func gmailLabelType(labelID string) string {
switch labelID {
case "INBOX":
return "inbox"
case "SENT":
return "sent"
case "DRAFT":
return "drafts"
case "TRASH":
return "trash"
case "SPAM":
return "junk"
default:
return "custom"
}
}
type gmailLabel struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
MessagesTotal int `json:"messagesTotal"`
MessagesUnread int `json:"messagesUnread"`
}
func (p *GmailAPIProvider) ListFolders(ctx context.Context) ([]Folder, error) {
var resp struct {
Labels []gmailLabel `json:"labels"`
}
if err := p.do(ctx, http.MethodGet, "/labels", nil, &resp); err != nil {
return nil, err
}
folders := make([]Folder, 0, len(resp.Labels))
for _, l := range resp.Labels {
// Per-label counts need a separate labels.get call (labels.list
// doesn't include them) — only fetch it for the handful of system
// labels a folder list actually shows as top-level, to avoid an
// API call per label when most installs only care about INBOX.
total, unread := l.MessagesTotal, l.MessagesUnread
if l.Type == "system" {
var detail gmailLabel
if err := p.do(ctx, http.MethodGet, "/labels/"+l.ID, nil, &detail); err == nil {
total, unread = detail.MessagesTotal, detail.MessagesUnread
}
}
folders = append(folders, Folder{
ID: l.ID, DisplayName: l.Name, Type: gmailLabelType(l.ID),
UnreadCount: unread, TotalCount: total,
})
}
return folders, nil
}
func (p *GmailAPIProvider) ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
limit := opts.Limit
if limit <= 0 {
limit = 50
}
var listResp struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}
path := fmt.Sprintf("/messages?labelIds=%s&maxResults=%d", folderID, limit)
if err := p.do(ctx, http.MethodGet, path, nil, &listResp); err != nil {
return nil, err
}
// Gmail's list endpoint returns IDs only — headers need a metadata call
// per message, the same "N per listing" shape the local mailbox's own
// header cache (internal/mailstore) was added to avoid, but there's no
// equivalent server-side cache to lean on for a remote account.
headers := make([]MessageHeader, 0, len(listResp.Messages))
for _, m := range listResp.Messages {
var msg struct {
LabelIDs []string `json:"labelIds"`
SizeEstimate int64 `json:"sizeEstimate"`
Payload struct {
Headers []struct {
Name, Value string
} `json:"headers"`
} `json:"payload"`
}
metaPath := "/messages/" + m.ID + "?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Subject&metadataHeaders=Date"
if err := p.do(ctx, http.MethodGet, metaPath, nil, &msg); err != nil {
continue
}
h := MessageHeader{ID: m.ID, FolderID: folderID, SizeBytes: msg.SizeEstimate}
for _, hdr := range msg.Payload.Headers {
switch hdr.Name {
case "From":
h.From = hdr.Value
case "To":
h.To = hdr.Value
case "Subject":
h.Subject = hdr.Value
case "Date":
h.Date = hdr.Value
}
}
h.Flags = gmailLabelsToFlags(msg.LabelIDs)
headers = append(headers, h)
}
return headers, nil
}
// gmailLabelsToFlags/flagsToGmailLabels translate between IMAP-style flag
// strings and the two Gmail labels with a direct equivalent. Flags with no
// Gmail equivalent (\Answered, \Draft) are a named gap, not silently wrong.
func gmailLabelsToFlags(labelIDs []string) []string {
var flags []string
seen := map[string]bool{}
for _, id := range labelIDs {
seen[id] = true
}
if !seen["UNREAD"] {
flags = append(flags, "\\Seen")
}
if seen["STARRED"] {
flags = append(flags, "\\Flagged")
}
return flags
}
func (p *GmailAPIProvider) GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error) {
var resp struct {
LabelIDs []string `json:"labelIds"`
Raw string `json:"raw"`
SizeEstimate int64 `json:"sizeEstimate"`
}
if err := p.do(ctx, http.MethodGet, "/messages/"+messageID+"?format=raw", nil, &resp); err != nil {
return nil, err
}
raw, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(resp.Raw)
if err != nil {
// Gmail sometimes includes padding despite the API doc saying
// unpadded — fall back to standard raw-URL decoding.
raw, err = base64.RawURLEncoding.DecodeString(resp.Raw)
if err != nil {
return nil, fmt.Errorf("decoding raw message: %w", err)
}
}
full := headerFromRaw(messageID, folderID, raw, "", resp.SizeEstimate)
full.Flags = gmailLabelsToFlags(resp.LabelIDs)
return &FullMessage{MessageHeader: full, Raw: raw}, nil
}
func (p *GmailAPIProvider) SendMessage(ctx context.Context, msg *OutgoingMessage) error {
raw := buildRFC5322(msg.From, msg)
encoded := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(raw)
return p.do(ctx, http.MethodPost, "/messages/send", map[string]string{"raw": encoded}, nil)
}
func (p *GmailAPIProvider) SetFlags(ctx context.Context, folderID, messageID string, flags []string) error {
want := map[string]bool{}
for _, f := range flags {
want[f] = true
}
var add, remove []string
if want["\\Seen"] {
remove = append(remove, "UNREAD")
} else {
add = append(add, "UNREAD")
}
if want["\\Flagged"] {
add = append(add, "STARRED")
} else {
remove = append(remove, "STARRED")
}
body := map[string]any{"addLabelIds": add, "removeLabelIds": remove}
return p.do(ctx, http.MethodPost, "/messages/"+messageID+"/modify", body, nil)
}
func (p *GmailAPIProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
body := map[string]any{"addLabelIds": []string{destFolderID}, "removeLabelIds": []string{folderID}}
return p.do(ctx, http.MethodPost, "/messages/"+messageID+"/modify", body, nil)
}
// Delete trashes the message (Gmail's TRASH label) rather than permanently
// deleting it — matches IMAP \Deleted softness and typical mail-client
// expectations; permanent delete is out of scope for this provider.
func (p *GmailAPIProvider) Delete(ctx context.Context, folderID, messageID string) error {
return p.do(ctx, http.MethodPost, "/messages/"+messageID+"/trash", nil, nil)
}
// Sync captures a fresh historyId cursor and does a full re-list — the same
// depth as IMAPProvider.Sync (no CONDSTORE/QRESYNC there either). Real
// incremental diffing via users.history.list is deferred: nothing consumes
// SyncResult yet (no sync worker exists), so building it now would be
// untested, unused code.
func (p *GmailAPIProvider) Sync(ctx context.Context, _ string) (*SyncResult, error) {
var profile struct {
HistoryID string `json:"historyId"`
}
if err := p.do(ctx, http.MethodGet, "/profile", nil, &profile); err != nil {
return nil, err
}
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{NewCursor: profile.HistoryID, NewMessages: all}, nil
}
// gmailDateTime is Calendar API v3's event start/end shape: either a full
// dateTime (timed event) or a bare date (all-day event) — never both.
type gmailDateTime struct {
DateTime string `json:"dateTime"`
Date string `json:"date"`
}
func (d gmailDateTime) parse() (t time.Time, allDay bool) {
if d.Date != "" {
t, _ = time.Parse("2006-01-02", d.Date)
return t, true
}
t, _ = time.Parse(time.RFC3339, d.DateTime)
return t, false
}
// ListEvents implements CalendarProvider against Calendar API v3. Calendar
// API is a different host than the Gmail API base, so this uses doURL
// rather than do.
func (p *GmailAPIProvider) ListEvents(ctx context.Context, from, to time.Time) ([]CalendarEvent, error) {
eventsURL := fmt.Sprintf("%s/calendars/primary/events?timeMin=%s&timeMax=%s&singleEvents=true&orderBy=startTime",
p.CalendarBaseURL, url.QueryEscape(from.Format(time.RFC3339)), url.QueryEscape(to.Format(time.RFC3339)))
var resp struct {
Items []struct {
ID string `json:"id"`
Summary string `json:"summary"`
Location string `json:"location"`
Description string `json:"description"`
Start gmailDateTime `json:"start"`
End gmailDateTime `json:"end"`
} `json:"items"`
}
if err := p.doURL(ctx, http.MethodGet, eventsURL, nil, &resp); err != nil {
return nil, err
}
events := make([]CalendarEvent, 0, len(resp.Items))
for _, it := range resp.Items {
start, allDay := it.Start.parse()
end, _ := it.End.parse()
events = append(events, CalendarEvent{
ID: it.ID, Summary: it.Summary, Location: it.Location, Description: it.Description,
Start: start, End: end, AllDay: allDay,
})
}
return events, nil
}
// ListContacts implements ContactProvider against People API v1.
func (p *GmailAPIProvider) ListContacts(ctx context.Context) ([]Contact, error) {
contactsURL := p.PeopleBaseURL + "/people/me/connections?personFields=names,emailAddresses,phoneNumbers"
var resp struct {
Connections []struct {
ResourceName string `json:"resourceName"`
Names []struct {
DisplayName string `json:"displayName"`
} `json:"names"`
EmailAddresses []struct {
Value string `json:"value"`
} `json:"emailAddresses"`
PhoneNumbers []struct {
Value string `json:"value"`
} `json:"phoneNumbers"`
} `json:"connections"`
}
if err := p.doURL(ctx, http.MethodGet, contactsURL, nil, &resp); err != nil {
return nil, err
}
contacts := make([]Contact, 0, len(resp.Connections))
for _, c := range resp.Connections {
contact := Contact{ID: c.ResourceName}
if len(c.Names) > 0 {
contact.Name = c.Names[0].DisplayName
}
for _, e := range c.EmailAddresses {
contact.Emails = append(contact.Emails, e.Value)
}
for _, ph := range c.PhoneNumbers {
contact.Phones = append(contact.Phones, ph.Value)
}
contacts = append(contacts, contact)
}
return contacts, nil
}
+15
View File
@@ -80,6 +80,21 @@ func (p *GoMailProvider) ListMessages(_ context.Context, folderID string, opts L
var headers []MessageHeader
for _, e := range page {
// Fast path: decrypt the small cached header blob instead of the
// full message body. Falls through to a full read for messages
// delivered before the header cache existed (header_enc is NULL)
// or if the cached blob fails to decrypt/parse for any reason.
if len(e.HeaderEnc) > 0 {
if hdr, err := p.store.DecryptHeaderCache(e.ID, e.HeaderEnc); err == nil {
h := MessageHeader{ID: strconv.Itoa(e.UID), FolderID: folderID, SizeBytes: e.SizeBytes,
From: hdr.From, To: hdr.To, Subject: hdr.Subject, Date: hdr.Date}
if e.Flags != "" {
h.Flags = strings.Fields(e.Flags)
}
headers = append(headers, h)
continue
}
}
raw, err := p.store.Read(e.EMLPath)
if err != nil {
continue
+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
}
+6 -65
View File
@@ -13,7 +13,6 @@ import (
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/imapclient"
"gomail/internal/oauth2"
)
const dialTimeout = 20 * time.Second
@@ -39,27 +38,19 @@ type OAuth2Credential struct {
// connections" story without IDLE/pooling machinery this pass doesn't build
// yet, so simplicity wins: connect, do the one operation, disconnect. A
// later pass can add persistent connections if per-operation latency matters.
// IMAPProvider is password-authenticated only — db.ProviderIMAP accounts
// are always created via LinkIMAPAccount with AuthTypePassword.
// Gmail/M365 accounts (previously OAuth2-over-IMAP here) now use
// GmailAPIProvider/GraphAPIProvider instead — see link.go's ProviderFor.
type IMAPProvider struct {
account *db.LinkedAccount
mk *crypto.MasterKey
database *db.DB // needed to persist a refreshed access token
oauthConfig *oauth2.Config // nil for password-auth accounts
account *db.LinkedAccount
mk *crypto.MasterKey
}
func NewIMAPProvider(account *db.LinkedAccount, mk *crypto.MasterKey) *IMAPProvider {
return &IMAPProvider{account: account, mk: mk}
}
// NewIMAPProviderOAuth2 is used for accounts.AuthTypeOAuth2 — the caller
// supplies the provider's oauth2.Config (built from operator-configured
// Client ID/Secret) so a stored access token can be refreshed transparently
// on expiry. database is used to persist the refreshed token — refreshing
// silently in memory only would force a re-refresh on every single
// operation instead of once per real expiry.
func NewIMAPProviderOAuth2(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfig *oauth2.Config) *IMAPProvider {
return &IMAPProvider{account: account, mk: mk, database: database, oauthConfig: oauthConfig}
}
func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error) {
addr := fmt.Sprintf("%s:%d", p.account.IMAPHost, p.account.IMAPPort)
// InsecureSkipVerify is a known gap, not a silent one: real ACME
@@ -85,13 +76,6 @@ func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error)
}
}
if p.account.AuthType == db.AuthTypeOAuth2 {
if err := p.loginOAuth2(ctx, client); err != nil {
return nil, err
}
return client, nil
}
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
if err != nil {
return nil, fmt.Errorf("decrypting stored credential: %w", err)
@@ -106,49 +90,6 @@ func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error)
return client, nil
}
// loginOAuth2 decrypts the stored OAuth2 credential, transparently refreshes
// it if expired (persisting the new token so the next call doesn't have to
// refresh again), and authenticates via SASL XOAUTH2.
func (p *IMAPProvider) loginOAuth2(ctx context.Context, client *imapclient.Client) error {
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
if err != nil {
return fmt.Errorf("decrypting stored OAuth2 credential: %w", err)
}
var cred OAuth2Credential
if err := json.Unmarshal(plain, &cred); err != nil {
return fmt.Errorf("parsing stored OAuth2 credential: %w", err)
}
if time.Now().UTC().After(cred.ExpiresAt) {
if p.oauthConfig == nil {
return fmt.Errorf("access token expired and no oauth2.Config available to refresh it")
}
newTok, err := p.oauthConfig.RefreshToken(ctx, cred.RefreshToken)
if err != nil {
return fmt.Errorf("refreshing OAuth2 token: %w", err)
}
cred.AccessToken = newTok.AccessToken
cred.RefreshToken = newTok.RefreshToken
cred.ExpiresAt = newTok.ExpiresAt
if p.database != nil {
updated, err := json.Marshal(cred)
if err == nil {
if encUpdated, encErr := crypto.Encrypt(p.mk, p.account.ID, "linked-account-cred", updated); encErr == nil {
p.database.Exec(`UPDATE linked_accounts SET credential_enc = ?, oauth_expires_at = ? WHERE id = ?`,
encUpdated, cred.ExpiresAt, p.account.ID)
}
}
}
}
sasl := oauth2.XOAUTH2SASLString(p.account.EmailAddress, cred.AccessToken)
if err := client.LoginXOAUTH2(sasl); err != nil {
return fmt.Errorf("XOAUTH2 login: %w", err)
}
return nil
}
func (p *IMAPProvider) ListFolders(ctx context.Context) ([]Folder, error) {
client, err := p.connect(ctx)
if err != nil {