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
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).
333 lines
9.7 KiB
Go
333 lines
9.7 KiB
Go
package main
|
|
|
|
// The index (novox/hq ADR 0359 §3): one row per message and folder, in the module's own database; the events still to
|
|
// be said on the bus, written in the same step as the change they say; and each account's state as its keeper last
|
|
// saw it, which the health watch reads. The Store is that, over postgres (pgstore.go) or in memory for the tests.
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Person is a sender or a recipient.
|
|
type Person struct {
|
|
Name string `json:"name,omitempty"`
|
|
Address string `json:"address"`
|
|
}
|
|
|
|
// AttachmentRef names an attachment by its content (§3): the bucket and key it is kept under, the key being its
|
|
// SHA-256 in hex, with its size, media type and file name.
|
|
type AttachmentRef struct {
|
|
Bucket string `json:"bucket"`
|
|
Key string `json:"key"`
|
|
Size int64 `json:"size"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// Message is one message in one folder of one account, as indexed.
|
|
type Message struct {
|
|
Account string `json:"account"`
|
|
Folder string `json:"folder"`
|
|
UIDValidity uint32 `json:"uidvalidity"`
|
|
UID uint32 `json:"uid"`
|
|
MessageID string `json:"message-id,omitempty"`
|
|
InReplyTo string `json:"in-reply-to,omitempty"`
|
|
Date time.Time `json:"date"`
|
|
From []Person `json:"from"`
|
|
To []Person `json:"to"`
|
|
Cc []Person `json:"cc,omitempty"`
|
|
Subject string `json:"subject"`
|
|
Size int64 `json:"size"`
|
|
Flags []string `json:"flags"`
|
|
Attachments []AttachmentRef `json:"attachments"`
|
|
// Body is the message's text, kept for search and served only through mail_read; never on the bus.
|
|
Body string `json:"-"`
|
|
}
|
|
|
|
// Event is one change said on the bus: its key (`mail.<account>.message.<added|changed|removed>`) and its body,
|
|
// metadata only.
|
|
type Event struct {
|
|
ID int64
|
|
Key string
|
|
Body map[string]any
|
|
}
|
|
|
|
// Folder is what the index knows of one folder.
|
|
type Folder struct {
|
|
Account string `json:"-"`
|
|
Name string `json:"name"`
|
|
UIDValidity uint32 `json:"-"`
|
|
Messages int `json:"messages"`
|
|
Synced time.Time `json:"synced"`
|
|
}
|
|
|
|
// The states an account's keeper says it is in.
|
|
const (
|
|
AccountSyncing = "syncing"
|
|
AccountCurrent = "current"
|
|
AccountWaits = "waits-for-sign-in"
|
|
AccountFailing = "failing"
|
|
)
|
|
|
|
// AccountState is an account as its keeper last said it: its state, why, and when the server last answered.
|
|
type AccountState struct {
|
|
Account string `json:"account"`
|
|
State string `json:"state"`
|
|
Why string `json:"why"`
|
|
Heard time.Time `json:"heard,omitempty"`
|
|
Synced time.Time `json:"synced,omitempty"`
|
|
Said time.Time `json:"said"`
|
|
}
|
|
|
|
// Query is a search of the index.
|
|
type Query struct {
|
|
Text string
|
|
Accounts []string
|
|
Folder string
|
|
Limit int
|
|
}
|
|
|
|
// Store is the index.
|
|
type Store interface {
|
|
Ready(ctx context.Context) error
|
|
// Folder is what the index knows of a folder; false when it never synced it.
|
|
Folder(ctx context.Context, account, folder string) (Folder, bool, error)
|
|
// SetFolder records a folder synced.
|
|
SetFolder(ctx context.Context, f Folder) error
|
|
// DropFolder forgets a folder's rows, without an event: the server renumbered it (a new UIDVALIDITY) and the
|
|
// next sync indexes it afresh.
|
|
DropFolder(ctx context.Context, account, folder string) error
|
|
// Flags is every indexed message of a folder, by UID, with its flags.
|
|
Flags(ctx context.Context, account, folder string) (map[uint32][]string, error)
|
|
// Add indexes a message, and keeps the event that says it when there is one, in one step.
|
|
Add(ctx context.Context, m Message, e *Event) error
|
|
// SetFlags changes a message's flags, and keeps the event, in one step.
|
|
SetFlags(ctx context.Context, account, folder string, uid uint32, flags []string, e *Event) error
|
|
// Remove removes a message, and keeps the event, in one step.
|
|
Remove(ctx context.Context, account, folder string, uid uint32, e *Event) error
|
|
// Get is one message, with its body.
|
|
Get(ctx context.Context, account, folder string, uid uint32) (Message, bool, error)
|
|
// Search finds messages, newest first, without their bodies.
|
|
Search(ctx context.Context, q Query) ([]Message, error)
|
|
// Folders is every folder the index knows of an account.
|
|
Folders(ctx context.Context, account string) ([]Folder, error)
|
|
// Pending is the events not yet said, oldest first; Said removes those said.
|
|
Pending(ctx context.Context, limit int) ([]Event, error)
|
|
Said(ctx context.Context, ids []int64) error
|
|
// SetState and States are each account's state, as its keeper says it.
|
|
SetState(ctx context.Context, s AccountState) error
|
|
States(ctx context.Context) (map[string]AccountState, error)
|
|
}
|
|
|
|
// ---- in memory ----------------------------------------------------------------------------------------------------
|
|
|
|
type memStore struct {
|
|
mu sync.Mutex
|
|
messages map[string]Message
|
|
folders map[string]Folder
|
|
events []Event
|
|
next int64
|
|
states map[string]AccountState
|
|
}
|
|
|
|
func newMemStore() *memStore {
|
|
return &memStore{messages: map[string]Message{}, folders: map[string]Folder{}, states: map[string]AccountState{}}
|
|
}
|
|
|
|
func msgKey(account, folder string, uid uint32) string {
|
|
return account + "\x00" + folder + "\x00" + itoa(int64(uid))
|
|
}
|
|
|
|
func folderKey(account, folder string) string { return account + "\x00" + folder }
|
|
|
|
func (s *memStore) Ready(context.Context) error { return nil }
|
|
|
|
func (s *memStore) Folder(_ context.Context, account, folder string) (Folder, bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
f, ok := s.folders[folderKey(account, folder)]
|
|
return f, ok, nil
|
|
}
|
|
|
|
func (s *memStore) SetFolder(_ context.Context, f Folder) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.folders[folderKey(f.Account, f.Name)] = f
|
|
return nil
|
|
}
|
|
|
|
func (s *memStore) DropFolder(_ context.Context, account, folder string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for k, m := range s.messages {
|
|
if m.Account == account && m.Folder == folder {
|
|
delete(s.messages, k)
|
|
}
|
|
}
|
|
delete(s.folders, folderKey(account, folder))
|
|
return nil
|
|
}
|
|
|
|
func (s *memStore) Flags(_ context.Context, account, folder string) (map[uint32][]string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := map[uint32][]string{}
|
|
for _, m := range s.messages {
|
|
if m.Account == account && m.Folder == folder {
|
|
out[m.UID] = append([]string(nil), m.Flags...)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *memStore) keep(e *Event) {
|
|
if e == nil {
|
|
return
|
|
}
|
|
s.next++
|
|
ev := *e
|
|
ev.ID = s.next
|
|
s.events = append(s.events, ev)
|
|
}
|
|
|
|
func (s *memStore) Add(_ context.Context, m Message, e *Event) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.messages[msgKey(m.Account, m.Folder, m.UID)] = m
|
|
s.keep(e)
|
|
return nil
|
|
}
|
|
|
|
func (s *memStore) SetFlags(_ context.Context, account, folder string, uid uint32, flags []string, e *Event) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
k := msgKey(account, folder, uid)
|
|
if m, ok := s.messages[k]; ok {
|
|
m.Flags = flags
|
|
s.messages[k] = m
|
|
}
|
|
s.keep(e)
|
|
return nil
|
|
}
|
|
|
|
func (s *memStore) Remove(_ context.Context, account, folder string, uid uint32, e *Event) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.messages, msgKey(account, folder, uid))
|
|
s.keep(e)
|
|
return nil
|
|
}
|
|
|
|
func (s *memStore) Get(_ context.Context, account, folder string, uid uint32) (Message, bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
m, ok := s.messages[msgKey(account, folder, uid)]
|
|
return m, ok, nil
|
|
}
|
|
|
|
func (s *memStore) Search(_ context.Context, q Query) ([]Message, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
words := strings.Fields(strings.ToLower(q.Text))
|
|
var out []Message
|
|
for _, m := range s.messages {
|
|
if !contains(q.Accounts, m.Account) || (q.Folder != "" && q.Folder != m.Folder) {
|
|
continue
|
|
}
|
|
hay := strings.ToLower(m.Subject + " " + m.Body + " " + peopleText(m.From) + " " + peopleText(m.To) + " " + peopleText(m.Cc))
|
|
all := true
|
|
for _, w := range words {
|
|
all = all && strings.Contains(hay, w)
|
|
}
|
|
if all {
|
|
m.Body = ""
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Date.After(out[j].Date) })
|
|
if q.Limit > 0 && len(out) > q.Limit {
|
|
out = out[:q.Limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *memStore) Folders(_ context.Context, account string) ([]Folder, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []Folder
|
|
for _, f := range s.folders {
|
|
if f.Account == account {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out, nil
|
|
}
|
|
|
|
func (s *memStore) Pending(_ context.Context, limit int) ([]Event, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
n := min(limit, len(s.events))
|
|
return append([]Event(nil), s.events[:n]...), nil
|
|
}
|
|
|
|
func (s *memStore) Said(_ context.Context, ids []int64) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
said := map[int64]bool{}
|
|
for _, id := range ids {
|
|
said[id] = true
|
|
}
|
|
kept := s.events[:0]
|
|
for _, e := range s.events {
|
|
if !said[e.ID] {
|
|
kept = append(kept, e)
|
|
}
|
|
}
|
|
s.events = kept
|
|
return nil
|
|
}
|
|
|
|
func (s *memStore) SetState(_ context.Context, st AccountState) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.states[st.Account] = st
|
|
return nil
|
|
}
|
|
|
|
func (s *memStore) States(context.Context) (map[string]AccountState, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := map[string]AccountState{}
|
|
for k, v := range s.states {
|
|
out[k] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- small helpers ------------------------------------------------------------------------------------------------
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func peopleText(ps []Person) string {
|
|
var parts []string
|
|
for _, p := range ps {
|
|
parts = append(parts, strings.TrimSpace(p.Name+" "+p.Address))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|