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 }