Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/pgstore.go
T
jochen 70c1110c53
mesh/merge-gate out of date: judged against trunk e22326240833, the trunk is now 482232e8c51c; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk e22326240833, the trunk is now 482232e8c51c; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
Add mesh-mail: watch the operator's own mailbox read-only and say each mail on the bus
ADR 0359 decided one module holds the operator's mail accounts. This is its
first stage: an imap adapter that keeps IDLE on the inbox and polls the other
folders read-only, indexes each message in its own database, keeps each
attachment once by its SHA-256 in its bucket, and says every added, changed or
removed message as a metadata-only event. Bodies and attachments are served
only by its tools, to callers the trusted setting grants names. Its health
waits for the account's own secret until it is given at the desk (issue 710).
2026-10-14 20:14:42 +02:00

505 lines
16 KiB
Go

package main
// The index on the mesh's postgres (novox/hq ADR 0359 §3): the database the mesh provisions for this module through
// the postgres-database provision, its address in the file DATABASE_URL_FILE names. Its schema is numbered migrations
// in migrations/, applied by the preparation step (`mesh-mail prepare`, ADR 0135) and recorded in the database itself;
// a fresh database is given the consolidated schema, schema.sql, at once, with every migration recorded as applied.
// Nothing else changes the schema: the keeper, the watch and the tools refuse a schema that is behind, and say so.
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
//go:embed schema.sql
var consolidated string
// Migration is one numbered change of the schema.
type Migration struct {
Number int
Name string
SQL string
Checksum string
}
var migrationFile = regexp.MustCompile(`^(\d{4})-([a-z0-9-]+)\.sql$`)
// Migrations are the migrations this binary carries, in order, numbered from 0001 without a gap.
func Migrations() ([]Migration, error) {
entries, err := fs.ReadDir(migrationFiles, "migrations")
if err != nil {
return nil, err
}
var out []Migration
for _, e := range entries {
m := migrationFile.FindStringSubmatch(e.Name())
if m == nil {
return nil, fmt.Errorf("%s is not a migration's name (NNNN-what-it-does.sql)", e.Name())
}
n, _ := strconv.Atoi(m[1])
body, err := fs.ReadFile(migrationFiles, path.Join("migrations", e.Name()))
if err != nil {
return nil, err
}
if strings.TrimSpace(string(body)) == "" {
return nil, fmt.Errorf("%s is empty", e.Name())
}
sum := sha256.Sum256(body)
out = append(out, Migration{Number: n, Name: m[2], SQL: string(body), Checksum: hex.EncodeToString(sum[:])})
}
sort.Slice(out, func(i, j int) bool { return out[i].Number < out[j].Number })
for i, m := range out {
if m.Number != i+1 {
return nil, fmt.Errorf("the migrations are not numbered 0001 on without a gap: %04d is the %d. one", m.Number, i+1)
}
}
return out, nil
}
const ledger = `create table if not exists migration (
number integer primary key,
name text not null,
checksum text not null,
applied timestamptz not null default now())`
// lockKey is the advisory lock every migration run takes, so two preparations never interleave.
const lockKey int64 = 4_250_359_001
// PgStore is the Store on postgres.
type PgStore struct{ pool *pgxpool.Pool }
// PgStoreFromEnv opens the index the mesh provisioned.
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
file := os.Getenv("DATABASE_URL_FILE")
if file == "" {
return nil, errors.New("DATABASE_URL_FILE is not set: the index's database is a requirement the mesh resolves")
}
raw, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("the database's address cannot be read: %w", err)
}
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
}
// OpenPgStore opens the index at an address.
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, err
}
return &PgStore{pool: pool}, nil
}
// Close closes the store.
func (s *PgStore) Close() { s.pool.Close() }
// Prepare brings the schema to what this version needs, and says what it did.
func (s *PgStore) Prepare(ctx context.Context) ([]string, error) {
migrations, err := Migrations()
if err != nil {
return nil, err
}
conn, err := s.pool.Acquire(ctx)
if err != nil {
return nil, err
}
defer conn.Release()
if _, err := conn.Exec(ctx, `select pg_advisory_lock($1)`, lockKey); err != nil {
return nil, fmt.Errorf("the migration lock cannot be taken: %w", err)
}
defer func() { _, _ = conn.Exec(context.WithoutCancel(ctx), `select pg_advisory_unlock($1)`, lockKey) }()
if _, err := conn.Exec(ctx, ledger); err != nil {
return nil, err
}
applied := map[int]string{}
rows, err := conn.Query(ctx, `select number, checksum from migration`)
if err != nil {
return nil, err
}
highest := 0
for rows.Next() {
var n int
var sum string
if err := rows.Scan(&n, &sum); err != nil {
rows.Close()
return nil, err
}
applied[n] = sum
highest = max(highest, n)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
record := func(tx pgx.Tx, m Migration) error {
_, err := tx.Exec(ctx, `insert into migration (number, name, checksum) values ($1, $2, $3)`, m.Number, m.Name, m.Checksum)
return err
}
if len(applied) == 0 {
var exists bool
if err := conn.QueryRow(ctx, `select to_regclass('message') is not null`).Scan(&exists); err != nil {
return nil, err
}
if exists {
return nil, errors.New("the database holds the index's tables and no record of a migration: its contents are not known, so nothing is changed")
}
tx, err := conn.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, consolidated); err != nil {
return nil, fmt.Errorf("the consolidated schema failed, and nothing of it was kept: %w", err)
}
for _, m := range migrations {
if err := record(tx, m); err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return []string{fmt.Sprintf("a fresh database: the consolidated schema, through migration %04d", len(migrations))}, nil
}
var problems []string
var pending []Migration
for _, m := range migrations {
sum, ran := applied[m.Number]
switch {
case ran && sum != m.Checksum:
problems = append(problems, fmt.Sprintf("migration %04d-%s ran, and its file has changed since: write a new one", m.Number, m.Name))
case !ran && m.Number < highest:
problems = append(problems, fmt.Sprintf("migration %04d-%s never ran, and %04d did: renumber it above %04d", m.Number, m.Name, highest, highest))
case !ran:
pending = append(pending, m)
}
}
if len(problems) > 0 {
return nil, errors.New("this database and these migrations disagree: " + strings.Join(problems, "; "))
}
var said []string
for _, m := range pending {
err := func() error {
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx, m.SQL); err != nil {
return fmt.Errorf("migration %04d-%s failed, and nothing it did was kept: %w", m.Number, m.Name, err)
}
if err := record(tx, m); err != nil {
return err
}
return tx.Commit(ctx)
}()
if err != nil {
return said, err
}
said = append(said, fmt.Sprintf("migration %04d-%s applied", m.Number, m.Name))
}
if len(said) == 0 {
said = []string{fmt.Sprintf("the schema is at migration %04d, as this version needs", len(migrations))}
}
return said, nil
}
// Ready says why the index cannot serve this version, or nil.
func (s *PgStore) Ready(ctx context.Context) error {
migrations, err := Migrations()
if err != nil {
return err
}
var at int
err = s.pool.QueryRow(ctx, `select coalesce(max(number), 0) from migration`).Scan(&at)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "42P01" {
at = 0
} else if err != nil {
return fmt.Errorf("the database cannot be reached: %w", err)
}
if at != len(migrations) {
return fmt.Errorf("the database's schema is at migration %04d and this version needs %04d: the preparation step "+
"(mesh-mail prepare) has not run", at, len(migrations))
}
return nil
}
func (s *PgStore) Folder(ctx context.Context, account, folder string) (Folder, bool, error) {
f := Folder{Account: account, Name: folder}
var validity int64
err := s.pool.QueryRow(ctx, `select uidvalidity, messages, synced from folder where account = $1 and name = $2`, account, folder).
Scan(&validity, &f.Messages, &f.Synced)
if errors.Is(err, pgx.ErrNoRows) {
return f, false, nil
}
f.UIDValidity = uint32(validity)
return f, err == nil, err
}
func (s *PgStore) SetFolder(ctx context.Context, f Folder) error {
_, err := s.pool.Exec(ctx, `insert into folder (account, name, uidvalidity, messages, synced) values ($1, $2, $3, $4, $5)
on conflict (account, name) do update set uidvalidity = excluded.uidvalidity, messages = excluded.messages, synced = excluded.synced`,
f.Account, f.Name, int64(f.UIDValidity), f.Messages, f.Synced)
return err
}
func (s *PgStore) DropFolder(ctx context.Context, account, folder string) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `delete from message where account = $1 and folder = $2`, account, folder); err != nil {
return err
}
_, err := tx.Exec(ctx, `delete from folder where account = $1 and name = $2`, account, folder)
return err
})
}
func (s *PgStore) Flags(ctx context.Context, account, folder string) (map[uint32][]string, error) {
rows, err := s.pool.Query(ctx, `select uid, flags from message where account = $1 and folder = $2`, account, folder)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[uint32][]string{}
for rows.Next() {
var uid int64
var flags []string
if err := rows.Scan(&uid, &flags); err != nil {
return nil, err
}
out[uint32(uid)] = flags
}
return out, rows.Err()
}
func keepEvent(ctx context.Context, tx pgx.Tx, e *Event) error {
if e == nil {
return nil
}
body, err := json.Marshal(e.Body)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `insert into outbox (key, body) values ($1, $2)`, e.Key, body)
return err
}
func (s *PgStore) Add(ctx context.Context, m Message, e *Event) error {
from, _ := json.Marshal(m.From)
to, _ := json.Marshal(m.To)
cc, _ := json.Marshal(orNone(m.Cc))
atts, _ := json.Marshal(m.Attachments)
flags := m.Flags
if flags == nil {
flags = []string{}
}
people := strings.Join([]string{peopleText(m.From), peopleText(m.To), peopleText(m.Cc)}, " ")
// An address is one word to the search, so its parts are kept beside it: "bob" finds bob@example.org.
people += " " + strings.NewReplacer("@", " ", ".", " ", ",", " ").Replace(people)
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `insert into message (account, folder, uid, uidvalidity, message_id, in_reply_to, date, sender,
recipients, copied, people, subject, size, flags, attachments, body)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
on conflict (account, folder, uid) do update set uidvalidity = excluded.uidvalidity, message_id = excluded.message_id,
in_reply_to = excluded.in_reply_to, date = excluded.date, sender = excluded.sender, recipients = excluded.recipients,
copied = excluded.copied, people = excluded.people, subject = excluded.subject, size = excluded.size,
flags = excluded.flags, attachments = excluded.attachments, body = excluded.body`,
m.Account, m.Folder, int64(m.UID), int64(m.UIDValidity), m.MessageID, m.InReplyTo, m.Date, from, to, cc, people,
strings.ToValidUTF8(strings.ReplaceAll(m.Subject, "\x00", ""), ""), m.Size, flags, atts,
strings.ReplaceAll(m.Body, "\x00", "")); err != nil {
return err
}
return keepEvent(ctx, tx, e)
})
}
func orNone(ps []Person) []Person {
if ps == nil {
return []Person{}
}
return ps
}
func (s *PgStore) SetFlags(ctx context.Context, account, folder string, uid uint32, flags []string, e *Event) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `update message set flags = $4 where account = $1 and folder = $2 and uid = $3`,
account, folder, int64(uid), flags); err != nil {
return err
}
return keepEvent(ctx, tx, e)
})
}
func (s *PgStore) Remove(ctx context.Context, account, folder string, uid uint32, e *Event) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `delete from message where account = $1 and folder = $2 and uid = $3`, account, folder, int64(uid)); err != nil {
return err
}
return keepEvent(ctx, tx, e)
})
}
const messageColumns = `account, folder, uid, uidvalidity, message_id, in_reply_to, date, sender, recipients, copied, subject,
size, flags, attachments`
func scanMessage(row pgx.Row, withBody bool) (Message, error) {
var m Message
var uid, validity int64
var from, to, cc, atts []byte
dest := []any{&m.Account, &m.Folder, &uid, &validity, &m.MessageID, &m.InReplyTo, &m.Date, &from, &to, &cc, &m.Subject,
&m.Size, &m.Flags, &atts}
if withBody {
dest = append(dest, &m.Body)
}
if err := row.Scan(dest...); err != nil {
return m, err
}
m.UID, m.UIDValidity, m.Date = uint32(uid), uint32(validity), m.Date.UTC()
for _, f := range []struct {
raw []byte
into any
}{{from, &m.From}, {to, &m.To}, {cc, &m.Cc}, {atts, &m.Attachments}} {
if err := json.Unmarshal(f.raw, f.into); err != nil {
return m, err
}
}
return m, nil
}
func (s *PgStore) Get(ctx context.Context, account, folder string, uid uint32) (Message, bool, error) {
m, err := scanMessage(s.pool.QueryRow(ctx, `select `+messageColumns+`, body from message where account = $1 and folder = $2 and uid = $3`,
account, folder, int64(uid)), true)
if errors.Is(err, pgx.ErrNoRows) {
return Message{}, false, nil
}
return m, err == nil, err
}
func (s *PgStore) Search(ctx context.Context, q Query) ([]Message, error) {
limit := q.Limit
if limit <= 0 {
limit = 20
}
rows, err := s.pool.Query(ctx, `select `+messageColumns+` from message
where account = any($1) and ($2 = '' or folder = $2) and search @@ plainto_tsquery('simple'::regconfig, $3)
order by date desc limit $4`, q.Accounts, q.Folder, q.Text, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Message
for rows.Next() {
m, err := scanMessage(rows, false)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *PgStore) Folders(ctx context.Context, account string) ([]Folder, error) {
rows, err := s.pool.Query(ctx, `select name, uidvalidity, messages, synced from folder where account = $1 order by name`, account)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Folder
for rows.Next() {
f := Folder{Account: account}
var validity int64
if err := rows.Scan(&f.Name, &validity, &f.Messages, &f.Synced); err != nil {
return nil, err
}
f.UIDValidity, f.Synced = uint32(validity), f.Synced.UTC()
out = append(out, f)
}
return out, rows.Err()
}
func (s *PgStore) Pending(ctx context.Context, limit int) ([]Event, error) {
rows, err := s.pool.Query(ctx, `select id, key, body from outbox order by id limit $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Event
for rows.Next() {
var e Event
var body []byte
if err := rows.Scan(&e.ID, &e.Key, &body); err != nil {
return nil, err
}
if err := json.Unmarshal(body, &e.Body); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func (s *PgStore) Said(ctx context.Context, ids []int64) error {
_, err := s.pool.Exec(ctx, `delete from outbox where id = any($1)`, ids)
return err
}
func (s *PgStore) SetState(ctx context.Context, st AccountState) error {
_, err := s.pool.Exec(ctx, `insert into account_state (account, state, why, heard, synced, said) values ($1, $2, $3, $4, $5, $6)
on conflict (account) do update set state = excluded.state, why = excluded.why, heard = excluded.heard,
synced = excluded.synced, said = excluded.said`,
st.Account, st.State, st.Why, nullTime(st.Heard), nullTime(st.Synced), st.Said)
return err
}
func nullTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
func (s *PgStore) States(ctx context.Context) (map[string]AccountState, error) {
rows, err := s.pool.Query(ctx, `select account, state, why, heard, synced, said from account_state`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]AccountState{}
for rows.Next() {
var st AccountState
var heard, synced *time.Time
if err := rows.Scan(&st.Account, &st.State, &st.Why, &heard, &synced, &st.Said); err != nil {
return nil, err
}
if heard != nil {
st.Heard = heard.UTC()
}
if synced != nil {
st.Synced = synced.UTC()
}
st.Said = st.Said.UTC()
out[st.Account] = st
}
return out, rows.Err()
}