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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,12 @@ type Mailbox struct {
|
|||||||
AccessToken string `json:"-"`
|
AccessToken string `json:"-"`
|
||||||
ExpiresAt time.Time `json:"-"`
|
ExpiresAt time.Time `json:"-"`
|
||||||
Label string `json:"label,omitempty"`
|
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"`
|
LastUID uint32 `json:"last_uid,omitempty"`
|
||||||
LastSyncAt time.Time `json:"last_sync_at,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,
|
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.
|
// Mailboxes returns everything one person has connected, oldest first.
|
||||||
func (db *DB) Mailboxes(ctx context.Context, userID string) ([]Mailbox, error) {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("seal access_token: %w", err)
|
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, `
|
_, err = db.ExecContext(ctx, `
|
||||||
INSERT INTO mailboxes (`+mailboxColumns+`)
|
INSERT INTO mailboxes (`+mailboxColumns+`)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
host = excluded.host,
|
host = excluded.host,
|
||||||
@@ -107,10 +124,12 @@ func (db *DB) SaveMailbox(ctx context.Context, m *Mailbox) error {
|
|||||||
access_token = excluded.access_token,
|
access_token = excluded.access_token,
|
||||||
expires_at = excluded.expires_at,
|
expires_at = excluded.expires_at,
|
||||||
label = excluded.label,
|
label = excluded.label,
|
||||||
|
trigger_kind = excluded.trigger_kind,
|
||||||
|
seen = excluded.seen,
|
||||||
last_uid = excluded.last_uid,
|
last_uid = excluded.last_uid,
|
||||||
last_sync_at = excluded.last_sync_at`,
|
last_sync_at = excluded.last_sync_at`,
|
||||||
m.ID, m.UserID, m.Kind, m.Name, m.Host, m.Username, password, m.Folder,
|
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())
|
m.LastUID, unixOrZero(m.LastSyncAt), m.CreatedAt.Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -157,12 +176,16 @@ type scanner interface {
|
|||||||
func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
|
func (db *DB) scanMailbox(row scanner) (Mailbox, error) {
|
||||||
var m Mailbox
|
var m Mailbox
|
||||||
var expires, lastSync, created int64
|
var expires, lastSync, created int64
|
||||||
|
var seen string
|
||||||
err := row.Scan(&m.ID, &m.UserID, &m.Kind, &m.Name, &m.Host, &m.Username,
|
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.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 {
|
if err != nil {
|
||||||
return m, err
|
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} {
|
for _, field := range []*string{&m.Password, &m.RefreshToken, &m.AccessToken} {
|
||||||
plain, err := db.unsealValue(*field)
|
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