update
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user