feat(mailboxes): plads til Gmails udløser og dens set-liste
Kolonnerne, ikke flytningen. Gmail-handlerne læser stadig user_settings, og en migration, der tømte den tabel før de var skrevet om, ville koble en fungerende postkasse fra. Det additive først; flytningen når koden på den anden side er klar til den, og den ligger som PENDING_gmail_move.sql.txt uden for .sql-mønstret, så den ikke kan køre ved et uheld. Udløseren fik sin egen kolonne frem for at blive pakket ind i label med et kolon. En kolonne, der rummer to ting adskilt af et tegn, er en kolonne, nogen læser forkert senere — og en etiket med kolon i navnet ville gøre det på første dag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c9dfffd95a
commit
30adc69cb5
@@ -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)
|
||||
|
||||
@@ -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,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';
|
||||
Reference in New Issue
Block a user