Compare commits

..
16 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
2026-08-18 22:38:45 +02:00
Kristian WindandClaude Opus 5 c9dfffd95a feat(mailboxes): postkasser kan forbindes fra fladen
Release / Build and publish image (push) Failing after 9m59s
Panelet under Integrationer: tilføj, hent nu, kobl fra. Sit eget frem for en
tilstand mere inde i Gmail-panelet, for de to er ikke det samme i forskellige
klæder — det ene er en app, man logger ind igennem, det andet et kodeord, man
selv holder.

Feltet hedder IMAP-server og ikke Server: der stod i forvejen Server på CalDAV
længere nede, og to felter med samme navn på én side er tvetydige også for en
skærmlæser. Testen fandt det, men det var ikke kun testens problem.

To vagter fangede mangler undervejs, og begge havde ret: mailbox_failed skulle
have en tekst i fladen, ellers ville nogen få serverens engelske logsprog at se,
og de tre ruter skulle i openapi.yaml. Der er nu et Mailbox-skema samme sted, som
siger hvad der ikke kommer tilbage — kodeordet og eventuelle tokens.

E2E-prøven forbinder til noget, der ikke lytter, og ser efter værtens egen
afvisning på skærmen. Den er samtidig prøven på at 422 var det rigtige valg: var
det stadig 502, ville et mellemled kunne kaste beskeden væk, og der ville stå
"noget gik galt".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:22:31 +02:00
Kristian WindandClaude Opus 5 6b4ef5b2f2 feat(imap): postkasser kan læses, og det er bevist mod en rigtig server
Handlere til at forbinde, koble fra og hente nu, plus baggrundssynk hvert tiende
minut ved siden af Gmails. Forbindelsen prøves FØR den gemmes: en postkasse, der
blev taget imod og så fejler stille hvert tiende minut, er værre end en, der blev
afvist ved døren, mens man stadig sidder med kodeordet i hånden.

Ingen "set"-liste til deduplikering som Gmail har. Uid'er er monotone, så
last_uid er hele svaret — og den skrives også, når budgettet løb ud, for en
kørsel, der lavede opgaver og glemte hvor langt den nåede, laver dem alle igen
næste gang.

Rundturen køres nu mod go-imap's egen server i processen: flaget post kommer med,
uflaget gør ikke, og fortsættelsen fra et uid henter kun det nyere. De hidtidige
prøver dækkede de rene dele og ville alle have bestået med selve hentningen i
stykker.

Testen kræver et selvsigneret certifikat, og klienten nægter dem. Account.RootCAs
lader en test TILFØJE tillid; der er stadig ingen måde at fjerne den på. Forskellen
er en søm og ikke et hul — en prøve viser, at samme server afvises uden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:15:50 +02:00
Kristian WindandClaude Opus 5 1e59886d86 feat(mailboxes): en tabel, så I kan have hver sin postkasse — og flere hver
Gmail lå i user_settings under en scope. Den tabel har user_id som hele
primærnøglen, så den rummer præcis én scope pr. person, og dens upsert skriver
scope-navnet om ved konflikt: at lægge IMAP ved siden af ville have slettet
Gmail-forbindelsen uden at sige det. Migration 0011 advarer ordret mod netop det,
og advarslen var tæt på at blive gået i for anden gang.

Formen var forkert alligevel. Man har postkasser, flertal — en arbejds-Gmail og
en privat iCloud er det almindelige tilfælde, ikke det sære. En række hver siger
det. Og den svarer på spørgsmålet, der satte det i gang: hver postkasse hører til
en bruger, så to mennesker på samme instans forbinder hver sin og ser kun sin
egen. Det eneste, der bliver fælles, er Googles OAuth-klient, og det er rigtigt:
den er appen, ikke kontoen.

Kodeord og tokens skrives forseglet. Forseglingen i notifications.go dækker kun
indstillingstabellerne, så denne tabels metoder kalder den i hånden — værd at
vide, før der kommer en tredje hemmelig kolonne.

Gmail bliver hvor den er indtil videre. Den virker, og at lægge en flytning af en
kørende forbindelse i samme ombæring som en ny integration gør begge dele sværere
at efterprøve. Skemaet har allerede plads til den.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 22:10:20 +02:00
Kristian WindandClaude Opus 5 cbe66d75a5 feat(capture): skriv hvor som helst, og se hvad feltet forstår
Release / Build and publish image (push) Failing after 10m4s
To ting fra Doda. Begynder man at skrive et sted i appen, åbner hurtig tilføjelse
med bogstavet allerede i feltet — ingen knap at ramme først, og tanken skal ikke
overleve en rejse hen for at finde et sted at ligge. Og under feltet står nu, hvad
parseren kan læse: #projekt, /sektion, @etiket, p1, en dato. Det stod i
pladsholderen før, som er det ene sted en hjælp ikke kan stå: den forsvinder ved
første anslag, altså præcis når den begynder at være nyttig.

Prisen er de enkelte bogstaver som genveje. Ét tastatur kan ikke både stave "tal
med Anders" og springe til I dag på samme t, og det, man gør halvtreds gange om
dagen, er at fange. Navigation ligger i paletten, som kan mere og koster ét
anslag. Genvejssiden er rettet, så den ikke lover q, t og u.

Undervejs, i den rækkefølge testene fandt dem: reglen var først "er det et
tekstfelt?", hvilket stjal bogstaver fra en fokuseret knap eller liste — den er nu
"har noget som helst fokus?". Hjælpelinjen lå i flowet og skubbede hele listen ned,
når feltet fik fokus, så klik landede en række forkert; tolv tests fangede det.
Den svæver nu, og efter at have svævet ovenpå den første opgave og slugt dens klik
tager den ikke længere imod pegeredskaber.

Og en fejl, der allerede lå der: q slog feltet op på aria-label="Ny opgave", så
genvejen gjorde stille ingenting for enhver, der kørte fladen på engelsk. Feltet
findes nu på et mærke, ikke på en oversat tekst.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 21:45:31 +02:00
Kristian WindandClaude Opus 5 a38a79e9b6 feat(secrets): tokens og kodeord forlader ikke længere huset med en kopi
Gmails refresh-token lå i klartekst i databasen. Det er ikke filen på serveren,
der er problemet, men kopierne af den: sikkerhedskopier kan hentes ned gennem
fladen, så hver eneste kopi, der er taget indtil nu, bar et levende token.

Derfor ligger nøglen uden for databasen — i miljøet, eller i en fil ved siden af
dataene med tilstand 0600. VAPID-parret er præcedens for en hemmelighed,
instansen selv laver og gemmer i basen, men det mønster dur ikke her: en nøgle i
samme fil som det, den låser, er ingen lås. Backup er VACUUM INTO af selve
databasen, så filen følger ikke med.

Kun de navngivne felter forsegles — refresh_token, access_token, password,
client_secret. Resten af klatten er det, man læser, når en postkasse driller:
hvilken mappe, hvilken etiket, hvilken adresse. At kryptere det med ville ikke
købe noget og ville koste muligheden for at kigge.

Ingen migration, og ingen der skal forbinde forfra: en værdi uden mærket læses
som sig selv, og næste skrivning lægger den forseglet på plads. Kolonnen
konverterer sig selv efterhånden som den bruges, i stedet for i én omgang, der
skal have ret om hver eneste række på én gang. Der er en prøve for netop det.

Fladen siger det på siden, hvor man henter kopien — for den dag nøglen betyder
noget, er den dag, man gendanner et andet sted og undrer sig over, at postkassen
er tavs. Runen er på v4; en ny variabel uden et versionsspring er usynlig.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 21:18:48 +02:00
Kristian WindandClaude Opus 5 aabbbac583 feat(imap): en postkasse, der ikke er Gmail
Første del af IMAP-understøttelsen: selve klienten, som sit eget hjørne uden
tråde ud i noget bestående. Den logger ind over TLS, henter de flagede beskeder
nyere end et givet uid, og giver dem tilbage i samme form som Gmail-klienten —
afsender, emne, uddrag — så den, der senere kalder dem, kan få at vide hvor der
skal kigges og ikke hvordan.

Flagede og ikke ulæste: ulæst er en tilstand, ejeren stadig bruger, og en synk,
der brugte den op, ville tage noget fra ham. Det er samme begrundelse som stjerner
på Gmail-siden.

TLS er ikke valgfrit, og der er ingen kontakt til at slå det fra. Det her bærer et
kodeord, der åbner en hel postkasse, og en kontakt til at fjerne beskyttelsen er
en kontakt, der en dag bliver fundet allerede slået fra.

iCloud og enhver almindelig udbyder taler det her. Office 365 gør ikke længere —
Microsoft har lukket for kodeord på IMAP og vil have sit eget OAuth — så det er
ikke en vært, man kan nå herfra, og at lade som om ville kun fejle senere og
mindre tydeligt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 20:21:45 +02:00
Kristian WindandClaude Opus 5 c151d6c98c feat(groups): omdøb med dobbeltklik, og slet der hvor man kan se hvad man sletter
Release / Build and publish image (push) Failing after 9m59s
De to ord "Omdøb" og "Slet" lå oven på gruppens navn, hver gang musen var i
nærheden. De er væk fra sidebjælken. Omdøbning starter nu ved dobbeltklik på
rækken — eller F2, hvis man har den i fokus og ikke vil bruge musen. Sletning er
flyttet til gruppens egen side, hvor projekterne, den ikke tager med sig, står på
skærmen ovenover knappen.

Dobbeltklik på et navn, der også er et link, er ikke gratis. Første forsøg lod
det første klik navigere, og så åbnede siden mens omdøbningsformularen kom frem —
farveprikkerne flyttede sig ud under markøren, hvilket testen fangede som "element
is not stable". Navigationen venter derfor to tiendedele på et muligt andet klik.
Det er under grænsen for et dobbeltklik og over grænsen for at mærke det.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 20:08:24 +02:00
Kristian WindandClaude Opus 5 bd5e86dd03 fix(errors): sig fejlen i en kode, mellemleddet ikke kaster væk
Serveren svarede 502 med en forklaring, og Cloudflare erstattede kroppen med sin
egen side. Bevist i produktion: handleren skrev 122 bytes JSON på 38 ms med
teksten "the panel refused: HTTP 401", og browseren fik en HTML-fejlside. Det
gjaldt også Gmail, som bruger samme hjælper — derfor har fladen hele tiden kunnet
sige "noget gik galt" og ikke andet, selv om serveren vidste præcis hvad der var
sket.

En proxy har lov til at gøre det ved en origin-5xx. Så det er ikke Cloudflare,
der skal laves om: 502 var den pæne kode, ikke den brugbare. Upstream-afvisninger
er nu 422, som går uændret igennem. Navnet StatusUpstreamRefused står ét sted med
grunden, så det ikke bliver rullet tilbage af en, der med rette synes 502 lyder
mere rigtigt.

Fladen læste altid fejlkoden i kroppen og ikke statuskoden, så der var intet at
rette dér.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 19:17:49 +02:00
Kristian WindandClaude Opus 5 bb25b94f96 fix(sidebar): en gruppe står nu hvor projekterne står, og hedder hele sit navn
To fejl i samme række. "Omdøb" og "Slet" lå i flowet og optog deres bredde også
usynlige, så ARBEJDE blev tegnet som "ARBE"; de ligger nu over rækkens højre
ende og dukker op ved hover. Og chevronen sad før navnet og skubbede en gruppe
toogtyve pixels længere ind end de projekter, den indeholder — så en gruppe så
ud til at ligge under dem. Den deler nu plads med gruppens prik: prikken hviler,
chevronen tager over ved hover, og bliver stående mens gruppen er foldet.

De sidste otte pixels sad i a-reglen, som giver hvert link i sidebjælken sin
egen venstrepolstring oven i rækkens.

Prøven, der holdt øje med rækken, målte at alt lå side om side — hvilket var
netop den geometri, der skulle laves om. Den måler nu det, der blev bedt om:
at navnet kan læses helt, og at gruppens prik står præcis hvor et projekts gør.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 18:10:34 +02:00
Kristian WindandClaude Opus 5 dab1827c14 fix(i18n): en engelsk flade sagde "2 opgave(r) hentet"
Release / Build and publish image (push) Failing after 10m6s
Beskeden efter "Fetch now" stod på dansk uanset sprog, og med et "(r)" i stedet
for rigtig bøjning. Den bruger nu t() og plural() som resten.

