Release / Build and publish image (push) Failing after 9m59s
En postkasse, der ikke svarer, holdt hele forespørgslen åben. Efter hundrede sekunder gav Cloudflare op og sendte sin egen 502-side tilbage — HTML uden fejlkode, så fladen kunne kun sige "Noget gik galt", og fejlloggen var tom, fordi handleren aldrig nåede at vende tilbage. Synk får nu sit eget budget på femogtyve sekunder. Løkken tjekker fristen mellem hver besked, og hver opgave gemmes efterhånden som den laves, så en overskredet frist er en kortere kørsel og ikke en tabt. Baggrundsjobbet beholder den fulde tid; det har ingen, der venter. Endepunkterne kan nu pege et andet sted hen, så testen kan stille en postkasse op, der aldrig svarer, og se hvor lang tid der går. Budgettet kan sættes, så den prøve koster et sekund frem for femogtyve. Et budget på nul betyder standarden — ikke ingen tid. Uden det ville en opsætning, der ikke satte feltet, lade fristen udløbe før første kald og gøre hver eneste synk til et stilfærdigt nul. Co-Authored-By: Claude Opus 5 <[email protected]>
386 lines
11 KiB
Go
386 lines
11 KiB
Go
// Package gmail implements the OAuth2 authorisation-code flow with PKCE against
|
|
// Google, and the small slice of the Gmail API verdande needs.
|
|
//
|
|
// The connection is one-way on purpose: a starred or labelled message becomes a
|
|
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
|
// message does not delete the task, completing the task does not unstar the
|
|
// message. Two-way would mean deciding what to do when both sides changed, and the
|
|
// only honest answer to that is a synchronisation model far bigger than the feature.
|
|
//
|
|
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
|
|
// asking for less is what makes the consent screen something a person can agree to.
|
|
package gmail
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
|
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
|
|
|
// Read-only, and only mail. Not profile, not contacts, not send.
|
|
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
|
)
|
|
|
|
// Endpoints is where Google is.
|
|
//
|
|
// A field rather than a constant so a test can point it at a server it controls.
|
|
// There is no other way to exercise what happens when Gmail is slow or refuses —
|
|
// 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.
|
|
//
|
|
// The zero value is Google, so nothing outside a test has to know it exists.
|
|
type Endpoints struct {
|
|
Token string
|
|
API string
|
|
}
|
|
|
|
func (e Endpoints) token() string {
|
|
if e.Token != "" {
|
|
return e.Token
|
|
}
|
|
return defaultTokenEndpoint
|
|
}
|
|
|
|
func (e Endpoints) api() string {
|
|
if e.API != "" {
|
|
return e.API
|
|
}
|
|
return defaultAPIBase
|
|
}
|
|
|
|
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
|
// the instance rather than to a person: one registration, any number of users.
|
|
type Config struct {
|
|
ClientID string
|
|
ClientSecret string
|
|
RedirectURL string
|
|
// Endpoints is Google unless a test says otherwise.
|
|
Endpoints Endpoints
|
|
}
|
|
|
|
func (c Config) Configured() bool {
|
|
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
|
}
|
|
|
|
// PKCE is the proof-key pair for one authorisation attempt.
|
|
//
|
|
// PKCE on a server-side client is not strictly required — the client secret already
|
|
// authenticates the token exchange — but it costs nothing and closes the window in
|
|
// which an authorisation code intercepted from the redirect could be replayed
|
|
// before verdande gets to it.
|
|
type PKCE struct {
|
|
Verifier string
|
|
Challenge string
|
|
State string
|
|
}
|
|
|
|
func NewPKCE() (PKCE, error) {
|
|
verifier, err := randomString(64)
|
|
if err != nil {
|
|
return PKCE{}, err
|
|
}
|
|
state, err := randomString(32)
|
|
if err != nil {
|
|
return PKCE{}, err
|
|
}
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
return PKCE{
|
|
Verifier: verifier,
|
|
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
|
State: state,
|
|
}, nil
|
|
}
|
|
|
|
// AuthURL is where the browser is sent to ask the person for consent.
|
|
func (c Config) AuthURL(p PKCE) string {
|
|
v := url.Values{}
|
|
v.Set("client_id", c.ClientID)
|
|
v.Set("redirect_uri", c.RedirectURL)
|
|
v.Set("response_type", "code")
|
|
v.Set("scope", Scope)
|
|
v.Set("state", p.State)
|
|
v.Set("code_challenge", p.Challenge)
|
|
v.Set("code_challenge_method", "S256")
|
|
// Without both of these Google returns no refresh token on a re-authorisation,
|
|
// and the connection silently stops working an hour later.
|
|
v.Set("access_type", "offline")
|
|
v.Set("prompt", "consent")
|
|
return authEndpoint + "?" + v.Encode()
|
|
}
|
|
|
|
type Token struct {
|
|
AccessToken string
|
|
RefreshToken string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// Exchange trades the authorisation code for tokens.
|
|
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
|
form := url.Values{}
|
|
form.Set("code", code)
|
|
form.Set("client_id", c.ClientID)
|
|
form.Set("client_secret", c.ClientSecret)
|
|
form.Set("redirect_uri", c.RedirectURL)
|
|
form.Set("grant_type", "authorization_code")
|
|
form.Set("code_verifier", p.Verifier)
|
|
|
|
return c.token(ctx, form)
|
|
}
|
|
|
|
// Refresh gets a fresh access token. Google does not return a new refresh token
|
|
// here, so the caller keeps the one it has.
|
|
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
|
form := url.Values{}
|
|
form.Set("refresh_token", refreshToken)
|
|
form.Set("client_id", c.ClientID)
|
|
form.Set("client_secret", c.ClientSecret)
|
|
form.Set("grant_type", "refresh_token")
|
|
|
|
token, err := c.token(ctx, form)
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
token.RefreshToken = refreshToken
|
|
return token, nil
|
|
}
|
|
|
|
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
|
|
strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var parsed struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
|
return Token{}, fmt.Errorf("gmail: reading token response: %w", err)
|
|
}
|
|
if parsed.Error != "" {
|
|
return Token{}, fmt.Errorf("gmail: %s: %s", parsed.Error, parsed.ErrorDescription)
|
|
}
|
|
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
|
return Token{}, fmt.Errorf("gmail: token exchange failed with %s", resp.Status)
|
|
}
|
|
|
|
return Token{
|
|
AccessToken: parsed.AccessToken,
|
|
RefreshToken: parsed.RefreshToken,
|
|
// A minute of slack, so a token that is about to expire is refreshed
|
|
// rather than used and rejected.
|
|
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
|
}, nil
|
|
}
|
|
|
|
// --- the API ---------------------------------------------------------------------
|
|
|
|
type Client struct {
|
|
accessToken string
|
|
endpoints Endpoints
|
|
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(e Endpoints) *Client {
|
|
c.endpoints = e
|
|
return c
|
|
}
|
|
|
|
// 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.endpoints.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.endpoints.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.endpoints.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 {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
return ErrUnauthorized
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("gmail: %s", resp.Status)
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
|
// or changed their password. The caller disconnects rather than retrying.
|
|
var ErrUnauthorized = fmt.Errorf("gmail: authorisation was refused")
|
|
|
|
// SenderName pulls "Anders Jensen" out of "Anders Jensen <[email protected]>",
|
|
// 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, " ")
|
|
}
|
|
|
|
func randomString(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
|
}
|