Compare commits

...
3 Commits
Author SHA1 Message Date
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
16 changed files with 1208 additions and 1 deletions
+1
View File
@@ -74,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: |
+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
+211
View File
@@ -0,0 +1,211 @@
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, 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))
}
// 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
}
+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)
+13 -1
View File
@@ -17,6 +17,7 @@ package imap
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"strings"
@@ -41,6 +42,13 @@ type Account struct {
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.
@@ -55,7 +63,11 @@ func Dial(a Account) (*Client, error) {
}
c, err := imapclient.DialTLS(a.Host, &imapclient.Options{
TLSConfig: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12},
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)
+183
View File
@@ -0,0 +1,183 @@
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
}
// 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 := "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
}
+43
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
}
@@ -61,6 +65,7 @@ func (r *Runner) Start(ctx context.Context) {
// 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() }
@@ -272,6 +277,44 @@ func (r *Runner) syncGmailAccounts(ctx context.Context) error {
return nil
}
// syncMailboxes polls every connected IMAP mailbox.
//
// Nobody is waiting, so no budget of its own — the request handler has one because
// a person is watching a spinner. A host that has gone quiet costs this run its
// own dial timeout and nothing else: the loop moves on, per mailbox and per
// person, so one bad password does not stop everybody else's mail.
func (r *Runner) syncMailboxes(ctx context.Context) error {
if r.SyncMailbox == nil {
return nil
}
userIDs, err := r.db.UsersWithMailboxes(ctx)
if err != nil {
return err
}
for _, id := range userIDs {
user, err := r.db.UserByID(ctx, id)
if err != nil || user == nil {
continue
}
boxes, err := r.db.Mailboxes(ctx, id)
if err != nil {
r.log.Warn("list mailboxes", "err", err, "user", id)
continue
}
for i := range boxes {
created, err := r.SyncMailbox(ctx, user, &boxes[i])
if err != nil {
r.log.Warn("mailbox sync", "err", err, "user", id, "mailbox", boxes[i].ID)
continue
}
if created > 0 {
r.log.Info("tasks created from a mailbox", "user", id, "count", created)
}
}
}
return nil
}
// --- housekeeping -----------------------------------------------------------------
func (r *Runner) emptyTrash(ctx context.Context) error {
+210
View File
@@ -0,0 +1,210 @@
package store
import (
"context"
"database/sql"
"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"`
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, 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
}
// 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)
}
_, 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,
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.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
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.LastUID, &lastSync, &created)
if err != nil {
return m, err
}
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)
}
+173
View File
@@ -0,0 +1,173 @@
package store
import (
"context"
"strings"
"testing"
"time"
)
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")
}
}
@@ -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);
+32
View File
@@ -1814,3 +1814,35 @@ test('man kan skrive hvor som helst, og feltet fortæller hvad det forstår', as
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'),
+18
View File
@@ -459,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.',
@@ -663,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.',
+18
View File
@@ -455,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.',
@@ -658,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.',
@@ -182,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">
@@ -352,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. */