Compare commits

...
4 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 993822f6fd fix(panel): bed om en planlagt genstart, ikke en der dør med afsenderen
Release / Build and publish image (push) Failing after 10m6s
Knappen tog produktionen ned og lod den ligge. Panelets almindelige restart
udfører arbejdet mens forespørgslen står åben: den stopper containeren — hvilket
slår denne proces ihjel — og panelet, der ser afsenderen forsvinde, når aldrig
til den halvdel, der starter igen. Fire minutters nedetid den 18. august, og det
kan ikke lappes herfra: enhver knap, der synkront beder om sin egen død, har
samme form.

safe-restart planlægges i stedet. Panelet svarer "Restart scheduled" og gør
arbejdet bagefter for egen regning, så intet afhænger af, at denne proces stadig
er i live til at høre svaret.

Prøven hang bagefter, og det var prøvens skyld: dens falske panel læste aldrig
forespørgslens krop, og der er en nu. En server, der ikke læser hvad den fik,
opdager aldrig at afsenderen har lagt på — så httptest ventede på en handler, der
aldrig vendte tilbage, og hele binæren løb tør for tid i stedet for den ene
forespørgsel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:15:10 +02:00
Kristian WindandClaude Opus 5 b0f78d1549 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>
2026-08-18 22:59:40 +02:00
Kristian WindandClaude Opus 5 f3fecbd0b4 fix(imap): samme mail blev en ny opgave hver gang, og uddraget var rå MIME
To fejl fra ét skærmbillede.

Markøren stod stille. Den blev sat ud fra uid'en i fetch-svaret, og den kommer
ikke fra alle servere — så next-run læste fra nul hver gang. Den sættes nu ud fra
hvad SØGNINGEN fandt, når kørslen kom hele vejen igennem: så er alt op til det
punkt betragtet, uanset hvad fetch-svaret bar med. En afbrudt kørsel flytter kun
til den sidste, den faktisk lavede en opgave af. Prøven mod hukommelsesserveren
ville aldrig have fanget det, for den svarer altid med uid.

Og uddraget hentede BODY[TEXT], som ved multipart er grænser og dele-hoveder og
det hele — derfor stod der "--=_be71cb39… Content-Transfer-Encoding: 8bit" foran
teksten i hver eneste opgave. Nu hentes hele beskeden og MIME-strukturen gås
igennem: text/plain foretrækkes, HTML er reserven med mærkerne pillet af. Groft,
for det er linjen under en overskrift og ikke en gengivelsesmotor.

Multipart-prøven er sat på prøve: med den gamle hentning fejler den med præcis
den tekst, skærmbilledet viste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:44:00 +02:00
Kristian WindandClaude Opus 5 30adc69cb5 feat(mailboxes): plads til Gmails udløser og dens set-liste
Kolonnerne, ikke flytningen. Gmail-handlerne læser stadig user_settings, og en
migration, der tømte den tabel før de var skrevet om, ville koble en fungerende
postkasse fra. Det additive først; flytningen når koden på den anden side er klar
til den, og den ligger som PENDING_gmail_move.sql.txt uden for .sql-mønstret,
så den ikke kan køre ved et uheld.

