From 30adc69cb5c9000fbf7f93a57e2f87d2fe30d60f Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Tue, 18 Aug 2026 22:38:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(mailboxes):=20plads=20til=20Gmails=20udl?= =?UTF-8?q?=C3=B8ser=20og=20dens=20set-liste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kolonnerne, ikke flytningen. Gmail-handlerne læser stadig user_settings, og en migration, der tømte den tabel før de var skrevet om, ville koble en fungerende postkasse fra. Det additive først; flytningen når koden på den anden side er klar til den, og den ligger som PENDING_gmail_move.sql.txt uden for .sql-mønstret, så den ikke kan køre ved et uheld. Udløseren fik sin egen kolonne frem for at blive pakket ind i label med et kolon. En kolonne, der rummer to ting adskilt af et tegn, er en kolonne, nogen læser forkert senere — og en etiket med kolon i navnet ville gøre det på første dag. Co-Authored-By: Claude Opus 5 --- internal/store/mailboxes.go | 31 ++++++++++-- .../0015_mailbox_trigger_and_seen.sql | 22 +++++++++ .../migrations/PENDING_gmail_move.sql.txt | 48 +++++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 internal/store/migrations/0015_mailbox_trigger_and_seen.sql create mode 100644 internal/store/migrations/PENDING_gmail_move.sql.txt diff --git a/internal/store/mailboxes.go b/internal/store/mailboxes.go index 6d4d295..09a6a30 100644 --- a/internal/store/mailboxes.go +++ b/internal/store/mailboxes.go @@ -3,6 +3,7 @@ package store import ( "context" "database/sql" + "encoding/json" "fmt" "time" ) @@ -23,6 +24,12 @@ type Mailbox struct { AccessToken string `json:"-"` ExpiresAt time.Time `json:"-"` Label string `json:"label,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"` @@ -30,7 +37,8 @@ type Mailbox struct { } const mailboxColumns = `id, user_id, kind, name, host, username, password, folder, - refresh_token, access_token, expires_at, label, last_uid, last_sync_at, created_at` + refresh_token, access_token, expires_at, label, trigger_kind, seen, + 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) { @@ -93,10 +101,19 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error { 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = excluded.name, host = excluded.host, @@ -107,10 +124,12 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error { access_token = excluded.access_token, expires_at = excluded.expires_at, label = excluded.label, + trigger_kind = excluded.trigger_kind, + seen = excluded.seen, 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, + refresh, access, unixOrZero(m.ExpiresAt), m.Label, m.Trigger, string(seen), m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix()) return err } @@ -157,12 +176,16 @@ type scanner interface { 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.LastUID, &lastSync, &created) + &m.Label, &m.Trigger, &seen, &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) diff --git a/internal/store/migrations/0015_mailbox_trigger_and_seen.sql b/internal/store/migrations/0015_mailbox_trigger_and_seen.sql new file mode 100644 index 0000000..e4f84c2 --- /dev/null +++ b/internal/store/migrations/0015_mailbox_trigger_and_seen.sql @@ -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'; diff --git a/internal/store/migrations/PENDING_gmail_move.sql.txt b/internal/store/migrations/PENDING_gmail_move.sql.txt new file mode 100644 index 0000000..ba39098 --- /dev/null +++ b/internal/store/migrations/PENDING_gmail_move.sql.txt @@ -0,0 +1,48 @@ +-- NOT A MIGRATION YET. Deliberately outside the .sql glob so it cannot run. +-- +-- This is the data move for task #23. It must not be applied until the Gmail +-- handlers read the mailboxes table instead of user_settings: it empties the +-- settings row, and running it early would disconnect a working mailbox. +-- +-- Rename to 0016_gmail_is_a_mailbox.sql when internal/httpapi/gmail_handlers.go +-- has been rewritten. + +-- The values move across still sealed. Both tables seal with the same key and +-- 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, + 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'), '[]'), + 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';