Compare commits
@@ -60,3 +60,8 @@ node_modules.nosync/
|
||||
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
|
||||
# it right back.
|
||||
/shots
|
||||
|
||||
# The working handover. It names the panel, the server id and the production
|
||||
# address — a map of one person's setup, which does not belong in a public repo.
|
||||
# Kept on disk because it is how a session hands over to the next one.
|
||||
HANDOFF.md
|
||||
|
||||
@@ -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,8 @@ func run() error {
|
||||
defer stopJobs()
|
||||
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
||||
runner.SyncGmail = api.SyncGmail
|
||||
runner.SyncMailbox = api.SyncMailbox
|
||||
runner.Push = api.PushToUser
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
@@ -100,6 +104,7 @@ func Load() (*Config, error) {
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -179,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
|
||||
}
|
||||
@@ -240,36 +252,40 @@ 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
|
||||
if box != nil {
|
||||
// The same guard as the mailboxes read over IMAP, for the same reason: a
|
||||
// press of "Fetch now" during the sweep would otherwise make two runs from
|
||||
// one marker.
|
||||
unlock := s.lockMailbox(box.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
|
||||
box = fresh
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -281,7 +297,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// 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
|
||||
}
|
||||
@@ -290,19 +306,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 {
|
||||
@@ -352,15 +364,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
|
||||
}
|
||||
}
|
||||
@@ -483,30 +495,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 := 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -38,8 +39,8 @@ 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)
|
||||
}
|
||||
@@ -111,9 +112,9 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
}
|
||||
// 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.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
||||
"refresh_token": "r", "access_token": "a", "trigger": "starred",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"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) {
|
||||
// One reader of this mailbox at a time. Held for the whole run, marker and all:
|
||||
// releasing before the marker is written would leave exactly the gap this is
|
||||
// here to close.
|
||||
unlock := s.lockMailbox(m.ID)
|
||||
defer unlock()
|
||||
|
||||
// Re-read under the lock. The row that came in may have been fetched before the
|
||||
// run that just finished moved the marker.
|
||||
if fresh, err := s.db.Mailbox(ctx, m.UserID, m.ID); err == nil && fresh != nil {
|
||||
m = fresh
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// lockMailbox serialises one mailbox against itself and returns the release.
|
||||
func (s *Server) lockMailbox(id string) func() {
|
||||
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
@@ -98,6 +98,12 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
|
||||
}
|
||||
}
|
||||
|
||||
// PushToUser is pushToUser for callers outside this package — the reminder job,
|
||||
// which has the schedule but not the keys.
|
||||
func (s *Server) PushToUser(userID, title, body, projectID string) {
|
||||
s.pushToUser(userID, title, body, projectID)
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
||||
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,11 @@ import (
|
||||
// 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()
|
||||
@@ -56,6 +63,7 @@ func TestAPanelThatNeverAnswersStillEndsTheRequest(t *testing.T) {
|
||||
// And a panel that says no must say so in this server's own words, not in a proxy's.
|
||||
func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer panel.Close()
|
||||
@@ -75,3 +83,69 @@ func TestAPanelThatRefusesSaysSoAsJSON(t *testing.T) {
|
||||
t.Errorf("the reply does not say what the panel answered: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Forty copies of one mail arrived in a single second because two runs read the
|
||||
// same marker before either wrote a new one — somebody pressing "Fetch now" while
|
||||
// the ten-minute sweep was already inside the same mailbox.
|
||||
//
|
||||
// The lock is what stops it, and a lock is only worth having if something tries
|
||||
// the door: this runs the two concurrently on purpose.
|
||||
func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
|
||||
// A bare server: the lock needs nothing but its own zero value, and building a
|
||||
// database and a session around it would only make the test slower to read.
|
||||
srv := &Server{}
|
||||
|
||||
var inside, most int
|
||||
var mu sync.Mutex
|
||||
release := make(chan struct{})
|
||||
|
||||
// Stands in for a run: takes the lock, notes how many are inside, and holds it
|
||||
// until told to let go.
|
||||
run := func(done chan<- struct{}) {
|
||||
unlock := srv.lockMailbox("box-1")
|
||||
mu.Lock()
|
||||
inside++
|
||||
if inside > most {
|
||||
most = inside
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
<-release
|
||||
|
||||
mu.Lock()
|
||||
inside--
|
||||
mu.Unlock()
|
||||
unlock()
|
||||
done <- struct{}{}
|
||||
}
|
||||
|
||||
first, second := make(chan struct{}), make(chan struct{})
|
||||
go run(first)
|
||||
go run(second)
|
||||
|
||||
// Long enough that both would be inside if nothing kept them apart.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(release)
|
||||
<-first
|
||||
<-second
|
||||
|
||||
if most != 1 {
|
||||
t.Errorf("%d runs were inside the same mailbox at once; that is how one mail "+
|
||||
"becomes forty tasks", most)
|
||||
}
|
||||
|
||||
// And two different mailboxes must not wait for each other: one slow host is
|
||||
// not everybody's problem.
|
||||
a := srv.lockMailbox("box-a")
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
srv.lockMailbox("box-b")()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("a second mailbox waited for the first")
|
||||
}
|
||||
a()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -40,6 +41,18 @@ type Server struct {
|
||||
mcp *mcp.Server
|
||||
updates *update.Checker
|
||||
|
||||
// syncing serialises reads of one mailbox against itself. Two runs at once —
|
||||
// somebody pressing "Fetch now" while the ten-minute sweep is in the middle of
|
||||
// the same mailbox — both start from the marker as it was, both fetch the same
|
||||
// messages, and both make tasks out of them. That is how forty copies of one
|
||||
// mail arrived in a single second.
|
||||
//
|
||||
// A map of mutexes rather than one lock: two people's mailboxes have nothing to
|
||||
// say to each other, and a single lock would make one slow host everybody's
|
||||
// problem. Never cleaned out, because the key is a mailbox id and there are as
|
||||
// many of those as there are mailboxes.
|
||||
syncing sync.Map
|
||||
|
||||
// Nil when the base URL is not something an authenticator will accept — a bare
|
||||
// hostname with no scheme, say. The endpoints answer 503 rather than the
|
||||
// process refusing to start: passkeys are one way in among three, and an
|
||||
@@ -260,6 +273,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,14 @@ 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)
|
||||
|
||||
// Push delivers a notification to somebody's devices. Supplied by the HTTP
|
||||
// layer, which owns the VAPID keys and the subscription list.
|
||||
Push func(userID, title, body, projectID string)
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -60,7 +68,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() }
|
||||
@@ -122,6 +130,15 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
|
||||
"due": rem.RemindAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// And to the devices that asked for it. This was missing: reminders went out
|
||||
// in-app and by mail, so somebody with the tab closed and no mail configured
|
||||
// got a notification that never arrived — which is the case a reminder is
|
||||
// for. Notifications from other people's actions did push; only the timed
|
||||
// ones did not, and the interface said "on for this device" either way.
|
||||
if r.Push != nil {
|
||||
r.Push(rem.UserID, rem.TaskContent, reminderBody(rem.RemindAt), rem.ProjectID)
|
||||
}
|
||||
|
||||
if r.mail.Configured() && rem.UserEmail != "" {
|
||||
link := r.cfg.BaseURL + "/projekt/" + rem.ProjectID
|
||||
if err := r.mail.SendReminder(ctx, rem.UserEmail, rem.UserName, rem.TaskContent, link); err != nil {
|
||||
@@ -135,6 +152,12 @@ func (r *Runner) sendDueReminders(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reminderBody is the line under the task's title. The time rather than the date:
|
||||
// a reminder arrives when it is due, so the day is today and saying so is noise.
|
||||
func reminderBody(at time.Time) string {
|
||||
return at.Format("15:04")
|
||||
}
|
||||
|
||||
// --- backups ---------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
@@ -247,26 +270,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
|
||||
|
||||
@@ -276,3 +276,59 @@ func backupFiles(t *testing.T, cfg *config.Config) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A reminder that nobody receives is not a reminder.
|
||||
//
|
||||
// The job sent them in-app and by mail. Somebody with the tab closed and no mail
|
||||
// server configured — which is the setup the docker one-liner produces — got
|
||||
// nothing, while the settings page said notifications were on for the device.
|
||||
// The push half was simply never wired: notifications from other people's actions
|
||||
// went out, only the timed ones did not.
|
||||
func TestADueReminderReachesTheDevices(t *testing.T) {
|
||||
runner, db, _ := newRunner(t)
|
||||
user := seedUser(t, db)
|
||||
|
||||
type sent struct{ userID, title, projectID string }
|
||||
var got []sent
|
||||
runner.Push = func(userID, title, body, projectID string) {
|
||||
got = append(got, sent{userID, title, projectID})
|
||||
}
|
||||
|
||||
inbox, err := db.InboxID(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &store.Task{ProjectID: inbox, Content: "ring til Anders", CreatedBy: user.ID}
|
||||
if err := db.CreateTask(t.Context(), task, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A minute ago, so it is due rather than upcoming.
|
||||
at := time.Now().Add(-time.Minute)
|
||||
if _, err := db.CreateReminder(t.Context(), task.ID, user.ID, &at, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := runner.sendDueReminders(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("%d reminders reached the devices, want 1", len(got))
|
||||
}
|
||||
if got[0].userID != user.ID {
|
||||
t.Errorf("it went to %q", got[0].userID)
|
||||
}
|
||||
if got[0].title != "ring til Anders" {
|
||||
t.Errorf("the title is %q, not the task", got[0].title)
|
||||
}
|
||||
|
||||
// And once. A reminder that arrives on every sweep is worse than one that does
|
||||
// not arrive at all.
|
||||
got = nil
|
||||
if err := runner.sendDueReminders(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("the same reminder went out %d more times", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
<title>verdande — self-hosted tasks, shared</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. CalDAV, mail-to-task and an MCP connector for Claude."
|
||||
content="Self-hosted tasks and projects, shared. One Go binary with SQLite inside it. Your mail as an inbox, CalDAV, passkeys, and an MCP connector for Claude."
|
||||
/>
|
||||
<meta name="theme-color" content="#111312" />
|
||||
|
||||
<meta property="og:title" content="verdande" />
|
||||
<meta property="og:description" content="Self-hosted tasks and projects, shared. One binary, SQLite inside it." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="screenshots/today.webp" />
|
||||
<meta property="og:image:alt" content="The Today view: projects and filters beside a list of what is due." />
|
||||
<!-- Summary rather than the large card: the screenshot is a dense interface,
|
||||
and shrunk into a wide banner it reads as grey noise. -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||
|
||||
@@ -339,6 +344,89 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- zoom -------------------------------------------------------------
|
||||
|
||||
A screenshot of an interface is unreadable at 280px in a three-column
|
||||
grid, which is where most of them sit. Clicking one opens it at the
|
||||
size it was taken.
|
||||
|
||||
<dialog> rather than a hand-built overlay: Escape closes it, focus is
|
||||
trapped, the rest of the page is inert, and the browser does all three
|
||||
without a line of script. */
|
||||
.shot img {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Sized so the whole thing fits: a screenshot is 1.55 times wider than it
|
||||
is tall, so on a laptop the height is what runs out first. Left to grow,
|
||||
it pushed the caption and the close button off the top of the screen —
|
||||
and the button is the visible way out. */
|
||||
dialog.zoom {
|
||||
max-width: 96vw;
|
||||
max-height: 100vh;
|
||||
/* Wraps the image rather than the viewport. With a plain `auto` the box
|
||||
stayed full width, so the screenshot sat against the left edge and
|
||||
the close button ended up in the far corner, nowhere near it. */
|
||||
width: fit-content;
|
||||
margin: auto;
|
||||
padding: 2.5rem 0 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
dialog.zoom::backdrop {
|
||||
background: rgb(0 0 0 / 0.82);
|
||||
}
|
||||
|
||||
dialog.zoom img {
|
||||
max-width: 96vw;
|
||||
max-height: 78vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
dialog.zoom figcaption {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #a8b0ac;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The whole backdrop closes, so the button is a visible affordance rather
|
||||
than the only way out. */
|
||||
dialog.zoom .close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #a8b0ac;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
dialog.zoom .close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
dialog.zoom[open] {
|
||||
animation: zoom-in 0.15s ease-out;
|
||||
}
|
||||
@keyframes zoom-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
@@ -436,7 +524,7 @@
|
||||
<h2>What it looks like</h2>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/today.png"
|
||||
src="screenshots/today.webp"
|
||||
alt="The Today view: a sidebar of projects, filters and labels beside a list of tasks due today, each with its priority ring, time and labels."
|
||||
width="1400"
|
||||
height="900"
|
||||
@@ -448,7 +536,7 @@
|
||||
<div class="shots">
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/board.png"
|
||||
src="screenshots/board.webp"
|
||||
alt="A project in board view: one column per section, with task cards showing due dates, labels and repetition."
|
||||
width="1400"
|
||||
height="900"
|
||||
@@ -458,7 +546,7 @@
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/today-light.png"
|
||||
src="screenshots/today-light.webp"
|
||||
alt="The same Today view in the light theme."
|
||||
width="1400"
|
||||
height="900"
|
||||
@@ -468,7 +556,7 @@
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/week.png"
|
||||
src="screenshots/week.webp"
|
||||
alt="Upcoming as a single week: seven columns of days, each holding the tasks due that day."
|
||||
width="1400"
|
||||
height="900"
|
||||
@@ -478,7 +566,7 @@
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/detail.png"
|
||||
src="screenshots/detail.webp"
|
||||
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
|
||||
width="1400"
|
||||
height="900"
|
||||
@@ -488,7 +576,7 @@
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/themes.png"
|
||||
src="screenshots/themes.webp"
|
||||
alt="The settings page showing the five themes as swatches."
|
||||
width="1400"
|
||||
height="900"
|
||||
@@ -498,7 +586,7 @@
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/history.png"
|
||||
src="screenshots/history.webp"
|
||||
alt="The instance-wide history page: who did what, in which project, filtered by person and event."
|
||||
width="1400"
|
||||
height="900"
|
||||
@@ -541,10 +629,20 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Mail to task</h3>
|
||||
<h3>Your mail, as an inbox</h3>
|
||||
<p>
|
||||
Forward an email to your own address and it becomes a task, subject
|
||||
parsed like anything else you type.
|
||||
Star a mail in Gmail, or flag one in iCloud or Fastmail, and it becomes
|
||||
a task. Forward one to your own address instead and the subject is
|
||||
parsed like anything else you type. One way: unstarring it later does
|
||||
nothing to the task you made.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Nothing to remember</h3>
|
||||
<p>
|
||||
Start typing anywhere and you are writing a task, with the letter you
|
||||
pressed already in the field. Sign in with a passkey and there is no
|
||||
password to forget either.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
@@ -554,6 +652,22 @@
|
||||
round trip is tested, not promised.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Keys stay behind</h3>
|
||||
<p>
|
||||
Mail tokens and mailbox passwords are encrypted in the database, and the
|
||||
key lives in a file beside it — not inside it. So a backup you download,
|
||||
or one that ends up somewhere it should not, does not open your mail.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Who did what</h3>
|
||||
<p>
|
||||
Every change is written down — who moved it, who closed it, who let
|
||||
somebody in. Delete the account and the trail stays: it is the
|
||||
instance's record, not the person's.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -605,5 +719,58 @@
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Zoom. Added by script rather than written into the markup, so a page with
|
||||
no JavaScript is exactly what it was before: the screenshots are still
|
||||
there, still readable, just not clickable. -->
|
||||
<dialog class="zoom" aria-label="Screenshot, enlarged">
|
||||
<button class="close" type="button" aria-label="Close">×</button>
|
||||
<img alt="" />
|
||||
<figcaption></figcaption>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var dialog = document.querySelector('dialog.zoom');
|
||||
if (!dialog || typeof dialog.showModal !== 'function') return;
|
||||
|
||||
var big = dialog.querySelector('img');
|
||||
var caption = dialog.querySelector('figcaption');
|
||||
|
||||
function open(img) {
|
||||
big.src = img.currentSrc || img.src;
|
||||
big.alt = img.alt;
|
||||
var fig = img.closest('figure');
|
||||
var text = fig && fig.querySelector('figcaption');
|
||||
caption.textContent = text ? text.textContent : '';
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.shot img').forEach(function (img) {
|
||||
// Focusable and operable from the keyboard: a control that only a
|
||||
// mouse can reach is not a control everybody has.
|
||||
img.tabIndex = 0;
|
||||
img.setAttribute('role', 'button');
|
||||
img.addEventListener('click', function () { open(img); });
|
||||
img.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
open(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dialog.addEventListener('click', function (event) {
|
||||
// The backdrop is the dialog itself; anything inside it is not.
|
||||
if (event.target === dialog || event.target === big ||
|
||||
event.target.classList.contains('close')) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
// Let go of the image when it closes, so a large screenshot is not
|
||||
// held in memory for the rest of the visit.
|
||||
dialog.addEventListener('close', function () { big.removeAttribute('src'); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
Before Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -1768,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([]);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
@@ -653,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.',
|
||||
|
||||
@@ -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.',
|
||||
@@ -648,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.',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')]
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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. */
|
||||
|
||||