Files
verdande/internal/httpapi/gmail_handlers.go
T
Kristian WindandClaude Opus 5 c4e480a0c4
Release / Build and publish image (push) Failing after 10m6s
fix(gmail): stop hiding why it failed, twice over
Reported from use: "Fetch now" said "something went wrong" and the error log was
empty. Both halves were the same bug wearing two hats.

`handleGmailSyncNow` answered 502 with the code `internal_error`, which the
interface maps to a generic sentence — so Google's own words, which are the whole
diagnosis, were replaced by one that says nothing. And `writeError` records
nothing; only `s.internal` does. So there was no message to read and nowhere to
look it up afterwards.

`s.upstream()` is the answer: 502 because this is somebody else's service saying
no rather than this server breaking, a code of its own so the interface can quote
what was said, and a row in the error log — which is the entire reason that log
exists. A Gmail sync failing quietly for a week is otherwise invisible: the
background job writes a line the next restart erases.

The same connection also left the mailbox unnamed. The address is learned at
connect time and that call can fail; a warning went to the log and an empty string
to the database, so the page said "an unknown account" and kept saying it. The only
way back was to disconnect and reconnect, which nobody would guess. The status
endpoint asks again when it finds none, so a wrong answer lasts until the next look
rather than until somebody reconnects.

Ten Danish strings were hiding in `confirm()` and `app.toast()` calls, including
every destructive confirmation in the app — the worst possible set to leave in one
language, since it is what somebody reads in the half-second before agreeing to
lose something. The i18n guard reads markup and attributes, and a string argument
lives in the script block. It reads those now, and found four my own grep missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:28:44 +02:00

467 lines
16 KiB
Go

