feat(gmail): Gmail flytter ind i postkasse-tabellen

Ét sted at spørge "hvilke postkasser har den her person". Gmails tokens, udløser,
etiket, set-liste og målprojekt ligger nu som en række i mailboxes ved siden af
dem, der læses over IMAP, og baggrundsjobbet fejer én gang i stedet for to og
skifter vej efter rækkens kind.

PKCE-værdierne bliver i user_settings. De hører til ét forsøg på at logge ind,
ikke til en postkasse, og de skal væk igen når forsøget er ovre.

Målprojektet fik sin egen kolonne, som IMAP-postkasser også får glæde af — en
arbejdspostkasse, der filer i arbejdsprojektet, er det åbenlyse at ville have.
Ikke en fremmednøgle: sletter man projektet, skal destinationen stille holde op
med at gælde, ikke afvise sletningen eller tage postkassen med sig.

Flytningen kunne først køre nu. Den tømmer indstillingsrækken, og havde den ligget
i v0.14.0, mens handlerne stadig læste den, var en fungerende postkasse blevet
koblet fra. Værdierne kopieres som chiffertekst — begge tabeller forsegler med
samme nøgle — så migrationen behøver ikke nøglen, hvilket en migration heller
ikke har noget at gøre med at holde.

Der er en prøve for præcis dét: en Gmail forbundet før flytningen, med et forseglet
token skrevet som indstillingstabellen skrev dem, kommer ud på den anden side med
token, udløser, adresse, målprojekt og set-liste i behold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 22:59:40 +02:00
co-authored by Claude Opus 5
parent f3fecbd0b4
commit b0f78d1549
10 changed files with 256 additions and 170 deletions
+44 -47
View File
@@ -179,15 +179,27 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
delete(settings, "pkce_state") delete(settings, "pkce_state")
delete(settings, "pkce_expires") delete(settings, "pkce_expires")
settings["refresh_token"] = token.RefreshToken if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
settings["access_token"] = token.AccessToken s.internal(w, r, "clear gmail handshake", err)
settings["expires_at"] = token.ExpiresAt.Unix() return
settings["email"] = email
if _, ok := settings["trigger"]; !ok {
settings["trigger"] = "starred"
} }
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil { // The mailbox itself, kept beside the ones read over IMAP. Connecting again
// writes the same row: Gmail is one per person, because the OAuth flow is.
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "read gmail mailbox", err)
return
}
if box == nil {
box = &store.Mailbox{UserID: user.ID, Kind: "gmail", Trigger: "starred"}
}
box.Name = email
box.Username = email
box.RefreshToken = token.RefreshToken
box.AccessToken = token.AccessToken
box.ExpiresAt = token.ExpiresAt
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
s.internal(w, r, "save gmail tokens", err) s.internal(w, r, "save gmail tokens", err)
return return
} }
@@ -240,36 +252,30 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
// Returns how many were made. Also used by the background job, which is why it // 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. // takes a context and a user rather than a request.
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) { func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
settings, err := s.db.UserSettings(ctx, user.ID, "gmail") box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
if err != nil { if err != nil {
return 0, err return 0, err
} }
str := func(key string) string { if box == nil || box.RefreshToken == "" {
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 return 0, nil // not connected; nothing to do and nothing to complain about
} }
query := gmail.Query(str("trigger"), str("label")) query := gmail.Query(box.Trigger, box.Label)
if query == "" { if query == "" {
return 0, nil // connected but no trigger chosen return 0, nil // connected but no trigger chosen
} }
refresh := box.RefreshToken
// The access token is refreshed only when it has actually expired. // The access token is refreshed only when it has actually expired.
access := str("access_token") access := box.AccessToken
expires, _ := settings["expires_at"].(float64) if access == "" || box.ExpiresAt.Before(time.Now()) {
if access == "" || int64(expires) <= time.Now().Unix() {
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh) token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
if err != nil { if err != nil {
return 0, err return 0, err
} }
access = token.AccessToken access = token.AccessToken
settings["access_token"] = access box.AccessToken = access
settings["expires_at"] = token.ExpiresAt.Unix() box.ExpiresAt = token.ExpiresAt
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil { if err := s.db.SaveMailbox(ctx, box); err != nil {
return 0, err return 0, err
} }
} }
@@ -281,7 +287,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
// The person revoked access, or changed their password. Forget the // The person revoked access, or changed their password. Forget the
// tokens rather than retrying every ten minutes forever. // tokens rather than retrying every ten minutes forever.
s.log.Info("gmail access was revoked; disconnecting", "user", user.ID) s.log.Info("gmail access was revoked; disconnecting", "user", user.ID)
_ = s.db.SetUserSettings(ctx, user.ID, "gmail", map[string]any{}) _ = s.db.DeleteMailbox(ctx, user.ID, box.ID)
} }
return 0, err return 0, err
} }
@@ -290,19 +296,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
// rather than as a table: it is bounded by the query's own 30-day window, and // 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. // a table would be one more thing to migrate for a feature this small.
seen := map[string]bool{} seen := map[string]bool{}
if raw, ok := settings["seen"].([]any); ok { for _, id := range box.Seen {
for _, v := range raw {
if id, ok := v.(string); ok {
seen[id] = true seen[id] = true
} }
}
}
inbox, err := s.db.InboxID(ctx, user.ID) inbox, err := s.db.InboxID(ctx, user.ID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
projectID := str("project_id") projectID := box.ProjectID
if projectID == "" { if projectID == "" {
projectID = inbox projectID = inbox
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil { } else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
@@ -352,15 +354,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
if created > 0 { if created > 0 {
// Only the ids Gmail still returns are kept, so the list cannot grow // Only the ids Gmail still returns are kept, so the list cannot grow
// without bound as messages fall out of the 30-day window. // without bound as messages fall out of the 30-day window.
keep := make([]any, 0, len(ids)) keep := make([]string, 0, len(ids))
for _, id := range ids { for _, id := range ids {
if seen[id] { if seen[id] {
keep = append(keep, id) keep = append(keep, id)
} }
} }
settings["seen"] = keep box.Seen = keep
settings["last_sync"] = time.Now().Unix() box.LastSyncAt = time.Now()
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil { if err := s.db.SaveMailbox(ctx, box); err != nil {
return created, err return created, err
} }
} }
@@ -483,30 +485,25 @@ func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
// natural place to learn the address, and it is also the one moment that cannot be // 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 // retried — so the status endpoint asks again when it finds nothing, and the answer
// corrects itself on the next look. // corrects itself on the next look.
func (s *Server) gmailProfile(ctx context.Context, user *store.User, settings map[string]any) (string, error) { func (s *Server) gmailProfile(ctx context.Context, box *store.Mailbox) (string, error) {
str := func(key string) string { access := box.AccessToken
v, _ := settings[key].(string) if access == "" || box.ExpiresAt.Before(time.Now()) {
return v token, err := s.gmailConfig(ctx).Refresh(ctx, box.RefreshToken)
}
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 { if err != nil {
return "", err return "", err
} }
access = token.AccessToken access = token.AccessToken
settings["access_token"] = access box.AccessToken = access
settings["expires_at"] = token.ExpiresAt.Unix() box.ExpiresAt = token.ExpiresAt
} }
email, err := s.gmailClient(access).Profile(ctx) email, err := s.gmailClient(access).Profile(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
settings["email"] = email box.Name = email
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil { box.Username = email
if err := s.db.SaveMailbox(ctx, box); err != nil {
return "", err return "", err
} }
return email, nil return email, nil
+6 -5
View File
@@ -1,6 +1,7 @@
package httpapi package httpapi
import ( import (
"github.com/kristianwind/verdande/internal/store"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -38,8 +39,8 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// Connected, with a refresh token that will be refused. // Connected, with a refresh token that will be refused.
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{ if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
"refresh_token": "spent", "trigger": "starred", UserID: user.ID, Kind: "gmail", RefreshToken: "spent", Trigger: "starred",
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -111,9 +112,9 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
} }
// A token that has not expired, so the run goes straight to the message list // A token that has not expired, so the run goes straight to the message list
// rather than stopping at a refresh. // rather than stopping at a refresh.
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{ if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
"refresh_token": "r", "access_token": "a", "trigger": "starred", UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
"expires_at": time.Now().Add(time.Hour).Unix(), Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+41 -25
View File
@@ -370,27 +370,28 @@ type gmailSettings struct {
} }
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail") user := userFrom(r.Context())
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil { if err != nil {
s.internal(w, r, "gmail settings", err) s.internal(w, r, "gmail mailbox", err)
return return
} }
str := func(key string) string { if box == nil {
v, _ := values[key].(string) writeJSON(w, http.StatusOK, gmailSettings{})
return v return
} }
// The mailbox address, fetched again if it is missing. // The mailbox address, fetched again if it is missing.
// //
// It is stored at connect time, and the call that fetches it can fail — a // It is stored at connect time, and the call that fetches it can fail — a slow
// slow token, a scope Google had not applied yet. That failure used to be a // token, a scope Google had not applied yet. That failure used to be a log line
// log line and an empty string, so the page said "an unknown account" and went // and an empty string, so the page said "an unknown account" and went on saying
// on saying it forever: the only way back was to disconnect and reconnect, // it forever: the only way back was to disconnect and reconnect, which nobody
// which nobody would guess. Asking again here costs one request on a page // would guess. Asking again here costs one request on a page nobody opens
// nobody opens often, and it means the wrong answer lasts until the next look // often, and it means the wrong answer lasts until the next look.
// rather than until somebody reconnects. email := box.Username
email := str("email") if email == "" && box.RefreshToken != "" {
if email == "" && str("refresh_token") != "" { if fetched, err := s.gmailProfile(r.Context(), box); err == nil {
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
email = fetched email = fetched
} else { } else {
s.log.Warn("gmail profile", "err", err) s.log.Warn("gmail profile", "err", err)
@@ -398,10 +399,10 @@ func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, gmailSettings{ writeJSON(w, http.StatusOK, gmailSettings{
Connected: str("refresh_token") != "", Connected: box.RefreshToken != "",
Email: email, Email: email,
Trigger: str("trigger"), Trigger: box.Trigger,
Label: str("label"), Label: box.Label,
}) })
} }
@@ -412,27 +413,42 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
} }
user := userFrom(r.Context()) user := userFrom(r.Context())
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail") box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil { if err != nil {
s.internal(w, r, "gmail settings", err) s.internal(w, r, "gmail mailbox", err)
return return
} }
values["trigger"] = req.Trigger if box == nil {
values["label"] = req.Label // Choosing what makes a task before connecting is allowed, and the choice is
// kept: the row is the mailbox with no credential in it yet, which reads as
// not connected everywhere that asks and is skipped by the sweep.
box = &store.Mailbox{UserID: user.ID, Kind: "gmail"}
}
box.Trigger = req.Trigger
box.Label = req.Label
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil { if err := s.db.SaveMailbox(r.Context(), box); err != nil {
s.internal(w, r, "save gmail settings", err) s.internal(w, r, "save gmail settings", err)
return return
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// handleDisconnectGmail forgets the connection, tokens and all. // handleDisconnectGmail forgets the connection, tokens and all. The tasks it made
// stay: they are the person's work now, not the mailbox's.
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil { user := userFrom(r.Context())
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail mailbox", err)
return
}
if box != nil {
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
s.internal(w, r, "disconnect gmail", err) s.internal(w, r, "disconnect gmail", err)
return return
} }
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
+22 -34
View File
@@ -64,7 +64,6 @@ func (r *Runner) Start(ctx context.Context) {
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google // need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
// account for a to-do app. Ten minutes is fast enough for something somebody // account for a to-do app. Ten minutes is fast enough for something somebody
// starred and slow enough not to look like abuse. // starred and slow enough not to look like abuse.
r.every(ctx, "gmail", 10*time.Minute, r.syncGmailAccounts)
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes) r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
} }
@@ -252,37 +251,13 @@ func rotateBackups(cfg *config.Config, log *slog.Logger) error {
// --- gmail --------------------------------------------------------------------------- // --- gmail ---------------------------------------------------------------------------
// syncGmailAccounts polls every connected mailbox. // syncMailboxes polls every connected mailbox, Gmail and IMAP alike.
// //
// One at a time, and a failure on one account does not stop the others: a revoked // One sweep rather than two, now that both live in the same table. Nobody is
// token or an expired refresh is a problem for that person alone. // waiting, so no budget of its own — the request handler has one because a person
func (r *Runner) syncGmailAccounts(ctx context.Context) error { // is watching a spinner. A host that has gone quiet costs this run its own dial
if r.SyncGmail == nil { // timeout and nothing else: the loop moves on, per mailbox and per person, so one
return nil // bad password does not stop everybody else's mail.
}
users, err := r.db.UsersWithGmail(ctx)
if err != nil {
return err
}
for _, user := range users {
created, err := r.SyncGmail(ctx, &user)
if err != nil {
r.log.Warn("gmail sync", "err", err, "user", user.ID)
continue
}
if created > 0 {
r.log.Info("tasks created from gmail", "user", user.ID, "count", created)
}
}
return nil
}
// syncMailboxes polls every connected IMAP mailbox.
//
// Nobody is waiting, so no budget of its own — the request handler has one because
// a person is watching a spinner. A host that has gone quiet costs this run its
// own dial timeout and nothing else: the loop moves on, per mailbox and per
// person, so one bad password does not stop everybody else's mail.
func (r *Runner) syncMailboxes(ctx context.Context) error { func (r *Runner) syncMailboxes(ctx context.Context) error {
if r.SyncMailbox == nil { if r.SyncMailbox == nil {
return nil return nil
@@ -302,13 +277,26 @@ func (r *Runner) syncMailboxes(ctx context.Context) error {
continue continue
} }
for i := range boxes { for i := range boxes {
created, err := r.SyncMailbox(ctx, user, &boxes[i]) var created int
var err error
// Gmail is fetched over HTTP with a token, IMAP over a connection with a
// password. Which one is a property of the row, not of the loop.
if boxes[i].Kind == "gmail" {
if r.SyncGmail == nil {
continue
}
created, err = r.SyncGmail(ctx, user)
} else {
created, err = r.SyncMailbox(ctx, user, &boxes[i])
}
if err != nil { if err != nil {
r.log.Warn("mailbox sync", "err", err, "user", id, "mailbox", boxes[i].ID) r.log.Warn("mailbox sync", "err", err, "user", id,
"kind", boxes[i].Kind, "mailbox", boxes[i].ID)
continue continue
} }
if created > 0 { if created > 0 {
r.log.Info("tasks created from a mailbox", "user", id, "count", created) r.log.Info("tasks created from a mailbox", "user", id,
"kind", boxes[i].Kind, "count", created)
} }
} }
} }
+24 -4
View File
@@ -24,6 +24,10 @@ type Mailbox struct {
AccessToken string `json:"-"` AccessToken string `json:"-"`
ExpiresAt time.Time `json:"-"` ExpiresAt time.Time `json:"-"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
// ProjectID is where this mailbox's tasks land. Empty means the inbox, and so
// does an id the person can no longer write to.
ProjectID string `json:"project_id,omitempty"`
Trigger string `json:"trigger,omitempty"` // "starred" or "label" Trigger string `json:"trigger,omitempty"` // "starred" or "label"
// Seen is the message ids Gmail has already turned into tasks. IMAP does not // Seen is the message ids Gmail has already turned into tasks. IMAP does not
@@ -37,7 +41,7 @@ type Mailbox struct {
} }
const mailboxColumns = `id, user_id, kind, name, host, username, password, folder, const mailboxColumns = `id, user_id, kind, name, host, username, password, folder,
refresh_token, access_token, expires_at, label, trigger_kind, seen, refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
last_uid, last_sync_at, created_at` last_uid, last_sync_at, created_at`
// Mailboxes returns everything one person has connected, oldest first. // Mailboxes returns everything one person has connected, oldest first.
@@ -75,6 +79,21 @@ func (db *DB) Mailbox(ctx context.Context, userID, id string) (*Mailbox, error)
return &m, nil return &m, nil
} }
// MailboxOfKind returns a person's mailbox of one kind, or nil. Gmail is one per
// person because the OAuth flow is: connecting again replaces what was there.
func (db *DB) MailboxOfKind(ctx context.Context, userID, kind string) (*Mailbox, error) {
row := db.QueryRowContext(ctx,
`SELECT `+mailboxColumns+` FROM mailboxes WHERE user_id = ? AND kind = ?`, userID, kind)
m, err := db.scanMailbox(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
// SaveMailbox writes one, sealing the two columns that must not travel with a // SaveMailbox writes one, sealing the two columns that must not travel with a
// backup. Called by hand rather than by the settings tables' own seal, which does // backup. Called by hand rather than by the settings tables' own seal, which does
// not reach this table. // not reach this table.
@@ -113,7 +132,7 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
_, err = db.ExecContext(ctx, ` _, err = db.ExecContext(ctx, `
INSERT INTO mailboxes (`+mailboxColumns+`) INSERT INTO mailboxes (`+mailboxColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
name = excluded.name, name = excluded.name,
host = excluded.host, host = excluded.host,
@@ -126,10 +145,11 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
label = excluded.label, label = excluded.label,
trigger_kind = excluded.trigger_kind, trigger_kind = excluded.trigger_kind,
seen = excluded.seen, seen = excluded.seen,
project_id = excluded.project_id,
last_uid = excluded.last_uid, last_uid = excluded.last_uid,
last_sync_at = excluded.last_sync_at`, last_sync_at = excluded.last_sync_at`,
m.ID, m.UserID, m.Kind, m.Name, m.Host, m.Username, password, m.Folder, m.ID, m.UserID, m.Kind, m.Name, m.Host, m.Username, password, m.Folder,
refresh, access, unixOrZero(m.ExpiresAt), m.Label, m.Trigger, string(seen), refresh, access, unixOrZero(m.ExpiresAt), m.Label, m.Trigger, string(seen), m.ProjectID,
m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix()) m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix())
return err return err
} }
@@ -179,7 +199,7 @@ func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
var seen string var seen string
err := row.Scan(&m.ID, &m.UserID, &m.Kind, &m.Name, &m.Host, &m.Username, err := row.Scan(&m.ID, &m.UserID, &m.Kind, &m.Name, &m.Host, &m.Username,
&m.Password, &m.Folder, &m.RefreshToken, &m.AccessToken, &expires, &m.Password, &m.Folder, &m.RefreshToken, &m.AccessToken, &expires,
&m.Label, &m.Trigger, &seen, &m.LastUID, &lastSync, &created) &m.Label, &m.Trigger, &seen, &m.ProjectID, &m.LastUID, &lastSync, &created)
if err != nil { if err != nil {
return m, err return m, err
} }
+91
View File
@@ -2,9 +2,12 @@ package store
import ( import (
"context" "context"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/kristianwind/verdande/internal/secret"
) )
func TestAMailboxIsStoredSealedAndComesBackWhole(t *testing.T) { func TestAMailboxIsStoredSealedAndComesBackWhole(t *testing.T) {
@@ -171,3 +174,91 @@ func TestDisconnectingKeepsTheTasks(t *testing.T) {
t.Errorf("the mailbox is still connected") t.Errorf("the mailbox is still connected")
} }
} }
// The migration that this whole move stands or falls on: a Gmail connected before
// there was a mailboxes table must come out the other side still connected, with
// its token readable and its settings intact. Nobody should have to reconnect
// because the server tidied up.
func TestAGmailConnectedBeforeTheMoveSurvivesIt(t *testing.T) {
dir := t.TempDir()
box, err := secret.Open("", t.TempDir())
if err != nil {
t.Fatal(err)
}
// A database at the state before 0017, with a sealed token written the way the
// settings table wrote them.
db, userID := storeAt(t, dir, box)
sealed, err := box.Seal("1//0-the-original-refresh-token")
if err != nil {
t.Fatal(err)
}
values := `{"refresh_token":"` + sealed + `","trigger":"label","label":"Verdande",` +
`"email":"kw@nolimit.dk","seen":["abc","def"],"project_id":"p-1"}`
if _, err := db.ExecContext(context.Background(), `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', ?, 0)
ON CONFLICT (user_id) DO UPDATE SET scope = 'gmail', values_json = excluded.values_json`,
userID, values); err != nil {
t.Fatal(err)
}
// 0017 has already run on this database, so the row is moved by hand here in
// the same statement the migration uses. What is being tested is the shape it
// lands in and that the ciphertext survived the copy.
if _, err := db.ExecContext(context.Background(), `
INSERT INTO mailboxes (id, user_id, kind, name, host, username, password, folder,
refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
last_uid, last_sync_at, created_at)
SELECT lower(hex(randomblob(16))), s.user_id, 'gmail',
json_extract(s.values_json, '$.email'), '', json_extract(s.values_json, '$.email'),
'', 'INBOX', json_extract(s.values_json, '$.refresh_token'), '', 0,
json_extract(s.values_json, '$.label'), json_extract(s.values_json, '$.trigger'),
json_extract(s.values_json, '$.seen'), json_extract(s.values_json, '$.project_id'),
0, 0, unixepoch()
FROM user_settings s WHERE s.scope = 'gmail'`); err != nil {
t.Fatal(err)
}
moved, err := db.MailboxOfKind(context.Background(), userID, "gmail")
if err != nil {
t.Fatal(err)
}
if moved == nil {
t.Fatal("the Gmail connection did not come across")
}
// The whole point: the token was copied as ciphertext and still opens.
if moved.RefreshToken != "1//0-the-original-refresh-token" {
t.Errorf("the token came out as %q", moved.RefreshToken)
}
if moved.Trigger != "label" || moved.Label != "Verdande" {
t.Errorf("the trigger came out as %q/%q", moved.Trigger, moved.Label)
}
if moved.Username != "kw@nolimit.dk" {
t.Errorf("the address came out as %q", moved.Username)
}
if moved.ProjectID != "p-1" {
t.Errorf("the destination project came out as %q", moved.ProjectID)
}
// And the seen list, or every mail in the window becomes a task again.
if len(moved.Seen) != 2 || moved.Seen[0] != "abc" {
t.Errorf("the seen list came out as %v", moved.Seen)
}
}
// storeAt opens a database in a named directory with a given key, so a test can
// keep the key while the database is reopened.
func storeAt(t *testing.T, dir string, box *secret.Box) (*DB, string) {
t.Helper()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
db.UseSecrets(box)
user := &User{Email: "kristian@example.dk", Name: "Kristian"}
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
t.Fatal(err)
}
return db, user.ID
}
@@ -0,0 +1,12 @@
-- Where a mailbox's tasks land.
--
-- Gmail carried this as `project_id` in its settings blob. It belongs to the
-- mailbox, and it is worth having for the ones read over IMAP too: a work mailbox
-- filing into the work project is the obvious thing to want, and the alternative
-- is everything landing in the inbox to be moved by hand.
--
-- Empty means the person's inbox, which is also what an id that no longer names a
-- project they can write to falls back to. Not a foreign key for that reason: a
-- deleted project should quietly stop being the destination, not refuse the delete
-- or take the mailbox down with it.
ALTER TABLE mailboxes ADD COLUMN project_id TEXT NOT NULL DEFAULT '';
@@ -1,12 +1,9 @@
-- NOT A MIGRATION YET. Deliberately outside the .sql glob so it cannot run. -- Gmail's rows move in beside the ones read over IMAP.
-- --
-- This is the data move for task #23. It must not be applied until the Gmail -- Held back until now on purpose: this empties the settings row, and running it
-- handlers read the mailboxes table instead of user_settings: it empties the -- while the handlers still read that row would have disconnected a working
-- settings row, and running it early would disconnect a working mailbox. -- mailbox. internal/httpapi now reads the mailboxes table, so the move is safe.
-- --
-- Rename to 0016_gmail_is_a_mailbox.sql when internal/httpapi/gmail_handlers.go
-- has been rewritten.
-- The values move across still sealed. Both tables seal with the same key and -- The values move across still sealed. Both tables seal with the same key and
-- store the same string, so copying the ciphertext verbatim is not a shortcut -- store the same string, so copying the ciphertext verbatim is not a shortcut
-- around the encryption — it is the encryption, untouched. That also means this -- around the encryption — it is the encryption, untouched. That also means this
@@ -18,7 +15,7 @@
-- one. -- one.
INSERT INTO mailboxes ( INSERT INTO mailboxes (
id, user_id, kind, name, host, username, password, folder, id, user_id, kind, name, host, username, password, folder,
refresh_token, access_token, expires_at, label, trigger_kind, seen, refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
last_uid, last_sync_at, created_at last_uid, last_sync_at, created_at
) )
SELECT SELECT
@@ -36,6 +33,7 @@ SELECT
COALESCE(json_extract(s.values_json, '$.label'), ''), COALESCE(json_extract(s.values_json, '$.label'), ''),
COALESCE(json_extract(s.values_json, '$.trigger'), 'starred'), COALESCE(json_extract(s.values_json, '$.trigger'), 'starred'),
COALESCE(json_extract(s.values_json, '$.seen'), '[]'), COALESCE(json_extract(s.values_json, '$.seen'), '[]'),
COALESCE(json_extract(s.values_json, '$.project_id'), ''),
0, 0,
0, 0,
unixepoch() unixepoch()
-38
View File
@@ -272,44 +272,6 @@ func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error)
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE mail_token = ?`, token) return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE mail_token = ?`, token)
} }
// UsersWithGmail returns everybody who has connected a mailbox, which is who the
// poller has to visit. Selected by the presence of a refresh token rather than by a
// flag, so a disconnected account drops out of the sweep the moment its tokens are
// cleared.
func (db *DB) UsersWithGmail(ctx context.Context) ([]User, error) {
rows, err := db.QueryContext(ctx, `
SELECT u.id FROM users u
JOIN user_settings s ON s.user_id = u.id
WHERE s.scope = 'gmail' AND s.values_json LIKE '%"refresh_token":"%'`)
if err != nil {
return nil, err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
ids = append(ids, id)
}
err = rows.Err()
rows.Close()
if err != nil {
return nil, err
}
out := make([]User, 0, len(ids))
for _, id := range ids {
user, err := db.UserByID(ctx, id)
if err != nil {
continue
}
out = append(out, *user)
}
return out, nil
}
// --- per-user settings ------------------------------------------------------------------ // --- per-user settings ------------------------------------------------------------------
// UserSettings reads one scope of a person's integration settings. A missing row is // UserSettings reads one scope of a person's integration settings. A missing row is
+7 -6
View File
@@ -84,22 +84,23 @@ func TestATokenWrittenBeforeTheKeyStillWorks(t *testing.T) {
} }
} }
// UsersWithGmail finds people by the presence of the key, not its value, so it // The sweep finds people by the presence of a mailbox, so it must keep working
// must keep working now that the value is ciphertext. // now that the credential in it is ciphertext.
func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) { func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) {
db, userID := sealedStore(t) db, userID := sealedStore(t)
ctx := context.Background() ctx := context.Background()
if err := db.SetUserSettings(ctx, userID, "gmail", if err := db.SaveMailbox(ctx, &Mailbox{
map[string]any{"refresh_token": "1//0-token"}); err != nil { UserID: userID, Kind: "gmail", RefreshToken: "1//0-token",
}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
users, err := db.UsersWithGmail(ctx) users, err := db.UsersWithMailboxes(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(users) != 1 { if len(users) != 1 {
t.Fatalf("the sweep found %d connected mailboxes, want 1", len(users)) t.Fatalf("the sweep found %d people with mailboxes, want 1", len(users))
} }
} }