Files
verdande/internal/gmail/gmail.go
T

241 lines
7.9 KiB
Go
Raw Normal View History

// Package gmail is the small slice of the Gmail API verdande needs, and the scope
// it asks for.
//
// The one thing verdande writes back is the star. A starred message becomes a
// task, and once it has, the star comes off — exactly as the IMAP side takes the
// flag off a mail it has imported, and for the same reason: the star meant "turn
// this into a task", the task now exists, so the star is no longer true. Nothing
// else is touched. Completing the task does not reach back to the mail; the mail
// is a pointer the task was made from, not a copy the two have to be kept in step.
//
// That one write is why the scope is gmail.modify rather than gmail.readonly.
// Removing the STARRED label is a modify, and Gmail offers no narrower scope that
// can do it — so the consent screen asks for more than it used to, and the trade
// is deliberate: a starred inbox that empties itself as the tasks are made, rather
// than one that fills with stars nothing ever clears. Still not send, still not
// delete — modify is labels, and the only label verdande removes is the one that
// triggered the task.
//
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
// The names below are aliases so that every call site — and this package's own
// tests — still read as Gmail's, which is what they are: one registration, one
// flow, one scope each.
package gmail
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/kristianwind/verdande/internal/google"
)
// Read mail and change its labels, and only mail. Not profile, not contacts, not
// send, not delete. The single write is removing the STARRED label from a message
// that has become a task; see the package comment for why that costs modify.
const Scope = "https://www.googleapis.com/auth/gmail.modify"
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
// The OAuth flow, which is Google's rather than Gmail's.
type (
Config = google.Config
PKCE = google.PKCE
Token = google.Token
)
var (
NewPKCE = google.NewPKCE
ErrUnauthorized = google.ErrUnauthorized
)
// --- the API ---------------------------------------------------------------------
type Client struct {
accessToken string
// base is where the Gmail API is. A field rather than a constant so a test can
// point it at a server it controls — and "the sync comes back before whatever
// sits in front of this server gives up on it" is exactly the kind of promise
// worth a test and impossible to make against the real thing. Empty is Google.
base string
http *http.Client
}
func NewClient(accessToken string) *Client {
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
}
// At points the client at another server. Tests only; the zero value is Google.
func (c *Client) At(base string) *Client {
c.base = base
return c
}
func (c *Client) api() string {
if c.base != "" {
return c.base
}
return defaultAPIBase
}
// Message is the part of a Gmail message a task is made from.
type Message struct {
ID string
ThreadID string
From string
Subject string
Snippet string
// Link opens the message in Gmail's own interface, which is the whole point:
// the task is a pointer back to the mail, not a copy of it.
Link string
}
// Profile identifies the connected mailbox, so the settings page can show which
// account is connected rather than only that one is.
func (c *Client) Profile(ctx context.Context) (string, error) {
var parsed struct {
EmailAddress string `json:"emailAddress"`
}
if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil {
return "", err
}
return parsed.EmailAddress, nil
}
// List finds messages matching a Gmail search query.
//
// Gmail's own query language is used rather than a filter built here — "is:starred",
// "label:Til-handling" — because it is what the person already knows from the
// search box, and it is what their existing filters are written in.
func (c *Client) List(ctx context.Context, query string, max int) ([]string, error) {
if max <= 0 || max > 50 {
max = 25
}
v := url.Values{}
v.Set("q", query)
v.Set("maxResults", fmt.Sprint(max))
var parsed struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}
if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
return nil, err
}
ids := make([]string, 0, len(parsed.Messages))
for _, m := range parsed.Messages {
ids = append(ids, m.ID)
}
return ids, nil
}
// Get fetches the headers of one message. Metadata format only: verdande needs the
// sender and the subject, and asking for the full body would download attachments
// it has no use for.
func (c *Client) Get(ctx context.Context, id string) (Message, error) {
v := url.Values{}
v.Set("format", "metadata")
v.Add("metadataHeaders", "From")
v.Add("metadataHeaders", "Subject")
var parsed struct {
ID string `json:"id"`
ThreadID string `json:"threadId"`
Snippet string `json:"snippet"`
Payload struct {
Headers []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"headers"`
} `json:"payload"`
}
if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
return Message{}, err
}
msg := Message{
ID: parsed.ID, ThreadID: parsed.ThreadID, Snippet: parsed.Snippet,
Link: "https://mail.google.com/mail/u/0/#inbox/" + parsed.ThreadID,
}
for _, h := range parsed.Payload.Headers {
switch strings.ToLower(h.Name) {
case "from":
msg.From = h.Value
case "subject":
msg.Subject = h.Value
}
}
return msg, nil
}
func (c *Client) get(ctx context.Context, url string, out any) error {
return google.Get(ctx, c.http, c.accessToken, url, out)
}
// Unstar takes the star off the given messages — one call for all of them, the way
// the IMAP side unflags a batch, so a sweep that made ten tasks is one write and
// not ten.
//
// STARRED is a system label whose id is its name, so no lookup is needed; a custom
// label would need one, and verdande removes none. batchModify answers 204 with no
// body on success. An empty list is a no-op, not a call: batchModify with no ids is
// an error on Google's side and nothing worth making a round trip to hear.
func (c *Client) Unstar(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
body := struct {
IDs []string `json:"ids"`
RemoveLabelIDs []string `json:"removeLabelIds"`
}{IDs: ids, RemoveLabelIDs: []string{"STARRED"}}
return google.Post(ctx, c.http, c.accessToken, c.api()+"/users/me/messages/batchModify", body, nil)
}
// SenderName pulls "Anders Jensen" out of "Anders Jensen <anders@example.dk>",
// falling back to the address when there is no display name.
func SenderName(from string) string {
from = strings.TrimSpace(from)
if i := strings.Index(from, "<"); i > 0 {
// Space first, then quotes. A display name arrives as `"Jensen, Anders" `
// — with a trailing space before the angle bracket — so trimming quotes
// first stops at the space and leaves the closing quote behind.
name := strings.Trim(strings.TrimSpace(from[:i]), `"`)
if name != "" {
return name
}
}
return strings.Trim(strings.Trim(from, "<>"), `"`)
}
// Query builds the Gmail search for the configured trigger.
func Query(trigger, label string) string {
var terms []string
switch trigger {
case "starred":
terms = append(terms, "is:starred")
case "label":
if label != "" {
terms = append(terms, `label:"`+label+`"`)
}
case "both":
if label != "" {
// Gmail's OR needs the braces form to group correctly.
terms = append(terms, `{is:starred label:"`+label+`"}`)
} else {
terms = append(terms, "is:starred")
}
}
if len(terms) == 0 {
return ""
}
// Only recent mail. Without this, connecting an account with a decade of
// starred messages would create a decade of tasks in one sweep.
terms = append(terms, "newer_than:30d")
return strings.Join(terms, " ")
}