package httpapi
import (
"context"
"net/http"
"strings"
"time"
"github.com/kristianwind/verdande/internal/gmail"
"github.com/kristianwind/verdande/internal/store"
)
// gmailConfig is the OAuth client this instance authorises with.
//
// The environment wins when it is set. A value in the environment is a deliberate
// deployment decision — it is in the Rune's manifest, under version control,
// applied on every recreate — and a form that could quietly override it would make
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
// to redeploy a container to paste a client id.
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
cfg := gmail.Config{
ClientID: s.cfg.GmailClientID,
ClientSecret: s.cfg.GmailClientSecret,
RedirectURL: s.cfg.GmailRedirectURL(),
}
if cfg.ClientID != "" && cfg.ClientSecret != "" {
return cfg
}
stored, err := s.db.InstanceSettings(ctx, "gmail")
if err != nil {
// Not fatal: an unconfigured Gmail and a database that could not answer
// look the same from here, and the caller's next step is the same either
// way — say it is not set up.
return cfg
}
if id, _ := stored["client_id"].(string); cfg.ClientID == "" {
cfg.ClientID = id
}
if secret, _ := stored["client_secret"].(string); cfg.ClientSecret == "" {
cfg.ClientSecret = secret
}
return cfg
}
// gmailFromEnv says whether the deployment set the client, which the settings page
// needs so it can explain why the fields are not editable rather than accepting
// changes that will never take effect.
func (s *Server) gmailFromEnv() bool {
return s.cfg.GmailClientID != "" && s.cfg.GmailClientSecret != ""
}
// handleGmailAuthorize starts the flow: it mints a PKCE pair, remembers it against
// the user, and hands back the Google URL to send the browser to.
//
// The verifier and state are stored server-side rather than put in a cookie,
// because the callback arrives as a top-level navigation from Google and a
// SameSite=Lax cookie is the kind of thing that works until a browser tightens its
// defaults.
func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
cfg := s.gmailConfig(r.Context())
if !cfg.Configured() {
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
"no Google OAuth client is registered on this server")
return
}
pkce, err := gmail.NewPKCE()
if err != nil {
s.internal(w, r, "gmail pkce", err)
return
}
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail settings", err)
return
}
settings["pkce_verifier"] = pkce.Verifier
settings["pkce_state"] = pkce.State
// The attempt expires: a stored verifier that lives forever is a replay window
// that lives forever.
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
s.internal(w, r, "gmail settings", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
}
// handleGmailCallback is where Google sends the browser back.
//
// It is a top-level navigation, so it answers with a redirect into the app rather
// than with JSON — the person is looking at a browser tab, not at a fetch response.
func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
settingsURL := s.cfg.BaseURL + "/indstillinger"
if errParam := r.URL.Query().Get("error"); errParam != "" {
// Somebody clicked "cancel" on the consent screen. Not an error worth a
// page of its own.
http.Redirect(w, r, settingsURL+"?gmail="+errParam, http.StatusFound)
return
}
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
http.Redirect(w, r, settingsURL+"?gmail=invalid", http.StatusFound)
return
}
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail settings", err)
return
}
verifier, _ := settings["pkce_verifier"].(string)
expected, _ := settings["pkce_state"].(string)
expires, _ := settings["pkce_expires"].(float64)
// The state has to match, and match in constant time is not needed here — it
// is a comparison of two values the server generated — but it does have to
// match at all, or this endpoint accepts a code from anywhere.
if verifier == "" || expected == "" || state != expected {
http.Redirect(w, r, settingsURL+"?gmail=state", http.StatusFound)
return
}
if int64(expires) < time.Now().Unix() {
http.Redirect(w, r, settingsURL+"?gmail=expired", http.StatusFound)
return
}
token, err := s.gmailConfig(r.Context()).Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
if err != nil {
s.log.Error("gmail exchange", "err", err, "user", user.ID)
http.Redirect(w, r, settingsURL+"?gmail=failed", http.StatusFound)
return
}
if token.RefreshToken == "" {
// Without one the connection dies in an hour. Google only withholds it
// when the consent prompt was skipped, which access_type and prompt are
// meant to prevent — so this means something is wrong with the client
// registration rather than with this request.
s.log.Error("gmail returned no refresh token", "user", user.ID)
http.Redirect(w, r, settingsURL+"?gmail=norefresh", http.StatusFound)
return
}
// Best effort: a mailbox that connects but cannot name itself is still
// connected, and the status endpoint asks again when it finds no address —
// so this failing is a delay rather than a dead end.
email, err := gmail.NewClient(token.AccessToken).Profile(r.Context())
if err != nil {
s.log.Warn("gmail profile", "err", err)
}
// The one-time values are cleared: keeping a spent verifier serves nothing and
// is one more secret at rest.
delete(settings, "pkce_verifier")
delete(settings, "pkce_state")
delete(settings, "pkce_expires")
settings["refresh_token"] = token.RefreshToken
settings["access_token"] = token.AccessToken
settings["expires_at"] = token.ExpiresAt.Unix()
settings["email"] = email
if _, ok := settings["trigger"]; !ok {
settings["trigger"] = "starred"
}
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
s.internal(w, r, "save gmail tokens", err)
return
}
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
http.Redirect(w, r, settingsURL+"?gmail=connected", http.StatusFound)
}
// handleGmailSyncNow polls immediately, so somebody who has just connected can see
// it work rather than wondering whether it did.
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
created, err := s.SyncGmail(r.Context(), user)
if err != nil {
s.upstream(w, r, CodeGmailFailed, "sync gmail", err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"created": created})
}
// syncGmail creates a task for each matching message that has not produced one yet.
//
// Returns how many were made. Also used by the background job, which is why it
// takes a context and a user rather than a request.
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
settings, err := s.db.UserSettings(ctx, user.ID, "gmail")
if err != nil {
return 0, err
}
str := func(key string) string {
v, _ := settings[key].(string)
return v
}
refresh := str("refresh_token")
if refresh == "" {
return 0, nil // not connected; nothing to do and nothing to complain about
}
query := gmail.Query(str("trigger"), str("label"))
if query == "" {
return 0, nil // connected but no trigger chosen
}
// The access token is refreshed only when it has actually expired.
access := str("access_token")
expires, _ := settings["expires_at"].(float64)
if access == "" || int64(expires) <= time.Now().Unix() {
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
if err != nil {
return 0, err
}
access = token.AccessToken
settings["access_token"] = access
settings["expires_at"] = token.ExpiresAt.Unix()
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
return 0, err
}
}
client := gmail.NewClient(access)
ids, err := client.List(ctx, query, 25)
if err != nil {
if err == gmail.ErrUnauthorized {
// The person revoked access, or changed their password. Forget the
// tokens rather than retrying every ten minutes forever.
s.log.Info("gmail access was revoked; disconnecting", "user", user.ID)
_ = s.db.SetUserSettings(ctx, user.ID, "gmail", map[string]any{})
}
return 0, err
}
// Which messages have already become tasks. Kept as a list on the settings
// rather than as a table: it is bounded by the query's own 30-day window, and
// a table would be one more thing to migrate for a feature this small.
seen := map[string]bool{}
if raw, ok := settings["seen"].([]any); ok {
for _, v := range raw {
if id, ok := v.(string); ok {
seen[id] = true
}
}
}
inbox, err := s.db.InboxID(ctx, user.ID)
if err != nil {
return 0, err
}
projectID := str("project_id")
if projectID == "" {
projectID = inbox
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
projectID = inbox
}
created := 0
for _, id := range ids {
if seen[id] {
continue
}
msg, err := client.Get(ctx, id)
if err != nil {
s.log.Warn("gmail get message", "err", err, "id", id)
continue
}
subject := strings.TrimSpace(msg.Subject)
if subject == "" {
subject = "(uden emne)"
}
sender := gmail.SenderName(msg.From)
task := &store.Task{
ProjectID: projectID,
Content: sender + ": " + subject,
// The deep link is the point: the task is a pointer back to the mail,
// not a copy of it.
Description: msg.Snippet + "\n\n" + msg.Link,
Priority: 4,
CreatedBy: user.ID,
}
if err := s.db.CreateTask(ctx, task, nil); err != nil {
s.log.Warn("gmail create task", "err", err)
continue
}
seen[id] = true
created++
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
}
if created > 0 {
// Only the ids Gmail still returns are kept, so the list cannot grow
// without bound as messages fall out of the 30-day window.
keep := make([]any, 0, len(ids))
for _, id := range ids {
if seen[id] {
keep = append(keep, id)
}
}
settings["seen"] = keep
settings["last_sync"] = time.Now().Unix()
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
return created, err
}
}
return created, nil
}
// --- version and updates ------------------------------------------------------------
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
status := s.updates.Status(r.Context())
// The notice is for whoever would do something about it. Telling an ordinary
// member that the server is out of date is telling them about somebody else's
// job.
if user := userFrom(r.Context()); user == nil || !user.IsAdmin {
status.Available = false
status.Notes = ""
status.URL = ""
status.Latest = ""
}
writeJSON(w, http.StatusOK, status)
}
// --- the OAuth client itself ------------------------------------------------------
// The registration Google requires, enterable rather than deployed.
//
// The one click that opens Google's consent screen already exists; what it needs
// behind it is a registered client, and that is not something any app can conjure:
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
// a restricted scope, so an id shipped inside a public image would be both
// extractable from it and unusable without Google's security review of every
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
// recreate the container to paste the result was not.
//
// Administrators only, and sessions only: this is the instance's identity to
// Google, and a leaked API token must not be able to read or replace it.
type gmailClientJSON struct {
ClientID string `json:"client_id"`
// Secret is written and never read back, like the AI key: a settings page that
// repopulates a password field is one that will eventually leak into a
// screenshot.
ClientSecret string `json:"client_secret,omitempty"`
HasSecret bool `json:"has_secret"`
// FromEnv says the deployment set this, in which case the stored pair is
// ignored and the fields are not worth editing. Sent so the page can say why
// rather than accepting changes that will never take effect.
FromEnv bool `json:"from_env"`
// RedirectURI is what has to be registered with Google, spelled out. It is
// derived from VERDANDE_BASE_URL and must match exactly — it is the single
// most likely thing to be wrong, and the error Google gives for it names
// neither value.
RedirectURI string `json:"redirect_uri"`
}
func (s *Server) handleGetGmailClient(w http.ResponseWriter, r *http.Request) {
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
if err != nil {
s.internal(w, r, "gmail client", err)
return
}
out := gmailClientJSON{
FromEnv: s.gmailFromEnv(),
RedirectURI: s.cfg.GmailRedirectURL(),
}
if out.FromEnv {
out.ClientID = s.cfg.GmailClientID
out.HasSecret = true
} else {
out.ClientID, _ = stored["client_id"].(string)
secret, _ := stored["client_secret"].(string)
out.HasSecret = secret != ""
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
var req gmailClientJSON
if err := decodeJSON(w, r, &req); err != nil {
return
}
if s.gmailFromEnv() {
writeError(w, http.StatusConflict, CodeConflict,
"the Gmail client is set in the environment and cannot be changed here")
return
}
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
if err != nil {
s.internal(w, r, "gmail client", err)
return
}
// An empty secret means "leave it alone", not "delete it" — otherwise
// correcting a typo in the client id would silently clear the secret.
secret, _ := stored["client_secret"].(string)
if req.ClientSecret != "" {
secret = strings.TrimSpace(req.ClientSecret)
}
id := strings.TrimSpace(req.ClientID)
if fields := map[string]string{}; id == "" && secret != "" {
fields["client_id"] = "required"
writeFieldErrors(w, fields)
return
}
if err := s.db.SetInstanceSettings(r.Context(), "gmail", map[string]any{
"client_id": id, "client_secret": secret,
}); err != nil {
s.internal(w, r, "save gmail client", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// gmailProfile fetches the mailbox address and stores it, refreshing the access
// token first if it has expired.
//
// Shared by the connect callback and the status endpoint. The callback is the
// natural place to learn the address, and it is also the one moment that cannot be
// retried — so the status endpoint asks again when it finds nothing, and the answer
// corrects itself on the next look.
func (s *Server) gmailProfile(ctx context.Context, user *store.User, settings map[string]any) (string, error) {
str := func(key string) string {
v, _ := settings[key].(string)
return v
}
access := str("access_token")
expires, _ := settings["expires_at"].(float64)
if access == "" || int64(expires) <= time.Now().Unix() {
token, err := s.gmailConfig(ctx).Refresh(ctx, str("refresh_token"))
if err != nil {
return "", err
}
access = token.AccessToken
settings["access_token"] = access
settings["expires_at"] = token.ExpiresAt.Unix()
}
email, err := gmail.NewClient(access).Profile(ctx)
if err != nil {
return "", err
}
settings["email"] = email
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
return "", err
}
return email, nil
}