Udløseren fik sin egen kolonne frem for at blive pakket ind i label med et kolon.
En kolonne, der rummer to ting adskilt af et tegn, er en kolonne, nogen læser
forkert senere — og en etiket med kolon i navnet ville gøre det på første dag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:38:45 +02:00
16 changed files with 577 additions and 190 deletions
+45 -48
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 { seen[id] = true
if id, ok := v.(string); ok {
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)
} }
+42 -26
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())
s.internal(w, r, "disconnect gmail", err) box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail mailbox", err)
return return
} }
if box != nil {
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
s.internal(w, r, "disconnect gmail", err)
return
}
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
+9 -1
View File
@@ -147,7 +147,7 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
} }
defer client.Close() defer client.Close()
messages, err := client.Since(m.LastUID, 25) messages, searched, err := client.Since(m.LastUID, 25)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -197,6 +197,14 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
s.hub.Publish(projectID, "task.created", toTaskJSON(*task)) s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
} }
// A run that got all the way through moves the marker to the whole of what the
// search considered, not merely to the last message it happened to read. That
// is what stops the same mail becoming a task again on the next run when the
// server did not put a uid in the fetch reply.
if ctx.Err() == nil && searched > highest {
highest = searched
}
// Written even when the loop was cut short, and outside the request's context // Written even when the loop was cut short, and outside the request's context
// so a cancelled request still records what it did. // so a cancelled request still records what it did.
if highest > m.LastUID { if highest > m.LastUID {
+16 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
) )
@@ -72,13 +73,26 @@ func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request)
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second) ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
defer cancel() defer cancel()
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID) // safe-restart rather than restart, and the difference is not politeness.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) //
// The plain endpoint does its work while the request is open: it stops the
// container — which kills this process — and the panel, seeing the caller
// vanish, never gets to the starting half. That took production down and left
// it down for four minutes on 18 August, and it cannot be fixed from this side:
// any button that synchronously asks for its own death has the same shape.
//
// safe-restart is scheduled. The panel answers "Restart scheduled" and does the
// work on its own afterwards, so nothing depends on this process still being
// alive to hear the reply.
url := fmt.Sprintf("%s/api/servers/%s/safe-restart", s.cfg.PanelURL, s.cfg.PanelServerID)
body := strings.NewReader(`{"backup_first":false,"target_id":""}`)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil { if err != nil {
s.internal(w, r, "build panel request", err) s.internal(w, r, "build panel request", err)
return return
} }
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken) req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil { if err != nil {
+7
View File
@@ -1,6 +1,7 @@
package httpapi package httpapi
import ( import (
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -15,6 +16,11 @@ import (
// request has to end either way, or whatever sits in front answers for it. // request has to end either way, or whatever sits in front answers for it.
func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) { func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Drained first. A real server reads what it was sent, and one that does not
// never learns the caller has hung up — httptest.Server.Close then waits for
// a handler that will never return, and the whole binary times out instead
// of the one request.
_, _ = io.Copy(io.Discard, r.Body)
<-r.Context().Done() <-r.Context().Done()
})) }))
defer panel.Close() defer panel.Close()
@@ -56,6 +62,7 @@ func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
// And a panel that says no must say so in this server's own words, not in a proxy's. // And a panel that says no must say so in this server's own words, not in a proxy's.
func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) { func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
})) }))
defer panel.Close() defer panel.Close()
+120 -22
View File
@@ -16,15 +16,18 @@
package imap package imap
import ( import (
"bytes"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"io"
"net" "net"
"strings" "strings"
"time" "time"
"github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-message"
) )
// Message is one mail, reduced to what a task needs. // Message is one mail, reduced to what a task needs.
@@ -108,9 +111,13 @@ func (c *Client) Close() error {
// that consumed it would be taking something away. Flagging a mail is a deliberate // that consumed it would be taking something away. Flagging a mail is a deliberate
// act with no other meaning, which is exactly what is wanted — the same reason the // act with no other meaning, which is exactly what is wanted — the same reason the
// Gmail side reads stars. // Gmail side reads stars.
func (c *Client) Since(uid uint32, limit int) ([]Message, error) { // The second return is the highest uid the search considered. A caller that got
// through every message can move its marker to that, and then it does not matter
// whether the server put a uid in each fetch reply — which some do not, and which
// is how the same mail became a task again on every run.
func (c *Client) Since(uid uint32, limit int) ([]Message, uint32, error) {
if _, err := c.c.Select(c.folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil { if _, err := c.c.Select(c.folder, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
return nil, fmt.Errorf("open %s: %w", c.folder, err) return nil, 0, fmt.Errorf("open %s: %w", c.folder, err)
} }
// From uid+1 to the end. A mailbox with nothing new answers with no uids // From uid+1 to the end. A mailbox with nothing new answers with no uids
@@ -124,12 +131,12 @@ func (c *Client) Since(uid uint32, limit int) ([]Message, error) {
} }
found, err := c.c.UIDSearch(criteria, nil).Wait() found, err := c.c.UIDSearch(criteria, nil).Wait()
if err != nil { if err != nil {
return nil, fmt.Errorf("search %s: %w", c.folder, err) return nil, 0, fmt.Errorf("search %s: %w", c.folder, err)
} }
uids := found.AllUIDs() uids := found.AllUIDs()
if len(uids) == 0 { if len(uids) == 0 {
return nil, nil return nil, uid, nil
} }
if limit > 0 && len(uids) > limit { if limit > 0 && len(uids) > limit {
// The newest ones. A mailbox flagged over years would otherwise make the // The newest ones. A mailbox flagged over years would otherwise make the
@@ -139,29 +146,51 @@ func (c *Client) Since(uid uint32, limit int) ([]Message, error) {
want := imap.UIDSetNum(uids...) want := imap.UIDSetNum(uids...)
fetch := &imap.FetchOptions{ fetch := &imap.FetchOptions{
Envelope: true, Envelope: true,
UID: true, UID: true,
BodySection: []*imap.FetchItemBodySection{{Specifier: imap.PartSpecifierText, Peek: true}}, // The whole message, headers included, so the MIME structure can be walked.
// TEXT alone gives the body as bytes — boundaries and part headers and all —
// which is what put "--=_be71cb39… Content-Transfer-Encoding: 8bit" at the
// front of every task made from a multipart mail.
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
} }
var highest uint32
for _, u := range uids {
if uint32(u) > highest {
highest = uint32(u)
}
}
msgs, err := c.c.Fetch(want, fetch).Collect() msgs, err := c.c.Fetch(want, fetch).Collect()
if err != nil { if err != nil {
return nil, fmt.Errorf("fetch from %s: %w", c.folder, err) return nil, 0, fmt.Errorf("fetch from %s: %w", c.folder, err)
} }
out := make([]Message, 0, len(msgs)) out := make([]Message, 0, len(msgs))
for _, m := range msgs { for i, m := range msgs {
if m.Envelope == nil { if m.Envelope == nil {
continue continue
} }
// The uid comes from the search when the fetch reply does not carry one.
// A missing uid is not cosmetic: it is the marker the next run starts from,
// and a marker stuck at zero makes every message arrive again on every run.
// That is what happened against a real server while the tests stayed green,
// because the server they run against always answers with it.
msgUID := uint32(m.UID)
if msgUID == 0 && i < len(uids) {
msgUID = uint32(uids[i])
}
out = append(out, Message{ out = append(out, Message{
UID: uint32(m.UID), UID: msgUID,
From: address(m.Envelope.From), From: address(m.Envelope.From),
Subject: strings.TrimSpace(m.Envelope.Subject), Subject: strings.TrimSpace(m.Envelope.Subject),
Snippet: snippet(m), Snippet: snippet(m),
Date: m.Envelope.Date, Date: m.Envelope.Date,
}) })
} }
return out, nil return out, highest, nil
} }
// address renders the first sender as "Name <addr>", or just the address. // address renders the first sender as "Name <addr>", or just the address.
@@ -177,23 +206,92 @@ func address(list []imap.Address) string {
return a.Name + " <" + addr + ">" return a.Name + " <" + addr + ">"
} }
// snippet is the first readable line or so of the body. // snippet is the first readable text in the mail.
// //
// No MIME walk and no HTML stripping yet: this is what shows under a task's title, // It walks the MIME structure rather than taking the body as bytes. text/plain is
// and a wrong-but-short line is a smaller problem than a parser that has to be // preferred and HTML is the fallback with its tags stripped — crudely, because
// right about every mail ever sent. The full mail stays where it is. // this is the line under a task's title and not a rendering engine. The mail
// itself stays where it is.
const snippetLimit = 400 const snippetLimit = 400
func snippet(m *imapclient.FetchMessageBuffer) string { func snippet(m *imapclient.FetchMessageBuffer) string {
for _, body := range m.BodySection { for _, body := range m.BodySection {
text := strings.TrimSpace(string(body.Bytes)) if text := readable(body.Bytes); text != "" {
if text == "" { return cut(text)
continue
} }
if len(text) > snippetLimit {
text = text[:snippetLimit] + "…"
}
return text
} }
return "" return ""
} }
func readable(raw []byte) string {
entity, err := message.Read(bytes.NewReader(raw))
if err != nil {
// Not something this parser recognises. The raw bytes read badly but read,
// and a mail is not worth losing over its own headers.
return strings.TrimSpace(string(raw))
}
return strings.TrimSpace(fromEntity(entity, 0))
}
// fromEntity descends into a multipart mail. The depth guard is for the mail that
// nests parts inside parts inside parts — rare, and cheaper to bound than to trust.
func fromEntity(entity *message.Entity, depth int) string {
if depth > 4 {
return ""
}
if parts := entity.MultipartReader(); parts != nil {
var html string
for {
part, err := parts.NextPart()
if err != nil {
break
}
if text := fromEntity(part, depth+1); text != "" {
kind, _, _ := part.Header.ContentType()
if kind != "text/html" {
return text
}
if html == "" {
html = text
}
}
}
return html
}
body, _ := io.ReadAll(entity.Body)
kind, _, _ := entity.Header.ContentType()
if kind == "text/html" {
return stripTags(string(body))
}
return strings.TrimSpace(string(body))
}
// stripTags removes markup without pretending to understand it. Enough for one
// line of preview, and honest about being no more than that.
func stripTags(html string) string {
var b strings.Builder
depth := 0
for _, r := range html {
switch {
case r == '<':
depth++
case r == '>':
if depth > 0 {
depth--
}
case depth == 0:
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
func cut(text string) string {
runes := []rune(text)
if len(runes) > snippetLimit {
return string(runes[:snippetLimit]) + "…"
}
return text
}
+85 -4
View File
@@ -37,7 +37,7 @@ func TestFlaggedMailComesBackAsMessages(t *testing.T) {
} }
defer client.Close() defer client.Close()
msgs, err := client.Since(0, 25) msgs, _, err := client.Since(0, 25)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -59,7 +59,7 @@ func TestFlaggedMailComesBackAsMessages(t *testing.T) {
// And reading on from a marker returns only what is newer, which is the whole // And reading on from a marker returns only what is newer, which is the whole
// of the deduplication. // of the deduplication.
rest, err := client.Since(msgs[0].UID, 25) rest, _, err := client.Since(msgs[0].UID, 25)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -92,6 +92,9 @@ func TestAnUntrustedCertificateIsRefused(t *testing.T) {
type mail struct { type mail struct {
subject, from, body string subject, from, body string
flagged bool flagged bool
// raw replaces the whole message when set, which is the only way to get a
// genuinely multipart one in.
raw string
} }
// serveMailbox starts an in-process IMAP server over TLS and returns its address // serveMailbox starts an in-process IMAP server over TLS and returns its address
@@ -112,8 +115,11 @@ func serveMailbox(t *testing.T, mails []mail) (string, *x509.CertPool) {
if m.flagged { if m.flagged {
flags = append(flags, imap.FlagFlagged) flags = append(flags, imap.FlagFlagged)
} }
raw := "From: " + m.from + "\r\nSubject: " + m.subject + raw := m.raw
"\r\nDate: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + m.body + "\r\n" if raw == "" {
raw = "From: " + m.from + "\r\nSubject: " + m.subject +
"\r\nDate: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + m.body + "\r\n"
}
if _, err := user.Append("INBOX", strings.NewReader(raw), &imap.AppendOptions{ if _, err := user.Append("INBOX", strings.NewReader(raw), &imap.AppendOptions{
Flags: flags, Flags: flags,
}); err != nil { }); err != nil {
@@ -181,3 +187,78 @@ func selfSigned(t *testing.T) (tls.Certificate, *x509.CertPool) {
pool.AppendCertsFromPEM(certPEM) pool.AppendCertsFromPEM(certPEM)
return cert, pool return cert, pool
} }
// The bug this was written for: every press of "Fetch now" made the same mail
// into a task again. The marker the next run starts from is the uid, and if it
// comes back as zero the marker never moves.
func TestEveryMessageCarriesAUID(t *testing.T) {
addr, pool := serveMailbox(t, []mail{
{subject: "En", from: "a@example.dk", body: "en", flagged: true},
{subject: "To", from: "b@example.dk", body: "to", flagged: true},
})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
for _, m := range msgs {
if m.UID == 0 {
t.Fatalf("%q came back without a uid; the marker would never move and "+
"every run would make it into a task again", m.Subject)
}
}
if msgs[0].UID == msgs[1].UID {
t.Error("two messages share a uid")
}
}
// The other half of the same report: the snippet under a task's title read
// "--=_be71cb39… Content-Transfer-Encoding: 8bit Content-Type: text/plain".
func TestAMultipartMailReadsAsItsText(t *testing.T) {
const boundary = "=_be71cb3975bdfbc60a16327cb2f49c23"
body := "This is a multi-part message.\r\n" +
"--" + boundary + "\r\n" +
"Content-Transfer-Encoding: 8bit\r\n" +
"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n\r\n" +
"Hej igen,\r\n\r\nVi har normalt 3 slags. :)\r\n" +
"--" + boundary + "\r\n" +
"Content-Type: text/html; charset=UTF-8\r\n\r\n" +
"<html><body><p>Hej igen</p></body></html>\r\n" +
"--" + boundary + "--\r\n"
addr, pool := serveMailbox(t, []mail{{
flagged: true,
raw: "Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n" +
"From: kontakt@mikrobagt.dk\r\nSubject: Re: Kaffebestilling\r\n" +
"Date: Tue, 18 Aug 2026 10:00:00 +0200\r\n\r\n" + body,
}})
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
if err != nil {
t.Fatal(err)
}
defer client.Close()
msgs, _, err := client.Since(0, 25)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 1 {
t.Fatalf("got %d messages", len(msgs))
}
got := msgs[0].Snippet
for _, junk := range []string{boundary, "Content-Transfer-Encoding", "Content-Type", "<html>", "<p>"} {
if strings.Contains(got, junk) {
t.Errorf("the snippet still carries %q: %q", junk, got)
}
}
if !strings.HasPrefix(got, "Hej igen") {
t.Errorf("the snippet does not start with the text of the mail: %q", got)
}
}
+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)
} }
} }
} }
+47 -4
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"time" "time"
) )
@@ -24,13 +25,24 @@ type Mailbox struct {
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"
// Seen is the message ids Gmail has already turned into tasks. IMAP does not
// need it — uids are monotonic, so LastUID says the same in one number — but
// Gmail's query is a rolling window rather than a marker.
Seen []string `json:"-"`
LastUID uint32 `json:"last_uid,omitempty"` LastUID uint32 `json:"last_uid,omitempty"`
LastSyncAt time.Time `json:"last_sync_at,omitempty"` LastSyncAt time.Time `json:"last_sync_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
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, last_uid, last_sync_at, created_at` refresh_token, access_token, expires_at, label, trigger_kind, seen, project_id,
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.
func (db *DB) Mailboxes(ctx context.Context, userID string) ([]Mailbox, error) { func (db *DB) Mailboxes(ctx context.Context, userID string) ([]Mailbox, error) {
@@ -67,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.
@@ -93,10 +120,19 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
if err != nil { if err != nil {
return fmt.Errorf("seal access_token: %w", err) return fmt.Errorf("seal access_token: %w", err)
} }
// Not sealed: which mails have been read is not a secret, and a column of
// ciphertext here would only make the table harder to look at.
seen, err := json.Marshal(m.Seen)
if err != nil {
return err
}
if m.Trigger == "" {
m.Trigger = "starred"
}
_, 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,
@@ -107,10 +143,13 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
access_token = excluded.access_token, access_token = excluded.access_token,
expires_at = excluded.expires_at, expires_at = excluded.expires_at,
label = excluded.label, label = excluded.label,
trigger_kind = excluded.trigger_kind,
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, 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
} }
@@ -157,12 +196,16 @@ type scanner interface {
func (db *DB) scanMailbox(row scanner) (Mailbox, error) { func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
var m Mailbox var m Mailbox
var expires, lastSync, created int64 var expires, lastSync, created int64
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.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
} }
// A row that somehow holds nonsense here is not worth failing a whole sync
// over: an empty list means everything looks new, which is recoverable.
_ = json.Unmarshal([]byte(seen), &m.Seen)
for _, field := range []*string{&m.Password, &m.RefreshToken, &m.AccessToken} { for _, field := range []*string{&m.Password, &m.RefreshToken, &m.AccessToken} {
plain, err := db.unsealValue(*field) plain, err := db.unsealValue(*field)
+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,22 @@
-- Room for Gmail beside the others.
--
-- Only the two columns. Moving the data is a separate migration on purpose: the
-- Gmail handlers still read user_settings, and a migration that emptied that table
-- before they were rewritten would disconnect a working mailbox. Additive first,
-- and the move when the code on the other side is ready for it.
--
-- Gmail lived in `user_settings` under scope 'gmail' because it was the only
-- integration of its kind. Now there are two, and two places to look for the same
-- question — which mailboxes does this person have — is one place too many.
--
-- `seen` is Gmail's own deduplication: a list of message ids it has already turned
-- into tasks. IMAP does not need it, because uids are monotonic and `last_uid`
-- answers the same question in one number. Gmail's query is a 30-day window rather
-- than a marker, so the list is what it has.
ALTER TABLE mailboxes ADD COLUMN seen TEXT NOT NULL DEFAULT '[]';
-- What makes a mail into a task: 'starred', or 'label' with the name in `label`.
-- Its own column rather than packed into `label` with a separator — a column that
-- holds two things joined by a colon is a column somebody parses wrongly later,
-- and a label named with a colon in it would do it on the first day.
ALTER TABLE mailboxes ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT 'starred';
@@ -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 '';
@@ -0,0 +1,46 @@
-- Gmail's rows move in beside the ones read over IMAP.
--
-- Held back until now on purpose: this empties the settings row, and running it
-- while the handlers still read that row would have disconnected a working
-- mailbox. internal/httpapi now reads the mailboxes table, so the move is safe.
--
-- 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
-- around the encryption — it is the encryption, untouched. That also means this
-- migration does not need the key, which a migration has no business holding.
--
-- `username` is the address the account reports, and it is what makes the unique
-- index meaningful. It may be empty on rows that never got that far; two such rows
-- cannot exist for one person anyway, because the settings table could only hold
-- one.
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',
COALESCE(json_extract(s.values_json, '$.email'), 'Gmail'),
'',
COALESCE(json_extract(s.values_json, '$.email'), ''),
'',
'INBOX',
COALESCE(json_extract(s.values_json, '$.refresh_token'), ''),
COALESCE(json_extract(s.values_json, '$.access_token'), ''),
COALESCE(json_extract(s.values_json, '$.expires_at'), 0),
COALESCE(json_extract(s.values_json, '$.label'), ''),
COALESCE(json_extract(s.values_json, '$.trigger'), 'starred'),
COALESCE(json_extract(s.values_json, '$.seen'), '[]'),
COALESCE(json_extract(s.values_json, '$.project_id'), ''),
0,
0,
unixepoch()
FROM user_settings s
WHERE s.scope = 'gmail'
AND COALESCE(json_extract(s.values_json, '$.refresh_token'), '') <> '';
-- Emptied, not deleted: the row may hold nothing else today, but the table is
-- shared and a DELETE here would be a habit worth not forming.
UPDATE user_settings SET values_json = '{}' WHERE scope = 'gmail';
-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))
} }
} }