Vagten burde have fanget den og gjorde det ikke: den krævede et anførselstegn
lige efter app.toast(, og denne stod som en skabelonstreng inde i en ternær hen
over to linjer. Den læser nu hele argumentlisten frem til den matchende
slutparentes, så det er formen på kaldet og ikke tilfældig placering, der afgør,
om en streng bliver set. Sat på prøve ved at lægge den gamle tekst tilbage:
begge strenge blev fanget.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 15:14:37 +02:00
Kristian WindandClaude Opus 5 fe7ce5c853 test(panel): vis hvad genstart-kaldet gør, når panelet tier eller siger nej
Et 502 i produktion så ud som om kaldet til panelet hang. Det gør det ikke: et
panel, der aldrig svarer, giver 202 efter ti sekunder, og et panel, der afviser,
giver 502 med sin egen statuskode i teksten. Begge dele står nu som prøver, så
næste gang mistanken falder på handleren, kan den frikendes uden at gætte.

Hvad der så gav Cloudflares 502 i produktion, er stadig uafklaret.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 15:05:10 +02:00
Kristian WindandClaude Opus 5 a00cf39558 fix(gmail): "hent nu" svarer selv, i stedet for at lade Cloudflare gøre det
Release / Build and publish image (push) Failing after 9m59s
En postkasse, der ikke svarer, holdt hele forespørgslen åben. Efter hundrede
sekunder gav Cloudflare op og sendte sin egen 502-side tilbage — HTML uden
fejlkode, så fladen kunne kun sige "Noget gik galt", og fejlloggen var tom,
fordi handleren aldrig nåede at vende tilbage.

Synk får nu sit eget budget på femogtyve sekunder. Løkken tjekker fristen mellem
hver besked, og hver opgave gemmes efterhånden som den laves, så en overskredet
frist er en kortere kørsel og ikke en tabt. Baggrundsjobbet beholder den fulde
tid; det har ingen, der venter.

Endepunkterne kan nu pege et andet sted hen, så testen kan stille en postkasse
op, der aldrig svarer, og se hvor lang tid der går. Budgettet kan sættes, så
den prøve koster et sekund frem for femogtyve.

Et budget på nul betyder standarden — ikke ingen tid. Uden det ville en
opsætning, der ikke satte feltet, lade fristen udløbe før første kald og gøre
hver eneste synk til et stilfærdigt nul.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 13:43:48 +02:00
43 changed files with 3188 additions and 351 deletions
+11
View File
@@ -17,6 +17,7 @@ import (
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/httpapi"
"github.com/kristianwind/verdande/internal/jobs"
"github.com/kristianwind/verdande/internal/secret"
"github.com/kristianwind/verdande/internal/store"
)
@@ -54,6 +55,15 @@ func run() error {
defer db.Close()
log.Info("database ready", "path", db.Path())
// Before anything reads a setting. The key lives outside the database on
// purpose: backups can be downloaded through the interface, so a token stored
// in the clear leaves with every copy anyone takes.
box, err := secret.Open(cfg.SecretKey, cfg.DataDir)
if err != nil {
return err
}
db.UseSecrets(box)
httpapi.Version = version
api := httpapi.New(cfg, db, log, webAssets(log))
@@ -64,6 +74,7 @@ func run() error {
defer stopJobs()
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
runner.SyncGmail = api.SyncGmail
runner.SyncMailbox = api.SyncMailbox
runner.Start(jobCtx)
srv := &http.Server{
+94
View File
@@ -2477,6 +2477,86 @@ paths:
created: { type: integer }
"502": { $ref: "#/components/responses/Upstream" }
/api/v1/mailboxes:
get:
tags: [integrations]
summary: The mailboxes this person has connected.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
mailboxes:
type: array
items: { $ref: "#/components/schemas/Mailbox" }
post:
tags: [integrations]
summary: Connect a mailbox over IMAP.
description: >
The connection is tested before it is saved, so a mailbox that cannot be
read is refused here rather than failing quietly every ten minutes.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [host, username, password]
properties:
name: { type: string }
host: { type: string, example: "imap.mail.me.com:993" }
username: { type: string }
password: { type: string, description: "An app-specific password on every host worth naming. Stored sealed." }
folder: { type: string, default: INBOX }
responses:
"201":
description: Connected
content:
application/json:
schema: { $ref: "#/components/schemas/Mailbox" }
"400": { $ref: "#/components/responses/BadRequest" }
"409": { description: That mailbox is already connected }
"422": { $ref: "#/components/responses/Upstream" }
/api/v1/mailboxes/{mailboxID}:
delete:
tags: [integrations]
summary: Disconnect a mailbox. The tasks it made stay.
parameters:
- name: mailboxID
in: path
required: true
schema: { type: string }
responses:
"204": { description: Disconnected }
/api/v1/mailboxes/{mailboxID}/sync:
post:
tags: [integrations]
summary: Read one mailbox now.
parameters:
- name: mailboxID
in: path
required: true
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
created: { type: integer }
partial:
type: boolean
description: The run hit its time budget. What it made is kept.
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Upstream" }
/api/v1/ai/settings:
get:
tags: [integrations]
@@ -3082,6 +3162,20 @@ components:
items: { type: string }
schemas:
Mailbox:
type: object
description: One connected mailbox. The password and any token are never returned.
properties:
id: { type: string }
kind: { type: string, enum: [gmail, imap] }
name: { type: string }
host: { type: string }
username: { type: string }
folder: { type: string }
last_uid: { type: integer }
last_sync_at: { type: string, format: date-time }
created_at: { type: string, format: date-time }
Error:
type: object
description: |
+3
View File
@@ -4,6 +4,7 @@ go 1.26.3
require (
github.com/coder/websocket v1.8.15
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/go-chi/chi/v5 v5.3.1
github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0
@@ -15,6 +16,8 @@ require (
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
+37
View File
@@ -7,6 +7,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
@@ -51,18 +57,49 @@ github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
+16
View File
@@ -33,6 +33,10 @@ type Config struct {
GmailClientID string
GmailClientSecret string
// SecretKey seals the tokens and passwords in the database, 32 bytes of base64.
// Empty means a key file beside the data — see internal/secret.
SecretKey string
// UpdateCheck asks GitHub whether a newer release exists. Off unless asked
// for: a self-hosted app that reaches out without being told to has broken
// the deal its operator made by self-hosting.
@@ -50,6 +54,16 @@ type Config struct {
// can read this environment can restart it, which is true of anybody with the
// host anyway. It is never sent to a browser: the settings page is told only
// whether it is set.
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
// front of this server. Configurable so a test can prove the budget is applied
// without spending the real one on every run.
GmailSyncBudget time.Duration
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
// in production, where it means Google.
GmailTokenURL string
GmailAPIURL string
PanelURL string
PanelToken string
PanelServerID string
@@ -85,10 +99,12 @@ func Load() (*Config, error) {
Dev: envBool("VERDANDE_DEV", false),
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
GmailSyncBudget: 25 * time.Second,
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
SecretKey: env("VERDANDE_SECRET_KEY", ""),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
+44 -7
View File
@@ -25,20 +25,50 @@ import (
)
const (
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
tokenEndpoint = "https://oauth2.googleapis.com/token"
apiBase = "https://gmail.googleapis.com/gmail/v1"
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
// Read-only, and only mail. Not profile, not contacts, not send.
Scope = "https://www.googleapis.com/auth/gmail.readonly"
)
// Endpoints is where Google is.
//
// A field rather than a constant so a test can point it at a server it controls.
// There is no other way to exercise what happens when Gmail is slow or refuses —
// and "the sync comes back before whatever sits in front of this server gives up
// on it" is exactly the kind of promise worth a test and impossible to make
// against the real thing.
//
// The zero value is Google, so nothing outside a test has to know it exists.
type Endpoints struct {
Token string
API string
}
func (e Endpoints) token() string {
if e.Token != "" {
return e.Token
}
return defaultTokenEndpoint
}
func (e Endpoints) api() string {
if e.API != "" {
return e.API
}
return defaultAPIBase
}
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
// the instance rather than to a person: one registration, any number of users.
type Config struct {
ClientID string
ClientSecret string
RedirectURL string
// Endpoints is Google unless a test says otherwise.
Endpoints Endpoints
}
func (c Config) Configured() bool {
@@ -128,7 +158,7 @@ func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error)
}
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
@@ -171,6 +201,7 @@ func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
type Client struct {
accessToken string
endpoints Endpoints
http *http.Client
}
@@ -178,6 +209,12 @@ func NewClient(accessToken string) *Client {
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
}
// At points the client at another server. Tests only; the zero value is Google.
func (c *Client) At(e Endpoints) *Client {
c.endpoints = e
return c
}
// Message is the part of a Gmail message a task is made from.
type Message struct {
ID string
@@ -196,7 +233,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
var parsed struct {
EmailAddress string `json:"emailAddress"`
}
if err := c.get(ctx, apiBase+"/users/me/profile", &parsed); err != nil {
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
return "", err
}
return parsed.EmailAddress, nil
@@ -220,7 +257,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
ID string `json:"id"`
} `json:"messages"`
}
if err := c.get(ctx, apiBase+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
return nil, err
}
@@ -251,7 +288,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
} `json:"headers"`
} `json:"payload"`
}
if err := c.get(ctx, apiBase+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
return Message{}, err
}
+11 -2
View File
@@ -585,6 +585,15 @@ func inboxName(locale string) string {
// sync that has been failing for a week is invisible otherwise. The background job
// writes a log line the next restart erases, and nobody is watching the one screen
// that would have said so.
// StatusUpstreamRefused is what this server answers when a service it depends on
// says no. Not 502, though that is what it means: a proxy is entitled to replace
// the body of an origin 5xx with a page of its own, and Cloudflare does. That
// swallowed every explanation this server produced — "gmail: 403 Forbidden",
// "the panel refused: HTTP 401" — and left the interface with nothing to show but
// "something went wrong". A 4xx passes through untouched, and the truth reaching
// the person who has to act on it beats the tidier status code.
const StatusUpstreamRefused = http.StatusUnprocessableEntity
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
s.log.Warn(what, "err", err)
@@ -593,12 +602,12 @@ func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what str
userID = u.ID
}
s.db.RecordError(r.Context(), store.ServerError{
Method: r.Method, Path: r.URL.Path, Status: http.StatusBadGateway,
Method: r.Method, Path: r.URL.Path, Status: StatusUpstreamRefused,
What: what, Message: err.Error(), UserID: userID,
RequestID: middleware.GetReqID(r.Context()),
})
writeError(w, http.StatusBadGateway, code, err.Error())
writeError(w, StatusUpstreamRefused, code, err.Error())
}
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
+5
View File
@@ -58,6 +58,11 @@ const (
// read "something went wrong", and had nowhere to go. The interface wraps the
// message in one that says where it came from.
CodeGmailFailed = "gmail_failed"
// And the same for a mailbox read over IMAP. A host's refusal is the whole
// diagnosis — a wrong app password, a host that is not listening, a folder that
// is not there — and it says it in its own words.
CodeMailboxFailed = "mailbox_failed"
)
// errLastAdmin is returned to the caller that already wrote the response, so it
+96 -52
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"time"
@@ -10,6 +11,11 @@ import (
"github.com/kristianwind/verdande/internal/store"
)
// defaultGmailSyncBudget bounds one "fetch now" when nothing else says otherwise.
// Well under the hundred seconds a proxy is willing to wait, so this server is the
// one that answers a slow mailbox.
const defaultGmailSyncBudget = 25 * time.Second
// gmailConfig is the OAuth client this instance authorises with.
//
// The environment wins when it is set. A value in the environment is a deliberate
@@ -17,11 +23,20 @@ import (
// 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.
// gmailClient is the API client, pointed wherever the config says Google is.
func (s *Server) gmailClient(accessToken string) *gmail.Client {
return gmail.NewClient(accessToken).At(gmail.Endpoints{
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
})
}
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
cfg := gmail.Config{
ClientID: s.cfg.GmailClientID,
ClientSecret: s.cfg.GmailClientSecret,
RedirectURL: s.cfg.GmailRedirectURL(),
Endpoints: endpoints,
}
if cfg.ClientID != "" && cfg.ClientSecret != "" {
return cfg
@@ -153,7 +168,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
// 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())
email, err := s.gmailClient(token.AccessToken).Profile(r.Context())
if err != nil {
s.log.Warn("gmail profile", "err", err)
}
@@ -164,15 +179,27 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
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, "clear gmail handshake", err)
return
}
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)
return
}
@@ -185,8 +212,35 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
created, err := s.SyncGmail(r.Context(), user)
// A budget of its own, well under what sits in front of this server.
//
// The sync fetches up to twenty-five messages one at a time, each with its own
// thirty-second timeout — so a slow mailbox can hold the request open for
// minutes. Cloudflare gives up at a hundred seconds and answers with its own
// 502 page, which is HTML: the browser gets no JSON, no error code and no
// message, and shows "something went wrong". Nothing reaches the error log
// either, because this handler never returned.
//
// Whatever was created by the deadline is kept: every task is committed as it
// is made, so a short budget is a shorter run rather than a lost one. The
// background job keeps the full one; it has nobody waiting.
// An unset budget is not a budget of nothing: a zero here would expire the
// context before the first call and make every sync a silent no-op.
budget := s.cfg.GmailSyncBudget
if budget <= 0 {
budget = defaultGmailSyncBudget
}
ctx, cancel := context.WithTimeout(r.Context(), budget)
defer cancel()
created, err := s.SyncGmail(ctx, user)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
// Not a failure: the mailbox is slow or busy, some tasks were made, and
// the rest arrive on the next run ten minutes from now.
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
return
}
s.upstream(w, r, CodeGmailFailed, "sync gmail", err)
return
}
@@ -198,48 +252,42 @@ 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
// 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")
box, err := s.db.MailboxOfKind(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 == "" {
if box == nil || box.RefreshToken == "" {
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 == "" {
return 0, nil // connected but no trigger chosen
}
refresh := box.RefreshToken
// 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() {
access := box.AccessToken
if access == "" || box.ExpiresAt.Before(time.Now()) {
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 {
box.AccessToken = access
box.ExpiresAt = token.ExpiresAt
if err := s.db.SaveMailbox(ctx, box); err != nil {
return 0, err
}
}
client := gmail.NewClient(access)
client := s.gmailClient(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{})
_ = s.db.DeleteMailbox(ctx, user.ID, box.ID)
}
return 0, err
}
@@ -248,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
// 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
}
}
for _, id := range box.Seen {
seen[id] = true
}
inbox, err := s.db.InboxID(ctx, user.ID)
if err != nil {
return 0, err
}
projectID := str("project_id")
projectID := box.ProjectID
if projectID == "" {
projectID = inbox
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
@@ -272,6 +316,11 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
if seen[id] {
continue
}
// A spent budget ends the run rather than turning into twenty-five failed
// fetches logged one at a time.
if ctx.Err() != nil {
return created, ctx.Err()
}
msg, err := client.Get(ctx, id)
if err != nil {
s.log.Warn("gmail get message", "err", err, "id", id)
@@ -305,15 +354,15 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
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))
keep := make([]string, 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 {
box.Seen = keep
box.LastSyncAt = time.Now()
if err := s.db.SaveMailbox(ctx, box); err != nil {
return created, err
}
}
@@ -436,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
// 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"))
func (s *Server) gmailProfile(ctx context.Context, box *store.Mailbox) (string, error) {
access := box.AccessToken
if access == "" || box.ExpiresAt.Before(time.Now()) {
token, err := s.gmailConfig(ctx).Refresh(ctx, box.RefreshToken)
if err != nil {
return "", err
}
access = token.AccessToken
settings["access_token"] = access
settings["expires_at"] = token.ExpiresAt.Unix()
box.AccessToken = access
box.ExpiresAt = token.ExpiresAt
}
email, err := gmail.NewClient(access).Profile(ctx)
email, err := s.gmailClient(access).Profile(ctx)
if err != nil {
return "", err
}
settings["email"] = email
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
box.Name = email
box.Username = email
if err := s.db.SaveMailbox(ctx, box); err != nil {
return "", err
}
return email, nil
+94 -4
View File
@@ -1,9 +1,12 @@
package httpapi
import (
"github.com/kristianwind/verdande/internal/store"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
)
@@ -26,6 +29,8 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GmailTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
})
ts.bootstrap(t)
@@ -34,14 +39,14 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
t.Fatal(err)
}
// Connected, with a refresh token that will be refused.
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
"refresh_token": "spent", "trigger": "starred",
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
UserID: user.ID, Kind: "gmail", RefreshToken: "spent", Trigger: "starred",
}); err != nil {
t.Fatal(err)
}
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
if resp.StatusCode != http.StatusBadGateway {
if resp.StatusCode != StatusUpstreamRefused {
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
resp.StatusCode)
}
@@ -64,7 +69,92 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
if row["what"] != "sync gmail" {
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
}
if row["status"] != float64(http.StatusBadGateway) {
if row["status"] != float64(StatusUpstreamRefused) {
t.Errorf("status = %v, want 502", row["status"])
}
}
// A slow mailbox must not hold the request open until something in front of this
// server gives up on it.
//
// This is what was actually happening in production: each Gmail call has its own
// thirty-second timeout, and the sync makes up to twenty-five of them one after the
// other. Cloudflare stopped waiting at a hundred seconds and answered with its own
// 502 page — HTML, so the browser got no code, no message and no error-log row, and
// showed "something went wrong". The handler had never returned.
func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
// A Gmail that never answers.
blocked := make(chan struct{})
defer close(blocked)
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-blocked:
case <-r.Context().Done():
}
}))
defer google.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.GmailClientID = "test-client"
cfg.GmailClientSecret = "test-secret"
cfg.GmailTokenURL = google.URL + "/token"
cfg.GmailAPIURL = google.URL
// A second rather than the real twenty-five: what is being tested is that a
// budget is applied at all, and spending the production one on every run
// would put half a minute into the suite to learn the same thing.
cfg.GmailSyncBudget = time.Second
})
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// A token that has not expired, so the run goes straight to the message list
// rather than stopping at a refresh.
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
// Its own client, because the shared one gives up after ten seconds — which is
// shorter than the budget being tested and would prove nothing either way.
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/gmail/sync", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
started := time.Now()
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
if err != nil {
t.Fatalf("the request never came back (%v after %s); the sync has no budget of "+
"its own, and whatever sits in front of this server answers for it",
err, time.Since(started).Round(time.Second))
}
defer resp.Body.Close()
took := time.Since(started)
// Cloudflare stops waiting at a hundred seconds. Anything close to that is the
// bug, whatever status eventually arrives.
// Comfortably over the one-second budget and far under any proxy's patience.
if took > 10*time.Second {
t.Errorf("the sync took %s; the budget is not being applied",
took.Round(time.Second))
}
if resp.StatusCode >= 500 && resp.StatusCode != StatusUpstreamRefused {
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
}
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u
}
+44 -28
View File
@@ -288,7 +288,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
if err != nil {
s.log.Warn("ai split", "err", err)
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
@@ -352,7 +352,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
if err != nil {
s.log.Warn("ai summary", "err", err)
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
@@ -370,27 +370,28 @@ type gmailSettings struct {
}
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 {
s.internal(w, r, "gmail settings", err)
s.internal(w, r, "gmail mailbox", err)
return
}
str := func(key string) string {
v, _ := values[key].(string)
return v
if box == nil {
writeJSON(w, http.StatusOK, gmailSettings{})
return
}
// The mailbox address, fetched again if it is missing.
//
// It is stored at connect time, and the call that fetches it can fail — a
// slow token, a scope Google had not applied yet. That failure used to be a
// log line and an empty string, so the page said "an unknown account" and went
// on saying it forever: the only way back was to disconnect and reconnect,
// which nobody would guess. Asking again here costs one request on a page
// nobody opens often, and it means the wrong answer lasts until the next look
// rather than until somebody reconnects.
email := str("email")
if email == "" && str("refresh_token") != "" {
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
// It is stored at connect time, and the call that fetches it can fail — a slow
// token, a scope Google had not applied yet. That failure used to be a log line
// and an empty string, so the page said "an unknown account" and went on saying
// it forever: the only way back was to disconnect and reconnect, which nobody
// would guess. Asking again here costs one request on a page nobody opens
// often, and it means the wrong answer lasts until the next look.
email := box.Username
if email == "" && box.RefreshToken != "" {
if fetched, err := s.gmailProfile(r.Context(), box); err == nil {
email = fetched
} else {
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{
Connected: str("refresh_token") != "",
Connected: box.RefreshToken != "",
Email: email,
Trigger: str("trigger"),
Label: str("label"),
Trigger: box.Trigger,
Label: box.Label,
})
}
@@ -412,27 +413,42 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
}
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 {
s.internal(w, r, "gmail settings", err)
s.internal(w, r, "gmail mailbox", err)
return
}
values["trigger"] = req.Trigger
values["label"] = req.Label
if box == nil {
// 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)
return
}
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) {
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
s.internal(w, r, "disconnect gmail", err)
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)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
+219
View File
@@ -0,0 +1,219 @@
package httpapi
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/imap"
"github.com/kristianwind/verdande/internal/store"
)
// defaultMailboxSyncBudget bounds one read of one mailbox when somebody is waiting.
// The same reasoning as Gmail's: a mailbox that has stopped answering must not
// hold a request open until whatever sits in front of this server answers for it.
const defaultMailboxSyncBudget = 25 * time.Second
func (s *Server) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
list, err := s.db.Mailboxes(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "list mailboxes", err)
return
}
if list == nil {
list = []store.Mailbox{}
}
writeJSON(w, http.StatusOK, map[string]any{"mailboxes": list})
}
// handleAddMailbox connects one, after proving it can actually be read.
//
// Tested before it is saved, not after: a mailbox that was accepted and then
// silently fails every ten minutes is worse than one that refused at the door,
// where the person is still holding the password they just typed.
func (s *Server) handleAddMailbox(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
Folder string `json:"folder"`
}
if err := decodeJSON(w, r, &body); err != nil {
return
}
body.Host = strings.TrimSpace(body.Host)
body.Username = strings.TrimSpace(body.Username)
if body.Host == "" || body.Username == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, CodeValidation,
"a mailbox needs a host, a username and a password")
return
}
// A host with no port is the mistake everyone makes once, and 993 is the answer
// for every host worth naming.
if !strings.Contains(body.Host, ":") {
body.Host += ":993"
}
account := imap.Account{
Host: body.Host, Username: body.Username,
Password: body.Password, Folder: body.Folder,
}
client, err := imap.Dial(account)
if err != nil {
s.upstream(w, r, CodeMailboxFailed, "connect mailbox", err)
return
}
client.Close()
user := userFrom(r.Context())
m := &store.Mailbox{
UserID: user.ID, Kind: "imap", Name: strings.TrimSpace(body.Name),
Host: body.Host, Username: body.Username,
Password: body.Password, Folder: body.Folder,
}
if m.Name == "" {
m.Name = body.Username
}
if err := s.db.SaveMailbox(r.Context(), m); err != nil {
// The unique index. Saying which mailbox is already here beats "constraint
// failed", which is true and useless.
if strings.Contains(err.Error(), "UNIQUE") {
writeError(w, http.StatusConflict, CodeValidation,
fmt.Sprintf("%s on %s is already connected", body.Username, body.Host))
return
}
s.internal(w, r, "save mailbox", err)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
if err := s.db.DeleteMailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID")); err != nil {
s.internal(w, r, "delete mailbox", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleSyncMailbox reads one mailbox now, within a budget of its own.
func (s *Server) handleSyncMailbox(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
m, err := s.db.Mailbox(r.Context(), user.ID, chi.URLParam(r, "mailboxID"))
if err != nil {
s.internal(w, r, "read mailbox", err)
return
}
if m == nil {
writeError(w, http.StatusNotFound, CodeNotFound, "no such mailbox")
return
}
ctx, cancel := context.WithTimeout(r.Context(), defaultMailboxSyncBudget)
defer cancel()
created, err := s.SyncMailbox(ctx, user, m)
if err != nil {
// Whatever was made by the deadline is kept: each task is committed as it
// is made, so a short budget is a shorter run rather than a lost one.
if errors.Is(err, context.DeadlineExceeded) {
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
return
}
s.upstream(w, r, CodeMailboxFailed, "sync mailbox", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"created": created})
}
// SyncMailbox turns the flagged mail newer than the last run into tasks.
//
// Exported because the background job calls it too, on the same terms and without
// a budget: nobody is waiting there.
func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mailbox) (int, error) {
client, err := imap.Dial(imap.Account{
Host: m.Host, Username: m.Username, Password: m.Password, Folder: m.Folder,
})
if err != nil {
return 0, err
}
defer client.Close()
messages, searched, err := client.Since(m.LastUID, 25)
if err != nil {
return 0, err
}
if len(messages) == 0 {
return 0, nil
}
projectID, err := s.db.InboxID(ctx, user.ID)
if err != nil {
return 0, err
}
created := 0
highest := m.LastUID
for _, msg := range messages {
if ctx.Err() != nil {
// What was made so far is already committed and counted, and the marker
// below has to be written for it: a run that created tasks and forgot how
// far it got would make them all again on the next pass.
break
}
subject := msg.Subject
if subject == "" {
subject = "(uden emne)"
}
content := subject
if msg.From != "" {
content = msg.From + ": " + subject
}
task := &store.Task{
ProjectID: projectID,
Content: content,
Description: msg.Snippet,
Priority: 4,
CreatedBy: user.ID,
}
if err := s.db.CreateTask(ctx, task, nil); err != nil {
s.log.Warn("mailbox create task", "err", err, "mailbox", m.ID)
continue
}
created++
if msg.UID > highest {
highest = msg.UID
}
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
// so a cancelled request still records what it did.
if highest > m.LastUID {
if err := s.db.MarkMailboxRead(context.WithoutCancel(ctx), m.ID, highest, time.Now()); err != nil {
s.log.Warn("mark mailbox read", "err", err, "mailbox", m.ID)
}
}
if ctx.Err() != nil {
return created, ctx.Err()
}
return created, nil
}
+17 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"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)
defer cancel()
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
// safe-restart rather than restart, and the difference is not politeness.
//
// 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 {
s.internal(w, r, "build panel request", err)
return
}
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)
if err != nil {
@@ -96,7 +110,7 @@ func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request)
if resp.StatusCode >= 400 {
s.log.Error("panel refused the restart", "status", resp.StatusCode)
writeError(w, http.StatusBadGateway, CodeInternal,
writeError(w, StatusUpstreamRefused, CodeInternal,
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
return
}
+50 -97
View File
@@ -1,131 +1,84 @@
package httpapi
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/config"
)
// Restarting is administrators only, and sessions only. A leaked API token that
// could restart the server is a denial of service in one request.
func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
ts := newTestServer(t)
// A panel that never answers must not become this server's problem. The container
// is being asked to stop, so the reply may legitimately never arrive — but the
// request has to end either way, or whatever sits in front answers for it.
func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
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()
}))
defer panel.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.PanelURL = panel.URL
cfg.PanelToken = "test-token"
cfg.PanelServerID = "server-1"
})
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
method := "GET"
if path != "/api/v1/panel" {
method = "POST"
}
if resp, _ := other.do(t, method, path, nil); resp.StatusCode != http.StatusForbidden {
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
}
}
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
// Its own client: the shared one gives up at ten seconds, which is the very
// budget under test, so it could not tell a pass from a failure.
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
started := time.Now()
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
if err != nil {
t.Fatal(err)
t.Fatalf("the request never came back (%v after %s)", err, time.Since(started).Round(time.Second))
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("restart with an admin's API token: status %d, want 403", resp.StatusCode)
}
}
defer resp.Body.Close()
took := time.Since(started)
// Unconfigured, it says which of the three settings are missing rather than
// "not configured" — an operator should not have to go and find out which.
func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
if status["configured"] != false {
t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
if took > 20*time.Second {
t.Errorf("the restart took %s; that is long enough for a proxy to answer first",
took.Round(time.Second))
}
missing, _ := status["missing"].([]any)
if len(missing) != 3 {
t.Errorf("missing = %v, want all three settings named", missing)
}
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
}
if body["error"] == nil {
t.Error("the refusal says nothing about why")
}
}
// Configured, it calls the panel with a bearer token and reports what came back.
func TestARestartCallsThePanelWithItsToken(t *testing.T) {
var gotPath, gotAuth string
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer panel.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.PanelURL = panel.URL
cfg.PanelToken = "hemmelig"
cfg.PanelServerID = "abc-123"
})
ts.bootstrap(t)
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
if status["configured"] != true {
t.Fatalf("configured = %v with all three set", status["configured"])
}
// The token is a credential and must never reach a browser.
for key, value := range status {
if s, ok := value.(string); ok && s == "hemmelig" {
t.Errorf("the panel token was sent to the client in %q", key)
}
}
resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("restart: status %d, want 202", resp.StatusCode)
}
if gotPath != "/api/servers/abc-123/restart" {
t.Errorf("called %q", gotPath)
}
if gotAuth != "Bearer hemmelig" {
t.Errorf("Authorization = %q", gotAuth)
t.Errorf("status %d, want 202 — a panel that went quiet is the expected case, "+
"not a failure", resp.StatusCode)
}
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
}
// A panel that refuses is reported as the panel refusing, not as this server
// breaking: the fix is a token with control of that server, and saying "internal
// error" would send somebody looking in the wrong place.
func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
// 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) {
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusForbidden)
}))
defer panel.Close()
ts := newTestServerWith(t, func(cfg *config.Config) {
cfg.PanelURL = panel.URL
cfg.PanelToken = "forkert"
cfg.PanelServerID = "abc-123"
cfg.PanelToken = "wrong-token"
cfg.PanelServerID = "server-1"
})
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
if resp.StatusCode != http.StatusBadGateway {
t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
if resp.StatusCode != StatusUpstreamRefused {
t.Errorf("status %d, want 502: %s", resp.StatusCode, body)
}
if msg, _ := body["error"].(string); msg == "" {
t.Error("the refusal does not say what to check")
if msg, _ := body["error"].(string); !strings.Contains(msg, "403") {
t.Errorf("the reply does not say what the panel answered: %v", body)
}
}
+10
View File
@@ -260,6 +260,16 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/version", s.handleVersion)
// Mailboxes read over IMAP — iCloud, Fastmail, any ordinary host. Each
// belongs to the person who connected it, so there is no admin gate
// here and no shared registration: the credential is theirs.
r.Route("/mailboxes", func(r chi.Router) {
r.Get("/", s.handleListMailboxes)
r.Post("/", s.handleAddMailbox)
r.Delete("/{mailboxID}", s.handleDeleteMailbox)
r.Post("/{mailboxID}/sync", s.handleSyncMailbox)
})
r.Route("/gmail", func(r chi.Router) {
r.Get("/", s.handleGetGmail)
r.Put("/", s.handleSetGmail)
+41 -8
View File
@@ -28,11 +28,13 @@ import (
// the app, which is the worst possible set to leave in one language, because it is
// the text somebody reads in the half-second before they agree to lose something.
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
// The argument of one of the three, quoted or backticked. Deliberately narrow:
// a general scan of every string literal in a Svelte script block would drown
// in class names, keys and query fragments.
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(\s*['` + "`" + `]([^'` + "`" + `]{4,})['` + "`" + `]`)
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
// Every string literal in the argument list of one of the three, not just one
// sitting flush against the paren. A toast written as a ternary over two lines
// hid Danish from the earlier version of this guard for a whole release.
// Deliberately still narrow: a general scan of every literal in a script block
// would drown in class names, keys and query fragments.
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(`)
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|gemmes|hentet|hentes|beskeder|opgave|opgaver|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
var found []string
root := filepath.Join("..", "..", "web", "src")
@@ -51,9 +53,12 @@ func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
return err
}
rel, _ := filepath.Rel(root, path)
for _, m := range call.FindAllStringSubmatch(string(body), -1) {
if danish.MatchString(m[1]) {
found = append(found, rel+": "+m[1])
src := string(body)
for _, loc := range call.FindAllStringIndex(src, -1) {
for _, lit := range literalsInCall(src[loc[1]:]) {
if danish.MatchString(lit) {
found = append(found, rel+": "+lit)
}
}
}
return nil
@@ -70,6 +75,34 @@ func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
}
}
// literalsInCall returns the string literals in one argument list, given the text
// just past its opening paren. It stops at the matching close paren so the next
// statement's strings are somebody else's problem.
func literalsInCall(src string) []string {
var out []string
depth := 1
for i := 0; i < len(src); i++ {
switch c := src[i]; c {
case '(':
depth++
case ')':
if depth--; depth == 0 {
return out
}
case '\'', '"', '`':
end := strings.IndexByte(src[i+1:], c)
if end < 0 {
return out
}
if lit := src[i+1 : i+1+end]; len(lit) >= 4 {
out = append(out, lit)
}
i += end + 1
}
}
return out
}
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
// Text nodes of three characters or more, and the three attributes a person
// reads. Anything inside {…} is already an expression — usually a t() call —
+297
View File
@@ -0,0 +1,297 @@
// Package imap reads a mailbox over IMAP and hands back the messages that should
// become tasks.
//
// This exists next to internal/gmail rather than inside it because the two agree
// on almost nothing. Gmail is an HTTP API with OAuth, labels and stars; IMAP is a
// connection with a password, folders and flags. What they do share is the shape
// of the answer — a handful of messages, each with a sender, a subject and enough
// of a body to read — and that shape is Message here, deliberately identical to
// what the Gmail client returns, so the caller can be told where to look and not
// how to look.
//
// iCloud, Fastmail and any ordinary host speak this. Office 365 no longer accepts
// a password here at all — it wants OAuth of its own — so it is not a host you can
// reach with this package, and pretending otherwise would only fail later and
// less clearly.
package imap
import (
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"strings"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-message"
)
// Message is one mail, reduced to what a task needs.
type Message struct {
UID uint32
From string
Subject string
Snippet string
Date time.Time
}
// Account is one mailbox to read.
type Account struct {
Host string // "imap.mail.me.com:993"
Username string
Password string // an app-specific password on every host worth naming
Folder string // "INBOX" when empty
// RootCAs adds a certificate authority to trust, on top of the system's. It
// exists so a test can put a real IMAP server behind a self-signed certificate
// and still be refused by everything else — the field can only widen trust,
// never switch verification off, which is the difference between a seam and a
// hole. Nil in production, where the system's roots are the answer.
RootCAs *x509.CertPool
}
// Dial opens a TLS connection and logs in. The caller closes it.
//
// TLS is not optional and there is no flag to make it so: this carries a password
// that unlocks a person's whole mailbox, and a switch for turning that protection
// off is a switch that will one day be found already flipped.
func Dial(a Account) (*Client, error) {
host, _, err := net.SplitHostPort(a.Host)
if err != nil {
return nil, fmt.Errorf("host must include a port, like imap.mail.me.com:993: %w", err)
}
c, err := imapclient.DialTLS(a.Host, &imapclient.Options{
TLSConfig: &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
RootCAs: a.RootCAs,
},
})
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", a.Host, err)
}
if err := c.Login(a.Username, a.Password).Wait(); err != nil {
c.Close()
// Deliberately not repeating the server's own words here. A bad password is
// the overwhelmingly likely cause, and hosts answer it with everything from
// "AUTHENTICATIONFAILED" to a sentence about web login being required.
return nil, fmt.Errorf("%s refused the login for %s: %w", a.Host, a.Username, err)
}
folder := a.Folder
if folder == "" {
folder = "INBOX"
}
return &Client{c: c, folder: folder}, nil
}
// Client is a logged-in connection to one mailbox.
type Client struct {
c *imapclient.Client
folder string
}
// Close ends the session politely, then hangs up regardless.
func (c *Client) Close() error {
if err := c.c.Logout().Wait(); err != nil {
return c.c.Close()
}
return c.c.Close()
}
// Since returns the flagged messages in the folder newer than uid.
//
// Flagged, not unread: unread is a state the person is still using, and a sync
// 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
// Gmail side reads stars.
// 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 {
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
// rather than an error, and that is not a failure worth reporting upwards.
set := imap.UIDSetNum()
set.AddRange(imap.UID(uid+1), 0)
criteria := &imap.SearchCriteria{
UID: []imap.UIDSet{set},
Flag: []imap.Flag{imap.FlagFlagged},
}
found, err := c.c.UIDSearch(criteria, nil).Wait()
if err != nil {
return nil, 0, fmt.Errorf("search %s: %w", c.folder, err)
}
uids := found.AllUIDs()
if len(uids) == 0 {
return nil, uid, nil
}
if limit > 0 && len(uids) > limit {
// The newest ones. A mailbox flagged over years would otherwise make the
// first sync the largest one, which is the run most likely to be watched.
uids = uids[len(uids)-limit:]
}
want := imap.UIDSetNum(uids...)
fetch := &imap.FetchOptions{
Envelope: true,
UID: 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()
if err != nil {
return nil, 0, fmt.Errorf("fetch from %s: %w", c.folder, err)
}
out := make([]Message, 0, len(msgs))
for i, m := range msgs {
if m.Envelope == nil {
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{
UID: msgUID,
From: address(m.Envelope.From),
Subject: strings.TrimSpace(m.Envelope.Subject),
Snippet: snippet(m),
Date: m.Envelope.Date,
})
}
return out, highest, nil
}
// address renders the first sender as "Name <addr>", or just the address.
func address(list []imap.Address) string {
if len(list) == 0 {
return ""
}
a := list[0]
addr := a.Addr()
if a.Name == "" {
return addr
}
return a.Name + " <" + addr + ">"
}
// snippet is the first readable text in the mail.
//
// It walks the MIME structure rather than taking the body as bytes. text/plain is
// preferred and HTML is the fallback with its tags stripped — crudely, because
// this is the line under a task's title and not a rendering engine. The mail
// itself stays where it is.
const snippetLimit = 400
func snippet(m *imapclient.FetchMessageBuffer) string {
for _, body := range m.BodySection {
if text := readable(body.Bytes); text != "" {
return cut(text)
}
}
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
}
+77
View File
@@ -0,0 +1,77 @@
package imap
import (
"strings"
"testing"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
)
// A host without a port is the mistake everyone makes once, and the error a TLS
// dial gives for it says nothing useful about which of the two fields is wrong.
func TestAHostWithoutAPortSaysSo(t *testing.T) {
_, err := Dial(Account{Host: "imap.mail.me.com", Username: "kw", Password: "x"})
if err == nil {
t.Fatal("a host with no port was accepted")
}
if !strings.Contains(err.Error(), "port") {
t.Errorf("the error does not mention the port: %v", err)
}
}
func TestASenderIsRenderedForReading(t *testing.T) {
for _, c := range []struct {
name string
in []imap.Address
want string
}{
{"nobody", nil, ""},
{"bare address", []imap.Address{{Mailbox: "kw", Host: "nolimit.dk"}}, "[email protected]"},
{"with a name", []imap.Address{{Name: "Kristian Wind", Mailbox: "kw", Host: "nolimit.dk"}},
"Kristian Wind <[email protected]>"},
// The first sender wins. A mail from two people is rare enough that naming
// one is better than a task titled with a list.
{"two senders", []imap.Address{
{Mailbox: "first", Host: "example.dk"},
{Mailbox: "second", Host: "example.dk"},
}, "[email protected]"},
} {
t.Run(c.name, func(t *testing.T) {
if got := address(c.in); got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
// The snippet is what shows under a task's title, so its only real duty is to end.
func TestASnippetIsTrimmedAndBounded(t *testing.T) {
long := strings.Repeat("a", snippetLimit*2)
m := &imapclient.FetchMessageBuffer{
BodySection: []imapclient.FetchBodySectionBuffer{
{Bytes: []byte(" ")},
{Bytes: []byte(" " + long + " ")},
},
}
got := snippet(m)
if strings.HasPrefix(got, " ") || strings.HasSuffix(got, " ") {
t.Errorf("the snippet is not trimmed: %q", got[:20])
}
// An empty part must not win over the one that has the text.
if !strings.HasPrefix(got, "aaa") {
t.Errorf("an empty body part was preferred: %q", got)
}
if len([]rune(got)) > snippetLimit+1 {
t.Errorf("the snippet runs to %d characters", len([]rune(got)))
}
if !strings.HasSuffix(got, "…") {
t.Error("a cut snippet does not say that it was cut")
}
}
func TestAnEmptyMailboxIsNotAnError(t *testing.T) {
if got := snippet(&imapclient.FetchMessageBuffer{}); got != "" {
t.Errorf("a mail with no body parts gave %q", got)
}
}
+264
View File
@@ -0,0 +1,264 @@
package imap
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"strings"
"testing"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapserver"
"github.com/emersion/go-imap/v2/imapserver/imapmemserver"
)
// The round trip, against a real IMAP server rather than a stub of one. The
// earlier tests cover the pure parts — what a sender renders as, where a snippet
// is cut — and would all have passed with the fetch itself broken.
func TestFlaggedMailComesBackAsMessages(t *testing.T) {
addr, pool := serveMailbox(t, []mail{
{subject: "Faktura 4711", from: "Anders Bo <[email protected]>", body: "Vedhæftet.", flagged: true},
{subject: "Nyhedsbrev", from: "[email protected]", body: "Læs mere", flagged: false},
{subject: "Kontrakt", from: "[email protected]", body: "Til underskrift", 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)
}
// Two of the three. The newsletter is unflagged, and flagging is the whole
// signal: unread is a state the owner is still using.
if len(msgs) != 2 {
t.Fatalf("got %d messages, want the 2 flagged ones", len(msgs))
}
if msgs[0].Subject != "Faktura 4711" {
t.Errorf("first subject is %q", msgs[0].Subject)
}
if !strings.Contains(msgs[0].From, "[email protected]") {
t.Errorf("first sender is %q", msgs[0].From)
}
if msgs[0].UID == 0 {
t.Error("no uid came back, so a second run would fetch everything again")
}
// And reading on from a marker returns only what is newer, which is the whole
// of the deduplication.
rest, _, err := client.Since(msgs[0].UID, 25)
if err != nil {
t.Fatal(err)
}
if len(rest) != 1 || rest[0].Subject != "Kontrakt" {
t.Fatalf("reading on from uid %d gave %d messages", msgs[0].UID, len(rest))
}
}
func TestAWrongPasswordIsRefusedClearly(t *testing.T) {
addr, pool := serveMailbox(t, nil)
_, err := Dial(Account{Host: addr, Username: "kw", Password: "forkert", RootCAs: pool})
if err == nil {
t.Fatal("a wrong password was accepted")
}
// Hosts say everything from AUTHENTICATIONFAILED to a sentence about the web,
// so the reply names the host and the user rather than repeating their words.
if !strings.Contains(err.Error(), "kw") {
t.Errorf("the error does not say whose login was refused: %v", err)
}
}
// A certificate this process signed itself must not be enough on its own.
func TestAnUntrustedCertificateIsRefused(t *testing.T) {
addr, _ := serveMailbox(t, nil)
if _, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig"}); err == nil {
t.Fatal("a self-signed certificate was accepted without being trusted")
}
}
type mail struct {
subject, from, body string
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
// and the pool that trusts it.
func serveMailbox(t *testing.T, mails []mail) (string, *x509.CertPool) {
t.Helper()
cert, pool := selfSigned(t)
memServer := imapmemserver.New()
user := imapmemserver.NewUser("kw", "hemmelig")
if err := user.Create("INBOX", nil); err != nil {
t.Fatal(err)
}
memServer.AddUser(user)
for _, m := range mails {
flags := []imap.Flag{}
if m.flagged {
flags = append(flags, imap.FlagFlagged)
}
raw := m.raw
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{
Flags: flags,
}); err != nil {
t.Fatal(err)
}
}
srv := imapserver.New(&imapserver.Options{
NewSession: func(_ *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
return memServer.NewSession(), nil, nil
},
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
InsecureAuth: false,
})
t.Cleanup(func() { srv.Close() })
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
if err != nil {
t.Fatal(err)
}
go srv.Serve(ln)
// "localhost", not the loopback address: the certificate names a host, and the
// client checks that name — as it should.
_, port, _ := net.SplitHostPort(ln.Addr().String())
return net.JoinHostPort("localhost", port), pool
}
func selfSigned(t *testing.T) (tls.Certificate, *x509.CertPool) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IsCA: true,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatal(err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(certPEM)
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: "[email protected]", body: "en", flagged: true},
{subject: "To", from: "[email protected]", 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: [email protected]\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)
}
}
+44 -13
View File
@@ -37,6 +37,10 @@ type Runner struct {
// startable from main.
SyncGmail func(ctx context.Context, user *store.User) (int, error)
// SyncMailbox reads one IMAP mailbox. Supplied by the HTTP layer for the same
// reason as SyncGmail: it owns turning a message into a task.
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
wg sync.WaitGroup
}
@@ -60,7 +64,7 @@ func (r *Runner) Start(ctx context.Context) {
// 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
// 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)
}
func (r *Runner) Wait() { r.wg.Wait() }
@@ -247,26 +251,53 @@ func rotateBackups(cfg *config.Config, log *slog.Logger) error {
// --- 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
// token or an expired refresh is a problem for that person alone.
func (r *Runner) syncGmailAccounts(ctx context.Context) error {
if r.SyncGmail == nil {
// One sweep rather than two, now that both live in the same table. 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 {
if r.SyncMailbox == nil {
return nil
}
users, err := r.db.UsersWithGmail(ctx)
userIDs, err := r.db.UsersWithMailboxes(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)
for _, id := range userIDs {
user, err := r.db.UserByID(ctx, id)
if err != nil || user == nil {
continue
}
if created > 0 {
r.log.Info("tasks created from gmail", "user", user.ID, "count", created)
boxes, err := r.db.Mailboxes(ctx, id)
if err != nil {
r.log.Warn("list mailboxes", "err", err, "user", id)
continue
}
for i := range boxes {
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 {
r.log.Warn("mailbox sync", "err", err, "user", id,
"kind", boxes[i].Kind, "mailbox", boxes[i].ID)
continue
}
if created > 0 {
r.log.Info("tasks created from a mailbox", "user", id,
"kind", boxes[i].Kind, "count", created)
}
}
}
return nil
+141
View File
@@ -0,0 +1,141 @@
// Package secret seals the values that must not travel with a backup.
//
// The reason this exists is not the database on the server — it is the copies of
// it. Backups can be downloaded through the web interface, and every one taken so
// far carried a live Gmail refresh token in plain text. Anyone who ends up with a
// copy ends up with the mailbox.
//
// So the key must not live in the database. That rules out the pattern used for
// the VAPID pair, which the instance generates and stores in the same file it
// protects: a key kept beside what it locks is not a lock. It comes from the
// environment, or from a file next to the data that the backup does not include.
package secret
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// prefix marks a sealed value, so a mixed column can be read without guessing and
// so the scheme can be replaced later without having to.
const prefix = "v1:"
// KeyFile is where the key lands when the environment does not carry one. It sits
// beside the database rather than inside it, and the backup routine must never
// learn to include it.
const KeyFile = "secret.key"
// Box seals and opens values.
type Box struct {
aead cipher.AEAD
}
// Open loads the key, generating and saving one on first use.
//
// A key given in the environment wins: that is how a host keeps the secret out of
// its disks altogether. Otherwise a file, created 0600, and read back with its
// permissions checked — a key that anybody on the box can read is a key that is
// only ceremonially secret.
func Open(env, dataDir string) (*Box, error) {
if env != "" {
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(env))
if err != nil || len(key) != 32 {
return nil, errors.New("VERDANDE_SECRET_KEY must be 32 bytes of base64 — " +
"generate one with: openssl rand -base64 32")
}
return newBox(key)
}
path := filepath.Join(dataDir, KeyFile)
raw, err := os.ReadFile(path)
switch {
case err == nil:
key, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
if decErr != nil || len(key) != 32 {
// Deliberately not regenerating. A new key here would not fix anything —
// it would silently make every stored token unreadable, which looks
// exactly like the mailbox having disconnected itself overnight.
return nil, fmt.Errorf("%s is not a 32-byte base64 key; move it aside only "+
"if you accept that every connected mailbox must be reconnected", path)
}
if info, statErr := os.Stat(path); statErr == nil && info.Mode().Perm()&0o077 != 0 {
return nil, fmt.Errorf("%s is readable by others (%04o); chmod 600 it", path, info.Mode().Perm())
}
return newBox(key)
case errors.Is(err, os.ErrNotExist):
key := make([]byte, 32)
if _, randErr := rand.Read(key); randErr != nil {
return nil, randErr
}
encoded := base64.StdEncoding.EncodeToString(key)
if writeErr := os.WriteFile(path, []byte(encoded+"\n"), 0o600); writeErr != nil {
return nil, fmt.Errorf("write %s: %w", path, writeErr)
}
return newBox(key)
default:
return nil, fmt.Errorf("read %s: %w", path, err)
}
}
func newBox(key []byte) (*Box, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Box{aead: aead}, nil
}
// Seal returns the sealed form of v. An empty string stays empty: there is nothing
// to hide, and a row full of ciphertext for "" only makes the column harder to read.
func (b *Box) Seal(v string) (string, error) {
if v == "" || IsSealed(v) {
return v, nil
}
nonce := make([]byte, b.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
out := b.aead.Seal(nonce, nonce, []byte(v), nil)
return prefix + base64.StdEncoding.EncodeToString(out), nil
}
// Unseal returns the plain value. Anything without the marker is returned as it
// is — the column holds values written before there was a key, and they are read
// as themselves until something writes them back sealed.
func (b *Box) Unseal(v string) (string, error) {
if !IsSealed(v) {
return v, nil
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(v, prefix))
if err != nil {
return "", fmt.Errorf("sealed value is not base64: %w", err)
}
if len(raw) < b.aead.NonceSize() {
return "", errors.New("sealed value is too short to contain a nonce")
}
nonce, body := raw[:b.aead.NonceSize()], raw[b.aead.NonceSize():]
out, err := b.aead.Open(nil, nonce, body, nil)
if err != nil {
// The overwhelmingly likely cause, and worth saying rather than "message
// authentication failed", which sends people looking for a corrupt row.
return "", errors.New("cannot read a stored secret: this is a different key " +
"than the one it was written with")
}
return string(out), nil
}
// IsSealed reports whether v has already been through Seal.
func IsSealed(v string) bool { return strings.HasPrefix(v, prefix) }
+149
View File
@@ -0,0 +1,149 @@
package secret
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAValueSurvivesTheRoundTrip(t *testing.T) {
b := mustBox(t)
for _, in := range []string{"1//0abc-refresh", "kodeord med æøå", strings.Repeat("x", 5000)} {
sealed, err := b.Seal(in)
if err != nil {
t.Fatal(err)
}
if strings.Contains(sealed, in) {
t.Fatal("the sealed form still contains the value")
}
out, err := b.Unseal(sealed)
if err != nil {
t.Fatal(err)
}
if out != in {
t.Errorf("got %q, want %q", out, in)
}
}
}
// Two seals of the same value must not look alike, or the column tells anyone
// reading it which accounts share a password.
func TestSealingTwiceGivesDifferentText(t *testing.T) {
b := mustBox(t)
first, _ := b.Seal("same")
second, _ := b.Seal("same")
if first == second {
t.Error("the same value sealed to the same text twice")
}
}
// The column holds values written before there was a key. Reading must not fail
// on them, and sealing must not wrap an already-sealed value a second time.
func TestPlainTextIsReadAsItself(t *testing.T) {
b := mustBox(t)
out, err := b.Unseal("1//0abc-written-before-there-was-a-key")
if err != nil {
t.Fatal(err)
}
if out != "1//0abc-written-before-there-was-a-key" {
t.Errorf("an unsealed value came back as %q", out)
}
sealed, _ := b.Seal("x")
again, _ := b.Seal(sealed)
if again != sealed {
t.Error("an already-sealed value was sealed a second time")
}
}
func TestNothingIsStillNothing(t *testing.T) {
b := mustBox(t)
if out, _ := b.Seal(""); out != "" {
t.Errorf("an empty value sealed to %q", out)
}
}
// The message somebody gets after restoring a database without its key file. It
// has to point at the key, because every other symptom points at the mailbox.
func TestTheWrongKeySaysItIsTheWrongKey(t *testing.T) {
sealed, err := mustBox(t).Seal("hemmelig")
if err != nil {
t.Fatal(err)
}
_, err = mustBox(t).Unseal(sealed)
if err == nil {
t.Fatal("another key opened it")
}
if !strings.Contains(err.Error(), "different key") {
t.Errorf("the error does not name the cause: %v", err)
}
}
func TestTheKeyFileIsMadeOnceAndKeptPrivate(t *testing.T) {
dir := t.TempDir()
if _, err := Open("", dir); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, KeyFile)
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("the key file is %04o, not 0600", perm)
}
// Opening again must reuse it. A second key here would quietly make every
// stored token unreadable, and it would look like the mailboxes disconnecting
// themselves overnight.
first, _ := os.ReadFile(path)
if _, err := Open("", dir); err != nil {
t.Fatal(err)
}
second, _ := os.ReadFile(path)
if string(first) != string(second) {
t.Error("opening a second time replaced the key")
}
}
func TestAKeyOthersCanReadIsRefused(t *testing.T) {
dir := t.TempDir()
if _, err := Open("", dir); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, KeyFile)
if err := os.Chmod(path, 0o644); err != nil {
t.Fatal(err)
}
if _, err := Open("", dir); err == nil {
t.Error("a world-readable key was accepted")
}
}
// The environment wins, so a host can keep the key off its disks altogether.
func TestTheEnvironmentBeatsTheFile(t *testing.T) {
dir := t.TempDir()
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("k", 32)))
if _, err := Open(key, dir); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, KeyFile)); !os.IsNotExist(err) {
t.Error("a key file was written even though the environment carried one")
}
if _, err := Open("too-short", dir); err == nil {
t.Error("a key that is not 32 bytes was accepted")
}
}
func mustBox(t *testing.T) *Box {
t.Helper()
b, err := Open("", t.TempDir())
if err != nil {
t.Fatal(err)
}
return b
}
+253
View File
@@ -0,0 +1,253 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// Mailbox is one connected mailbox belonging to one person.
type Mailbox struct {
ID string `json:"id"`
UserID string `json:"-"`
Kind string `json:"kind"` // "gmail" or "imap"
Name string `json:"name"`
Host string `json:"host,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"-"` // never leaves the server
Folder string `json:"folder,omitempty"`
RefreshToken string `json:"-"`
AccessToken string `json:"-"`
ExpiresAt time.Time `json:"-"`
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"`
LastSyncAt time.Time `json:"last_sync_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
const mailboxColumns = `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`
// Mailboxes returns everything one person has connected, oldest first.
func (db *DB) Mailboxes(ctx context.Context, userID string) ([]Mailbox, error) {
rows, err := db.QueryContext(ctx,
`SELECT `+mailboxColumns+` FROM mailboxes WHERE user_id = ? ORDER BY created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Mailbox
for rows.Next() {
m, err := db.scanMailbox(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// Mailbox returns one, and only to the person it belongs to: the id is enough to
// find a row, so the owner is part of the question rather than checked after.
func (db *DB) Mailbox(ctx context.Context, userID, id string) (*Mailbox, error) {
row := db.QueryRowContext(ctx,
`SELECT `+mailboxColumns+` FROM mailboxes WHERE id = ? AND user_id = ?`, id, userID)
m, err := db.scanMailbox(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
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
// backup. Called by hand rather than by the settings tables' own seal, which does
// not reach this table.
func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
if m.ID == "" {
m.ID = NewID()
}
if m.CreatedAt.IsZero() {
m.CreatedAt = time.Now()
}
if m.Folder == "" {
m.Folder = "INBOX"
}
password, err := db.sealValue(m.Password)
if err != nil {
return fmt.Errorf("seal password: %w", err)
}
refresh, err := db.sealValue(m.RefreshToken)
if err != nil {
return fmt.Errorf("seal refresh_token: %w", err)
}
access, err := db.sealValue(m.AccessToken)
if err != nil {
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, `
INSERT INTO mailboxes (`+mailboxColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = excluded.name,
host = excluded.host,
username = excluded.username,
password = excluded.password,
folder = excluded.folder,
refresh_token = excluded.refresh_token,
access_token = excluded.access_token,
expires_at = excluded.expires_at,
label = excluded.label,
trigger_kind = excluded.trigger_kind,
seen = excluded.seen,
project_id = excluded.project_id,
last_uid = excluded.last_uid,
last_sync_at = excluded.last_sync_at`,
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), m.ProjectID,
m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix())
return err
}
// MarkMailboxRead records how far a run got. Written separately from the rest so
// a sync never rewrites the credentials it is holding in memory.
func (db *DB) MarkMailboxRead(ctx context.Context, id string, lastUID uint32, at time.Time) error {
_, err := db.ExecContext(ctx,
`UPDATE mailboxes SET last_uid = ?, last_sync_at = ? WHERE id = ?`,
lastUID, at.Unix(), id)
return err
}
// DeleteMailbox disconnects one. The tasks it made stay: they are the person's
// work now, not the mailbox's.
func (db *DB) DeleteMailbox(ctx context.Context, userID, id string) error {
_, err := db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id = ? AND user_id = ?`, id, userID)
return err
}
// UsersWithMailboxes returns everybody the sweep has to visit.
func (db *DB) UsersWithMailboxes(ctx context.Context) ([]string, error) {
rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM mailboxes`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
type scanner interface {
Scan(dest ...any) error
}
func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
var m Mailbox
var expires, lastSync, created int64
var seen string
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.Label, &m.Trigger, &seen, &m.ProjectID, &m.LastUID, &lastSync, &created)
if err != nil {
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} {
plain, err := db.unsealValue(*field)
if err != nil {
return m, err
}
*field = plain
}
m.ExpiresAt = timeOrZero(expires)
m.LastSyncAt = timeOrZero(lastSync)
m.CreatedAt = time.Unix(created, 0)
return m, nil
}
// sealValue and unsealValue are the single-string versions of the map helpers in
// notifications.go. Without a key they pass the value through, which is what the
// tests that never set one expect.
func (db *DB) sealValue(v string) (string, error) {
if db.box == nil {
return v, nil
}
return db.box.Seal(v)
}
func (db *DB) unsealValue(v string) (string, error) {
if db.box == nil {
return v, nil
}
return db.box.Unseal(v)
}
func unixOrZero(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
func timeOrZero(unix int64) time.Time {
if unix == 0 {
return time.Time{}
}
return time.Unix(unix, 0)
}
+264
View File
@@ -0,0 +1,264 @@
package store
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kristianwind/verdande/internal/secret"
)
func TestAMailboxIsStoredSealedAndComesBackWhole(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const password = "abcd-efgh-ijkl-mnop"
m := &Mailbox{
UserID: userID,
Kind: "imap",
Name: "iCloud",
Host: "imap.mail.me.com:993",
Username: "[email protected]",
Password: password,
}
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
var stored string
if err := db.QueryRowContext(ctx,
`SELECT password FROM mailboxes WHERE id = ?`, m.ID).Scan(&stored); err != nil {
t.Fatal(err)
}
if strings.Contains(stored, password) {
t.Fatalf("the password is in the row as typed: %s", stored)
}
back, err := db.Mailbox(ctx, userID, m.ID)
if err != nil {
t.Fatal(err)
}
if back == nil {
t.Fatal("the mailbox was not found")
}
if back.Password != password {
t.Errorf("got password %q", back.Password)
}
if back.Folder != "INBOX" {
t.Errorf("the folder defaulted to %q, want INBOX", back.Folder)
}
}
// The question that prompted the table: two people on one instance, each with
// their own mailbox, neither seeing the other's.
func TestAMailboxBelongsToOnePerson(t *testing.T) {
db, kristian := sealedStore(t)
ctx := context.Background()
andreas := &User{Email: "[email protected]", Name: "Andreas"}
if err := db.CreateUser(ctx, andreas, "Indbakke"); err != nil {
t.Fatal(err)
}
mine := &Mailbox{UserID: kristian, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
theirs := &Mailbox{UserID: andreas.ID, Kind: "imap", Host: "imap.fastmail.com:993", Username: "andreas"}
for _, m := range []*Mailbox{mine, theirs} {
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
}
list, err := db.Mailboxes(ctx, kristian)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Username != "kw" {
t.Fatalf("got %d mailboxes for Kristian, want only his own", len(list))
}
// And the id alone is not enough to reach somebody else's.
other, err := db.Mailbox(ctx, kristian, theirs.ID)
if err != nil {
t.Fatal(err)
}
if other != nil {
t.Error("one person read another's mailbox by its id")
}
}
// Several mailboxes each is the ordinary case — a work Gmail and a private
// iCloud — and is the whole reason this is a table rather than a setting.
func TestOnePersonCanHaveSeveral(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
for _, host := range []string{"imap.mail.me.com:993", "imap.fastmail.com:993"} {
if err := db.SaveMailbox(ctx, &Mailbox{
UserID: userID, Kind: "imap", Host: host, Username: "kw",
}); err != nil {
t.Fatal(err)
}
}
list, err := db.Mailboxes(ctx, userID)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("got %d mailboxes, want 2", len(list))
}
}
// The same mailbox twice would make two tasks out of every mail.
func TestTheSameMailboxTwiceIsRefused(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
first := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
if err := db.SaveMailbox(ctx, first); err != nil {
t.Fatal(err)
}
again := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
if err := db.SaveMailbox(ctx, again); err == nil {
t.Error("the same host and username was accepted a second time")
}
}
func TestReadingProgressIsRemembered(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
m := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
at := time.Now().Truncate(time.Second)
if err := db.MarkMailboxRead(ctx, m.ID, 4711, at); err != nil {
t.Fatal(err)
}
back, err := db.Mailbox(ctx, userID, m.ID)
if err != nil {
t.Fatal(err)
}
if back.LastUID != 4711 {
t.Errorf("last uid is %d", back.LastUID)
}
if !back.LastSyncAt.Equal(at) {
t.Errorf("last sync is %v, want %v", back.LastSyncAt, at)
}
// The credentials it was holding must not have been rewritten as a side effect.
if back.Host != "imap.mail.me.com:993" {
t.Errorf("the host changed to %q", back.Host)
}
}
// Disconnecting takes the mailbox, not the work it produced.
func TestDisconnectingKeepsTheTasks(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
m := &Mailbox{UserID: userID, Kind: "imap", Host: "imap.mail.me.com:993", Username: "kw"}
if err := db.SaveMailbox(ctx, m); err != nil {
t.Fatal(err)
}
if err := db.DeleteMailbox(ctx, userID, m.ID); err != nil {
t.Fatal(err)
}
list, err := db.Mailboxes(ctx, userID)
if err != nil {
t.Fatal(err)
}
if len(list) != 0 {
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":"[email protected]","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 != "[email protected]" {
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: "[email protected]", Name: "Kristian"}
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
t.Fatal(err)
}
return db, user.ID
}
@@ -0,0 +1,57 @@
-- Mailboxes: one row per connected mailbox, not one setting per person.
--
-- Gmail lived in `user_settings` under scope 'gmail'. That table has `user_id` as
-- its whole primary key, so it holds exactly one scope per person and its upsert
-- rewrites the scope name on conflict — adding IMAP beside Gmail there would have
-- deleted the Gmail connection without saying so. Migration 0011 warns about this
-- in as many words, and the warning was nearly walked into a second time.
--
-- The shape is wrong for the thing anyway. A person has mailboxes, plural: a work
-- Gmail and a private iCloud is the ordinary case, not the exotic one. A row each
-- says that. It also answers the question that prompted this — every mailbox
-- belongs to a user, so two people on the same instance connect their own and see
-- only their own. The one piece that stays instance-wide is the Google OAuth
-- client, which is a single registration everybody signs in through, and that is
-- correct: it is the app, not the account.
--
-- `kind` rather than two tables. What differs is how the credential is obtained —
-- OAuth for Gmail, a password for IMAP — and what differs is a nullable column,
-- not a schema. What is the same is everything the sync loop touches: which
-- mailbox, how far it has read, when it last ran.
--
-- `password` and `refresh_token` are written sealed; see internal/secret. The
-- store's own seal/unseal only covers the settings tables, so this table's methods
-- call them by hand — a fact worth knowing before adding a third secret column.
--
-- `last_uid` is IMAP's place-marker and means nothing for Gmail, which keeps its
-- own by query. Zero is "nothing read yet" for both.
CREATE TABLE mailboxes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('gmail', 'imap')),
name TEXT NOT NULL DEFAULT '',
-- IMAP
host TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
folder TEXT NOT NULL DEFAULT 'INBOX',
-- Gmail
refresh_token TEXT NOT NULL DEFAULT '',
access_token TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL DEFAULT '',
last_uid INTEGER NOT NULL DEFAULT 0,
last_sync_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- The sweep asks "whose mailboxes are due", so it reads by user.
CREATE INDEX mailboxes_by_user ON mailboxes (user_id);
-- The same mailbox twice is a mistake, not a choice: two rows would make two
-- tasks out of every mail. Gmail's uniqueness is the account, IMAP's is the host
-- and the username together, and `kind` keeps the two from colliding.
CREATE UNIQUE INDEX mailboxes_once ON mailboxes (user_id, kind, host, username);
@@ -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';
+70 -40
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/kristianwind/verdande/internal/auth"
@@ -271,48 +272,69 @@ func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error)
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 ------------------------------------------------------------------
// UserSettings reads one scope of a person's integration settings. A missing row is
// an empty map, not an error: nobody has settings until they set some.
// --- sealed values ----------------------------------------------------------------
// secretFields are the keys whose values never leave this process in the clear.
//
// A named set rather than "seal everything": the rest of a settings blob is what
// somebody reads when a mailbox misbehaves — which folder, which label, which
// address — and turning all of it into ciphertext would buy nothing and cost the
// ability to look.
var secretFields = map[string]bool{
"refresh_token": true,
"access_token": true,
"password": true,
"client_secret": true,
}
// seal returns a copy of values with the secret fields sealed. Without a key it
// returns them untouched, which is what the tests that never set one expect.
func (db *DB) seal(values map[string]any) (map[string]any, error) {
if db.box == nil {
return values, nil
}
out := make(map[string]any, len(values))
for k, v := range values {
s, ok := v.(string)
if !secretFields[k] || !ok {
out[k] = v
continue
}
sealed, err := db.box.Seal(s)
if err != nil {
return nil, fmt.Errorf("seal %s: %w", k, err)
}
out[k] = sealed
}
return out, nil
}
// unseal is seal's other direction. Values written before there was a key come
// back as themselves; the next write puts them away properly, so the column
// converts itself as it is used rather than in one migration that has to be right
// about every row at once.
func (db *DB) unseal(values map[string]any) (map[string]any, error) {
if db.box == nil {
return values, nil
}
for k, v := range values {
s, ok := v.(string)
if !secretFields[k] || !ok {
continue
}
plain, err := db.box.Unseal(s)
if err != nil {
return nil, fmt.Errorf("read %s: %w", k, err)
}
values[k] = plain
}
return values, nil
}
func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[string]any, error) {
var raw string
err := db.QueryRowContext(ctx,
@@ -329,10 +351,14 @@ func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[strin
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return map[string]any{}, nil
}
return values, nil
return db.unseal(values)
}
func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values map[string]any) error {
values, err := db.seal(values)
if err != nil {
return err
}
raw, err := json.Marshal(values)
if err != nil {
return err
@@ -370,10 +396,14 @@ func (db *DB) InstanceSettings(ctx context.Context, scope string) (map[string]an
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return map[string]any{}, nil
}
return values, nil
return db.unseal(values)
}
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
values, err := db.seal(values)
if err != nil {
return err
}
raw, err := json.Marshal(values)
if err != nil {
return err
+126
View File
@@ -0,0 +1,126 @@
package store
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/kristianwind/verdande/internal/secret"
)
// The whole point of the exercise: what is written must not be readable in the
// file that a backup copies.
func TestATokenIsNotStoredInTheClear(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const token = "1//0-a-real-looking-refresh-token"
if err := db.SetUserSettings(ctx, userID, "gmail", map[string]any{
"refresh_token": token,
"label": "Verdande",
}); err != nil {
t.Fatal(err)
}
var raw string
if err := db.QueryRowContext(ctx,
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
t.Fatal(err)
}
if strings.Contains(raw, token) {
t.Fatalf("the token is in the row as written: %s", raw)
}
// The rest stays readable, which is the reason for naming the secret fields
// rather than sealing the whole blob.
if !strings.Contains(raw, "Verdande") {
t.Errorf("the label was sealed too: %s", raw)
}
back, err := db.UserSettings(ctx, userID, "gmail")
if err != nil {
t.Fatal(err)
}
if back["refresh_token"] != token {
t.Errorf("got %q back", back["refresh_token"])
}
}
// The migration that matters. A row written before there was a key must still be
// readable, and must put itself away sealed the next time it is written — nobody
// should have to reconnect a mailbox because the server learned to encrypt.
func TestATokenWrittenBeforeTheKeyStillWorks(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const token = "1//0-written-when-there-was-no-key"
plain, _ := json.Marshal(map[string]any{"refresh_token": token})
if _, err := db.ExecContext(ctx, `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', ?, 0)`, userID, string(plain)); err != nil {
t.Fatal(err)
}
values, err := db.UserSettings(ctx, userID, "gmail")
if err != nil {
t.Fatalf("a row from before the key could not be read: %v", err)
}
if values["refresh_token"] != token {
t.Fatalf("got %q, want the plain token back", values["refresh_token"])
}
// Written back — and now sealed, without anything having asked for it.
if err := db.SetUserSettings(ctx, userID, "gmail", values); err != nil {
t.Fatal(err)
}
var raw string
if err := db.QueryRowContext(ctx,
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
t.Fatal(err)
}
if strings.Contains(raw, token) {
t.Error("the row is still in the clear after being written back")
}
}
// The sweep finds people by the presence of a mailbox, so it must keep working
// now that the credential in it is ciphertext.
func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
if err := db.SaveMailbox(ctx, &Mailbox{
UserID: userID, Kind: "gmail", RefreshToken: "1//0-token",
}); err != nil {
t.Fatal(err)
}
users, err := db.UsersWithMailboxes(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 1 {
t.Fatalf("the sweep found %d people with mailboxes, want 1", len(users))
}
}
func sealedStore(t *testing.T) (*DB, string) {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
box, err := secret.Open("", t.TempDir())
if err != nil {
t.Fatal(err)
}
db.UseSecrets(box)
user := &User{Email: "[email protected]", Name: "Kristian"}
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
t.Fatal(err)
}
return db, user.ID
}
+13
View File
@@ -7,6 +7,7 @@ import (
"database/sql"
"embed"
"fmt"
"github.com/kristianwind/verdande/internal/secret"
"io/fs"
"path/filepath"
"runtime"
@@ -23,8 +24,20 @@ var migrationFS embed.FS
type DB struct {
*sql.DB
path string
// box seals the values that must not travel with a backup. Nil in the tests
// that do not care, where a secret is stored as itself — see Seal.
box *secret.Box
}
// UseSecrets gives the store the key it seals with. Called once at startup, before
// anything reads or writes settings.
//
// Separate from Open because the key comes from the environment or from a file
// beside the data, and a database that cannot be opened without it would make a
// misplaced key look like a corrupt disk.
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
// dsn is the connection string.
//
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
+6 -1
View File
@@ -15,7 +15,7 @@ gameskill:
# this: a manifest with new fields and an unchanged version is a manifest the
# panel has no reason to re-read, so the fields never appear and the failure is
# silent — the server's settings page simply does not offer them.
version: 3
version: 4
icon: "app"
# The image is distroless — no shell — so keep_entrypoint runs the binary the
@@ -99,6 +99,11 @@ gameskill:
name: "This server's id in the panel (the one in its URL)"
type: string
default: ""
- key: VERDANDE_SECRET_KEY
name: "Key for the tokens and mailbox passwords in the database — 32 bytes of base64, from: openssl rand -base64 32. Leave empty to keep one in a file beside the data. Change it and every mailbox must be connected again."
type: string
secret: true
default: ""
install:
# The image has no shell, so the install step borrows one. It only has to make
+120 -22
View File
@@ -302,26 +302,41 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
await expect(heading).toBeVisible();
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
// The words in the heading must not sit on top of each other.
// A group's name must be readable in full, and must start where a project's
// name starts.
//
// Everything else here clicks by accessible name, which is exactly why this
// needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
// Both buttons still worked, so every assertion in this file passed.
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
// Links as well as buttons: the group's name is a link to its page now, and
// a measurement that skipped it would miss the widest thing in the row.
[...el.querySelectorAll('button, a')].map((b) => {
const r = b.getBoundingClientRect();
return { text: b.textContent.trim(), left: r.left, right: r.right };
})
);
for (let i = 1; i < boxes.length; i++) {
expect(
boxes[i].left,
`"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet`
).toBeGreaterThanOrEqual(boxes[i - 1].right);
}
// Both halves are here because both were once broken at the same time and for
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
// sat before the name, pushing a group 22px further in than the projects it
// contains — so a group read as if it were filed under them. Every assertion
// in this file passed through both, because both buttons still worked.
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
const link = el.querySelector('h2 a');
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
// The last child of the link is the name itself; the dot is a span before it.
return {
clipped: link.scrollWidth > link.clientWidth + 1,
left: name.left,
right: link.getBoundingClientRect().right,
count: el.querySelector('.count')?.getBoundingClientRect().left ?? Infinity
};
});
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
// Measured dot to dot, not box to box: both rows begin with the same coloured
// mark, and it is the only thing in them that means the same on both sides.
// Comparing the group's inner link with the project's outer box reported the
// row's own padding as a misalignment.
const projectLeft = await sidebar
.getByRole('link', { name: 'Regnskab' })
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
expect(
Math.abs(shape.left - projectLeft),
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
).toBeLessThanOrEqual(2);
// Dragged in, then dragged back out onto "Projekter" — which is the reason
// that heading is a drop target at all: with every project filed away there
@@ -348,7 +363,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Colour, which is chosen while renaming rather than from the row. Both are
// edits to the same thing, and a heading carrying four controls crowded the
// name it exists to show.
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
// Double-click on the row opens it; the two words that used to sit on top of
// the name are gone from the sidebar entirely.
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
await sidebar.getByRole('button', { name: 'Petrol' }).click();
// Still open: picking a colour must not close the form under the pointer, which
// is what a plain blur-to-cancel does in the two browsers that do not focus a
@@ -392,7 +409,7 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Renaming writes one row rather than one per project, which is the reason a
// group is a table and not a string repeated on every project.
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
const name = sidebar.getByLabel('Gruppens navn');
await name.fill('Kontoret');
await name.press('Enter');
@@ -401,8 +418,11 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Deleting the heading must not take the project filed under it. It is
// ungrouped here, which is the case that would look identical either way if
// the assertion were only "the group is gone".
// Deleting is on the group's own page now, where the projects it will not take
// with it are on the screen above the button.
await sidebar.getByRole('link', { name: 'Kontoret', exact: true }).click();
page.once('dialog', (dialog) => dialog.accept());
await sidebar.getByRole('button', { name: 'Slet' }).click();
await page.getByRole('button', { name: 'Slet' }).click();
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
@@ -1748,3 +1768,81 @@ test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
expect(response.headers()['content-type']).toContain('application/json');
expect((await response.json()).code).toBe('unauthorized');
});
test('man kan skrive hvor som helst, og feltet fortæller hvad det forstår', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// Nothing focused, nothing clicked: just type. The thought does not have to
// survive a journey to find somewhere to put it.
await page.locator('body').click({ position: { x: 5, y: 5 } });
// "i dag" because this is the Today view: a task parsed onto no date at all is
// correctly absent from it, which would read as the capture having failed.
await page.keyboard.type('ring til Anders i dag');
const field = page.locator('[data-quickadd]');
await expect(field).toBeFocused();
// Every letter arrives, including the first — which is the one a naive
// implementation drops while it is busy moving focus.
await expect(field).toHaveValue('ring til Anders i dag');
// And the legend appears with the field, saying what the parser can read. It
// used to live in the placeholder, which vanishes at the first keystroke —
// exactly when the help starts being useful.
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.getByText('ring til Anders', { exact: true })).toBeVisible();
// In English too. The shortcut used to find the field by its Danish label, so
// it silently did nothing for anybody running the interface in English.
await page.goto('/indstillinger');
await page.getByLabel('Sprog').selectOption('en');
await page.getByRole('button', { name: 'Gem' }).first().click();
await page.goto('/');
await page.locator('body').click({ position: { x: 5, y: 5 } });
await page.keyboard.type('call Anders');
await expect(page.locator('[data-quickadd]')).toHaveValue('call Anders');
// Back to Danish. The account is shared with every test after this one, and
// leaving it in English made three of them hunt for buttons by names that no
// longer existed — a failure that looks like the feature under test and is not.
await page.goto('/indstillinger');
await page.getByLabel('Language').selectOption('da');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByRole('button', { name: 'Gem' }).first()).toBeVisible();
expect(trouble).toEqual([]);
});
test('en postkasse kan tilføjes, og værtens afvisning når frem til skærmen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/integrationer');
await expect(page.getByRole('heading', { name: 'Postkasser' })).toBeVisible();
await page.getByRole('button', { name: 'Tilføj en postkasse' }).click();
// Nothing is listening there, so the dial fails at once. The point is not the
// failure but where it is reported: the server tests the connection before it
// saves, so this is refused at the door rather than accepted and then silently
// failing every ten minutes.
await page.getByLabel('IMAP-server').fill('127.0.0.1:1');
await page.getByLabel('Brugernavn').fill('kw');
await page.getByLabel('App-kodeord').fill('hemmelig');
// `exact`, because "Forbind" is a prefix of "Forbind Gmail" one panel up and
// Playwright matches an accessible name by substring unless told otherwise.
await page.getByRole('button', { name: 'Forbind', exact: true }).click();
// The host's own words, not "noget gik galt". This is the whole reason
// upstream refusals are answered as 422: a proxy may replace the body of a 5xx,
// and then the person is told nothing.
await expect(page.getByText(/Postkassen svarede/)).toBeVisible();
// And nothing was saved on the way past.
await page.reload();
await expect(page.getByText('127.0.0.1:1')).toBeHidden();
// The 422 above is the point of the test, not a symptom. Everything else the
// watcher caught still has to be empty.
expect(trouble.filter((t) => !t.includes('/mailboxes → 422'))).toEqual([]);
});
+13
View File
@@ -58,6 +58,9 @@ const MESSAGES = {
// The fallback for when Gmail refused and said nothing usable. When it did say
// something, `humanMessage` quotes it instead — see below.
gmail_failed: 'error.gmailRefused',
// Same shape for a mailbox read over IMAP: quoted when the host said something,
// this when it did not.
mailbox_failed: 'error.mailboxRefused',
last_admin: 'error.lastAdmin'
};
@@ -75,6 +78,9 @@ export function humanMessage(err) {
// authentication scopes" — and a generic sentence in their place throws away
// the only thing that says what to do. Wrapped so it reads as a quotation
// rather than as a stray English string in a Danish interface.
if (err.code === 'mailbox_failed' && err.message) {
return t('error.mailboxSaid', { what: err.message });
}
if (err.code === 'gmail_failed' && err.message) {
return t('error.gmailSaid', { what: err.message });
}
@@ -354,6 +360,13 @@ export const api = {
authorizeGmail: () => post('/gmail/authorize'),
syncGmail: () => post('/gmail/sync'),
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
// there is no instance-wide registration behind these the way Gmail has one.
mailboxes: () => get('/mailboxes'),
addMailbox: (body) => post('/mailboxes', body),
deleteMailbox: (id) => del(`/mailboxes/${id}`),
syncMailbox: (id) => post(`/mailboxes/${id}/sync`),
getAISettings: () => get('/ai/settings'),
setAISettings: (data) => put('/ai/settings', data),
aiSummary: () => post('/ai/summary'),
+58
View File
@@ -21,6 +21,19 @@
let spans = $state([]);
let submitting = $state(false);
let input;
let focused = $state(false);
// Kept beside the parser's own vocabulary on purpose: a legend that promises
// something internal/quickadd cannot read is worse than no legend. These four
// are what Parse recognises as marks; dates it reads as prose, which is why the
// last entry is an example rather than a symbol.
let syntax = $derived([
{ mark: '#', what: t('task.syntaxProject') },
{ mark: '/', what: t('task.syntaxSection') },
{ mark: '@', what: t('task.syntaxLabel') },
{ mark: 'p1', what: t('task.syntaxPriority') },
{ mark: t('task.syntaxDateMark'), what: t('task.syntaxDate') }
]);
let previewController = null;
let previewTimer = null;
@@ -143,7 +156,10 @@
bind:this={input}
bind:value={text}
{onkeydown}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
type="text"
data-quickadd
placeholder={t('task.placeholder')}
aria-label={t('task.new')}
autocomplete="off"
@@ -154,10 +170,52 @@
{#if text.trim()}
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
{/if}
<!-- What the parser understands, spelled out while somebody is typing into it.
It was in the placeholder before, which is the one place a hint cannot be:
the placeholder disappears at the first keystroke, so the help was gone
exactly when it started being useful.
Floated rather than in the flow. In the flow it pushed the whole list down
the moment the field took focus, and a click aimed at the first task landed
on the second — twelve tests caught it, which is twelve more than the eye
would have. -->
{#if focused}
<p class="syntax">
{#each syntax as item}
<span><kbd>{item.mark}</kbd> {item.what}</span>
{/each}
</p>
{/if}
</form>
<style>
/* Sits under the field it explains, in the same rhythm as a hint anywhere else. */
.syntax {
/* Nothing to click, and it floats over the first task in the list — which it
promptly swallowed the clicks of. Legends are for reading. */
pointer-events: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 2;
background: var(--surface);
display: flex;
flex-wrap: wrap;
gap: var(--s1) var(--s3);
padding: var(--s1) 0 0 var(--s4);
font-size: var(--text-xs);
color: var(--ink-faint);
}
.syntax kbd {
font-family: var(--mono, ui-monospace, monospace);
color: var(--ink-muted);
}
.quickadd {
position: relative;
display: flex;
align-items: center;
gap: var(--s3);
+79 -42
View File
@@ -105,17 +105,18 @@
await app.renameGroup(group.id, name);
}
async function removeGroup(group) {
const count = inGroup(group.id).length;
// Says what happens to the projects, because "slet gruppen" reads like it
// might take them with it. It does not: only the heading goes.
const kept =
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
const question = t('sidebar.deleteGroup', { name: group.name });
if (!confirm(count ? `${question} ${kept}` : question)) return;
await app.deleteGroup(group.id);
/** Double-click, or F2 with the row focused. The colour is chosen in the same
* form, because it is the same edit to the same thing. */
/** Cleared by a second click, which means the double was a rename. */
let navTimer;
function startRename(group) {
clearTimeout(navTimer);
renamingGroup = group.id;
groupName = group.name;
}
// --- resizing -------------------------------------------------------------------
let resizing = $state(false);
@@ -547,6 +548,13 @@
overId = null;
}}
ondrop={(e) => onGroupDrop(e, group)}
ondblclick={() => startRename(group)}
onkeydown={(e) => {
if (e.key === 'F2') {
e.preventDefault();
startRename(group);
}
}}
>
{#if renamingGroup === group.id}
<!-- Renaming is where the colour is chosen too.
@@ -618,7 +626,24 @@
</svg>
</button>
<h2 class="fold">
<a href="/gruppe/{group.id}" onclick={onnavigate}>
<!-- Navigation waits two tenths of a second for a second click. A
link cannot know a double is coming without pausing, and the
alternative was worse than the pause: the first click opened the
page while the rename form was appearing, and the colour swatches
moved out from under the pointer. Two tenths is under the double
click threshold and over the threshold of noticing. -->
<a
href="/gruppe/{group.id}"
onclick={(e) => {
e.preventDefault();
clearTimeout(navTimer);
if (e.detail > 1) return;
navTimer = setTimeout(() => {
onnavigate();
goto(`/gruppe/${group.id}`);
}, 200);
}}
>
<span
class="dot group-dot"
style="background: {colorVar(group.color)}"
@@ -628,14 +653,6 @@
</a>
</h2>
<span class="count">{inside.length}</span>
<button
class="group-action"
onclick={() => {
renamingGroup = group.id;
groupName = group.name;
}}>{t('sidebar.rename')}</button
>
<button class="group-action remove" onclick={() => removeGroup(group)}>{t('sidebar.delete')}</button>
{/if}
</div>
@@ -939,14 +956,30 @@
/* The chevron on its own, so the name beside it can be a link to the group's
page. Sized as a target rather than as a glyph: 11px of arrow is something
you miss. */
/* The chevron sits on top of the group's dot rather than before it. In the flow
it pushed the name 22px further in than a project's, so groups read as if
they were filed under the projects above them — the opposite of what they
are. Out of the flow, a group's name starts exactly where a project's does.
The dot is the resting state; the chevron takes over on hover or focus, and
stays put while the group is folded, so a folded group still shows the way
back open. */
.chevron-button {
flex: none;
position: absolute;
left: calc(var(--s2) - 5px);
width: 18px;
height: 18px;
display: grid;
place-items: center;
border-radius: var(--radius-sm);
color: inherit;
opacity: 0;
background: var(--surface);
}
.folder-head:hover .chevron-button,
.chevron-button:focus-visible,
.chevron-button[aria-expanded='false'] {
opacity: 1;
}
.chevron-button:hover {
@@ -997,30 +1030,34 @@
/* Hidden until the heading is hovered, like the section actions on a project
page: two buttons permanently beside a label stop it reading as a label. */
.group-action {
font-size: var(--text-xs);
color: var(--ink-faint);
padding: 0 var(--s1);
flex: none;
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.folder-head:hover .group-action,
.group-action:focus-visible {
opacity: 1;
}
.group-action:hover {
color: var(--ink);
}
.group-action.remove:hover {
color: var(--danger);
}
.folder-head .count {
margin-left: 0;
margin-left: auto;
}
/* Without min-width a flex item refuses to shrink below its content, so a long
group name pushed the count off the row instead of ending in an ellipsis. */
.folder-head h2 {
min-width: 0;
flex: 1;
}
/* No left padding of its own: the row already has the same padding as a project
row, and the anchor's would be added on top — which is where the last eight
pixels of the old indent were hiding. Vertical padding stays, so the heading
keeps its height. */
.folder-head h2 a {
display: flex;
align-items: center;
gap: var(--s2);
min-width: 0;
padding: var(--s2) 0;
}
.folder-head h2 a :global(*:last-child),
.folder-head h2 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The group's own mark, between the chevron and the name — inside the fold
+31
View File
@@ -57,6 +57,12 @@ export const da = {
// --- a task in a list ----------------------------------------------------------
'task.add': 'Tilføj',
'task.new': 'Ny opgave',
'task.syntaxProject': 'projekt',
'task.syntaxSection': 'sektion',
'task.syntaxLabel': 'etiket',
'task.syntaxPriority': 'prioritet',
'task.syntaxDateMark': 'i morgen kl 10',
'task.syntaxDate': 'forfalder',
'task.placeholder': 'Tilføj en opgave — prøv “betal moms i morgen kl 10 p1 #Firma”',
'task.complete': 'Markér som færdig',
'task.reopen': 'Genåbn opgave',
@@ -360,6 +366,8 @@ export const da = {
'Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som de var — bortset fra opgaver, du havde slettet hver for sig først; dem var der en grund til.',
'data.backupsHint':
'Databasen kopieres hver nat og de seneste fjorten gemmes. Kopierne er talt, ikke dateret: en container, der har stået slukket en måned, må ikke komme tilbage og slette dem alle for at være for gamle.',
'data.backupsSecrets':
'Tokens og kodeord til postkasser ligger krypteret i kopien, og nøglen gør ikke: den ligger i en fil ved siden af databasen. Det er meningen — en kopi, der kommer på afveje, åbner ikke din mail. Men gendanner du en kopi et sted uden den fil, skal postkasserne forbindes forfra.',
'data.backupsAttachments':
'Vedhæftede filer er ikke med. De er indholdsadresserede og bliver kun lagt til, så fjorten ens kopier af dem ville fylde det drev op, de skulle beskytte — tag hele /data, hvis du vil have det hele.',
'data.restoreHint':
@@ -369,6 +377,8 @@ export const da = {
'data.templatesHint':
'Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er relative, så de bliver regnet ud fra den startdato, du vælger.',
'keys.anyLetter': 'et bogstav',
'keys.typeToCapture': 'begynder en opgave, med bogstavet i feltet',
'keys.navigation': 'Navigation',
'keys.jumpToQuickAdd': 'Hop til feltet “tilføj opgave”',
'keys.thisPage': 'Denne side',
@@ -449,6 +459,22 @@ export const da = {
'detail.attachFile': 'Vedhæft en fil',
// --- settings: integrations --------------------------------------------------------
'int.mailboxes': 'Postkasser',
'int.mailboxesHint':
'Flag en mail i iCloud, Fastmail eller enhver anden udbyder, og den bliver en opgave. Én vej: fjerner du flaget, sker der ikke noget med opgaven. Office 365 kan ikke bruges her — Microsoft har lukket for kodeord på IMAP.',
'int.addMailbox': 'Tilføj en postkasse',
'int.host': 'IMAP-server',
'int.username': 'Brugernavn',
'int.appPassword': 'App-kodeord',
'int.appPasswordHint':
'Ikke dit almindelige kodeord. iCloud og de fleste andre laver et særskilt et til programmer, som du kan trække tilbage alene. Det gemmes krypteret.',
'int.connect': 'Forbind',
'int.connecting': 'Forbinder …',
'int.disconnect': 'Kobl fra',
'int.disconnectMailbox': 'Kobl {name} fra? Opgaverne, den har lavet, bliver.',
'int.mailboxConnected': 'Postkassen er forbundet.',
'int.fetchNow': 'Hent nu',
'int.fetching': 'Henter …',
'int.gmail': 'Gmail',
'int.gmailHint':
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.',
@@ -497,6 +523,9 @@ export const da = {
'int.server': 'Server',
'int.caldavAuth':
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
'int.gmailFetchedOne': '{n} opgave hentet.',
'int.gmailFetchedMany': '{n} opgaver hentet.',
'int.gmailNothingNew': 'Ingen nye beskeder.',
'int.gmailConnected': 'Gmail er forbundet.',
'int.gmailState':
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
@@ -650,6 +679,8 @@ export const da = {
'error.gmailSaid': 'Gmail svarede: {what}',
'error.mailboxRefused': 'Postkassen kunne ikke læses.',
'error.mailboxSaid': 'Postkassen svarede: {what}',
'error.gmailRefused': 'Gmail afviste kaldet. Prøv at forbinde postkassen igen.',
'int.newAddressQuestion': 'Ny adresse? Mail til den gamle bliver afvist.',
+31
View File
@@ -53,6 +53,12 @@ export const en = {
// --- a task in a list ----------------------------------------------------------
'task.add': 'Add',
'task.new': 'New task',
'task.syntaxProject': 'project',
'task.syntaxSection': 'section',
'task.syntaxLabel': 'label',
'task.syntaxPriority': 'priority',
'task.syntaxDateMark': 'tomorrow at 10',
'task.syntaxDate': 'due',
'task.placeholder': 'Add a task — try “pay VAT tomorrow at 10 p1 #Company”',
'task.complete': 'Mark as done',
'task.reopen': 'Reopen task',
@@ -355,6 +361,8 @@ export const en = {
'Deleted projects, with their tasks. They come back exactly as they were — except for tasks you had deleted individually first; there was a reason for those.',
'data.backupsHint':
'The database is copied every night and the most recent fourteen are kept. Counted, not dated: a container that has been off for a month must not come back and delete all of them for being too old.',
'data.backupsSecrets':
'Tokens and mailbox passwords are encrypted inside the copy, and the key is not: it sits in a file beside the database. That is the point — a copy that goes astray does not open your mail. But restore a copy somewhere without that file and the mailboxes must be connected again.',
'data.backupsAttachments':
'Attachments are not included. They are content-addressed and only ever added to, so fourteen identical copies would fill the volume they were meant to protect — take the whole of /data if you want everything.',
'data.restoreHint':
@@ -364,6 +372,8 @@ export const en = {
'data.templatesHint':
'A project saved as a shape you can use again. The dates in a template are relative, so they are worked out from the start date you pick.',
'keys.anyLetter': 'any letter',
'keys.typeToCapture': 'starts a task, with the letter already in the field',
'keys.navigation': 'Navigation',
'keys.jumpToQuickAdd': 'Jump to the “add a task” field',
'keys.thisPage': 'This page',
@@ -445,6 +455,22 @@ export const en = {
'detail.attachFile': 'Attach a file',
// --- settings: integrations --------------------------------------------------------
'int.mailboxes': 'Mailboxes',
'int.mailboxesHint':
'Flag an email in iCloud, Fastmail or any other provider and it becomes a task. One way: removing the flag does nothing to the task. Office 365 cannot be used here — Microsoft has closed IMAP to passwords.',
'int.addMailbox': 'Add a mailbox',
'int.host': 'IMAP server',
'int.username': 'Username',
'int.appPassword': 'App password',
'int.appPasswordHint':
'Not your ordinary password. iCloud and most others issue a separate one for applications, which you can withdraw on its own. It is stored encrypted.',
'int.connect': 'Connect',
'int.connecting': 'Connecting …',
'int.disconnect': 'Disconnect',
'int.disconnectMailbox': 'Disconnect {name}? The tasks it made will stay.',
'int.mailboxConnected': 'The mailbox is connected.',
'int.fetchNow': 'Fetch now',
'int.fetching': 'Fetching …',
'int.gmail': 'Gmail',
'int.gmailHint':
'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.',
@@ -493,6 +519,9 @@ export const en = {
'int.server': 'Server',
'int.caldavAuth':
'The username is your email. The password is an API token — not your real password.',
'int.gmailFetchedOne': '{n} task fetched.',
'int.gmailFetchedMany': '{n} tasks fetched.',
'int.gmailNothingNew': 'No new messages.',
'int.gmailConnected': 'Gmail is connected.',
'int.gmailState': 'Google’s reply did not match the attempt that started here. Try again.',
'int.gmailExpired': 'The attempt expired on the way. Try again.',
@@ -645,6 +674,8 @@ export const en = {
'error.gmailSaid': 'Gmail said: {what}',
'error.mailboxRefused': 'The mailbox could not be read.',
'error.mailboxSaid': 'The mailbox answered: {what}',
'error.gmailRefused': 'Gmail refused the call. Try connecting the mailbox again.',
'int.newAddressQuestion': 'A new address? Mail to the old one is refused.',
+38 -24
View File
@@ -29,16 +29,31 @@
});
/**
* Global shortcuts, mapped the way Todoist maps them so muscle memory carries
* over. They are ignored while a text field has focus — otherwise typing "t"
* in a task title would navigate away mid-sentence.
* Global keys.
*
* Typing anywhere starts a task, with the letter already in the field. That is
* the whole gesture: no button to hit first, no shortcut to remember, and the
* thought you had while looking at a list does not have to survive a journey to
* find somewhere to put it.
*
* It costs the single-letter navigation. "t" for today and "u" for upcoming
* cannot also be the first letter of "tal med Anders" — one keyboard cannot
* serve both, and capturing is the thing done fifty times a day. Navigation
* lives in the palette, which does more and is one keystroke away.
*
* Ignored while a field already has focus, while a task is open — the drawer is
* a place you are, not one you pass through — and for anything held with a
* modifier, which belongs to the browser and the system.
*/
function onkeydown(event) {
const el = event.target;
const typing =
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el?.isContentEditable;
// Only when nothing has focus. The first version asked the narrower question
// — "is this a text field?" — and so captured a letter typed at a focused
// select, button or link, which is where that letter belongs. A key pressed
// at something is meant for it; only a key pressed at the page itself is
// spare.
const idle = el === document.body || el === document.documentElement;
const typing = !idle;
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
@@ -46,25 +61,24 @@
return;
}
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
// The navigation shortcuts are for the list. With a task open, "t" means the
// letter t — the drawer is a place you are, not a place you pass through.
if (app.detailId) return;
switch (event.key) {
case 'q':
event.preventDefault();
document.querySelector('input[aria-label="Ny opgave"]')?.focus();
break;
case 't':
goto('/');
break;
case 'u':
goto('/upcoming');
break;
case '?':
goto('/genveje');
break;
}
// One character, so Tab, Escape, the arrows and the function keys stay
// themselves. Space is excluded too: a task that begins with a space begins
// with a mis-hit, and space is what people press to scroll a long list.
if (event.key.length !== 1 || event.key === ' ') return;
// Found by a marker rather than by its label. This used to look the field up
// by aria-label="Ny opgave", which meant the shortcut quietly did nothing for
// anybody running the interface in English.
const field = document.querySelector('[data-quickadd]');
if (!field) return;
event.preventDefault();
field.focus();
field.value += event.key;
// Svelte binds on input, and setting .value in script does not raise one.
field.dispatchEvent(new Event('input', { bubbles: true }));
}
</script>
+10 -6
View File
@@ -1,16 +1,20 @@
<script>
/** The shortcuts, listed. Mapped to Todoist's so muscle memory carries over. */
/**
* The shortcuts, listed.
*
* The single-letter navigation this page used to carry — q, t, u — is gone.
* Typing anywhere now starts a task, and one keyboard cannot both spell "tal
* med Anders" and jump to Today on the same "t". Navigation moved into the
* palette, which does more and costs one keystroke.
*/
import { t } from '$lib/i18n.svelte.js';
const groups = $derived([
{
title: t('keys.navigation'),
keys: [
['q', t('keys.jumpToQuickAdd')],
['t', t('nav.today')],
['u', t('nav.upcoming')],
['⌘K / Ctrl+K', t('nav.search')],
['?', t('keys.thisPage')]
[t('keys.anyLetter'), t('keys.typeToCapture')],
['⌘K / Ctrl+K', t('nav.search')]
]
},
{
+37
View File
@@ -12,6 +12,7 @@
* projects answers is what is left.
*/
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app, projectName } from '$lib/stores.svelte.js';
import { colorVar } from '$lib/colors.js';
@@ -29,6 +30,18 @@
load(id);
});
/** Says what happens to the projects, because "delete the group" reads like it
* might take them with it. It does not: only the heading goes. */
async function removeGroup() {
const count = projects.length;
const kept =
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
const question = t('sidebar.deleteGroup', { name: group.name });
if (!confirm(count ? `${question} ${kept}` : question)) return;
await app.deleteGroup(group.id);
goto('/');
}
async function load(groupId) {
if (!groupId) return;
status = 'loading';
@@ -208,6 +221,14 @@
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
</label>
</section>
<!-- Deleting lives here rather than in the sidebar, where two words sat on
top of the group's name and where you could press them without having
seen what the group holds. Here the projects are on the screen above
it, which is the whole argument for the button being here. -->
<section class="danger">
<button class="remove" onclick={removeGroup}>{t('sidebar.delete')}</button>
</section>
{/if}
</div>
@@ -230,6 +251,22 @@
gap: var(--s3);
}
.danger {
margin-top: var(--s4);
padding-top: var(--s4);
border-top: 1px solid var(--line);
}
.danger .remove {
font-size: var(--text-sm);
color: var(--ink-faint);
padding: var(--s2) 0;
}
.danger .remove:hover {
color: var(--danger);
}
h1 {
font-size: var(--text-2xl);
min-width: 0;
@@ -267,6 +267,13 @@
<p class="hint">
{t('data.backupsAttachments')}
</p>
<!-- Said here rather than only in the documentation: this is the page where
somebody downloads the file, and the day the key matters is the day
they are restoring it somewhere else and wondering why the mailbox is
silent. -->
<p class="hint">
{t('data.backupsSecrets')}
</p>
</header>
<div class="row">
@@ -3,7 +3,7 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { t } from '$lib/i18n.svelte.js';
import { t, plural } from '$lib/i18n.svelte.js';
// --- Gmail ------------------------------------------------------------------
@@ -115,7 +115,9 @@
try {
const result = await api.syncGmail();
app.toast(
result?.created ? `${result.created} opgave(r) hentet.` : 'Ingen nye beskeder.'
result?.created
? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany')
: t('int.gmailNothingNew')
);
} catch (e) {
app.toast(humanMessage(e));
@@ -180,6 +182,73 @@
app.toast(t('int.copyFailed'));
}
}
// --- mailboxes ----------------------------------------------------------------
let mailboxes = $state([]);
let addingMailbox = $state(false);
let connecting = $state(false);
let syncingId = $state(null);
let newMailbox = $state({ host: '', username: '', password: '' });
$effect(() => {
loadMailboxes();
});
async function loadMailboxes() {
try {
mailboxes = (await api.mailboxes()).mailboxes ?? [];
} catch {
// A settings page that will not render because one panel could not load
// is worse than a panel that is empty.
mailboxes = [];
}
}
async function addMailbox(event) {
event.preventDefault();
connecting = true;
try {
// The server dials before it saves, so an error here is the host's own
// refusal — a wrong app password, a host that is not listening.
await api.addMailbox({ ...newMailbox });
newMailbox = { host: '', username: '', password: '' };
addingMailbox = false;
await loadMailboxes();
app.toast(t('int.mailboxConnected'));
} catch (e) {
app.toast(humanMessage(e));
} finally {
connecting = false;
}
}
async function syncMailbox(box) {
syncingId = box.id;
try {
const result = await api.syncMailbox(box.id);
app.toast(
result?.created
? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany')
: t('int.gmailNothingNew')
);
await loadMailboxes();
} catch (e) {
app.toast(humanMessage(e));
} finally {
syncingId = null;
}
}
async function removeMailbox(box) {
if (!confirm(t('int.disconnectMailbox', { name: box.name }))) return;
try {
await api.deleteMailbox(box.id);
await loadMailboxes();
} catch (e) {
app.toast(humanMessage(e));
}
}
</script>
<section class="panel">
@@ -350,6 +419,66 @@
</div>
</section>
<!-- Mailboxes read over IMAP: iCloud, Fastmail, any ordinary host. Its own panel
rather than a second mode inside the Gmail one, because they are not the same
thing wearing different clothes — one is an app you sign in through, the other
is a password you hold. -->
<section class="panel">
<header>
<h2>{t('int.mailboxes')}</h2>
<p class="hint">{t('int.mailboxesHint')}</p>
</header>
{#if mailboxes.length}
<ul class="mailboxes">
{#each mailboxes as box (box.id)}
<li>
<div class="what">
<strong>{box.name}</strong>
<span class="hint">{box.username} · {box.host}</span>
</div>
<button class="ghost" onclick={() => syncMailbox(box)} disabled={syncingId === box.id}>
{syncingId === box.id ? t('int.fetching') : t('int.fetchNow')}
</button>
<button class="ghost remove" onclick={() => removeMailbox(box)}>
{t('int.disconnect')}
</button>
</li>
{/each}
</ul>
{/if}
{#if addingMailbox}
<form class="add-mailbox" onsubmit={addMailbox}>
<div class="field">
<label for="mb-host">{t('int.host')}</label>
<input id="mb-host" bind:value={newMailbox.host} placeholder="imap.mail.me.com:993" />
</div>
<div class="field">
<label for="mb-user">{t('int.username')}</label>
<input id="mb-user" bind:value={newMailbox.username} autocomplete="off" />
</div>
<div class="field">
<label for="mb-pass">{t('int.appPassword')}</label>
<input id="mb-pass" type="password" bind:value={newMailbox.password} autocomplete="off" />
<p class="hint">{t('int.appPasswordHint')}</p>
</div>
<div class="row">
<button type="submit" class="primary" disabled={connecting}>
{connecting ? t('int.connecting') : t('int.connect')}
</button>
<button type="button" class="ghost" onclick={() => (addingMailbox = false)}>
{t('account.cancel')}
</button>
</div>
</form>
{:else}
<div class="row">
<button class="ghost" onclick={() => (addingMailbox = true)}>{t('int.addMailbox')}</button>
</div>
{/if}
</section>
<style>
/* Folded away once it is set: it is a one-off registration, and a panel that
keeps showing its own setup instructions after setup reads as unfinished. */