796 lines
27 KiB
Go
796 lines
27 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"
|
|
"path"
|
|
"regexp"
|
|
"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
|
|
|
|
// From (issue 1228, AC2, AC3, AC6's cc part; issue 1230, AC11's "several domains") is a list of addresses or
|
|
// domains, case-insensitive, OR'd together: a message matches when at least one entry matches (a domain, no
|
|
// "@", matching every address of it). To and Cc (issue 1228, AC4, AC6's cc part) are each a single address or
|
|
// domain, the same way; no criterion asks for more than one. Empty/nil means no filter on that field.
|
|
From []string
|
|
To string
|
|
Cc string
|
|
|
|
// OnOrAfter and Before (issue 1228, AC2-AC4) bound Date (the Date header, imap.go:483-485): on-or-after is
|
|
// inclusive, before is exclusive. A zero time means no bound.
|
|
OnOrAfter time.Time
|
|
Before time.Time
|
|
|
|
// SubjectWords and BodyWords (issue 1229, AC13) are each a whole word, matched case-insensitively against
|
|
// Subject or Body respectively, and only that field (a word with digits and a dash, such as "4711-882", is
|
|
// matched whole, never by its separate parts, AC12). By default every word of the list must be found ("all");
|
|
// the *WordsAny flag switches that list to "any of them" (AC15). Empty means no filter.
|
|
SubjectWords []string
|
|
SubjectWordsAny bool
|
|
BodyWords []string
|
|
BodyWordsAny bool
|
|
|
|
// Words (issue 1229, AC5, AC6's words part, AC12, AC15; review round 1 finding 3) is the same whole-word,
|
|
// case-insensitive match as SubjectWords/BodyWords, but each word may be found in either field, Subject or
|
|
// Body: "the word in subject or body", as the acceptance criteria ask it, where SubjectWords/BodyWords alone
|
|
// would wrongly need it in one specific field. WordsAny is the same "any" switch. Empty means no filter.
|
|
Words []string
|
|
WordsAny bool
|
|
|
|
// HasAttachment, AttachmentType and AttachmentNamePattern (issue 1229, AC5, AC9) filter by attachment:
|
|
// HasAttachment requires at least one; AttachmentType (an exact media type, case-insensitive) and
|
|
// AttachmentNamePattern (a shell glob such as "*.pdf", case-insensitive) each require one attachment matching
|
|
// it, but when both are given, either suffices for the same attachment (AC9: "type text/calendar or name
|
|
// *.ics", since a calendar invite may carry either). Empty/false means no filter.
|
|
HasAttachment bool
|
|
AttachmentType string
|
|
AttachmentNamePattern string
|
|
|
|
// Unread (issue 1229, carrying 1228/AC2's flag part) is nil for no filter; true answers only messages without
|
|
// the \Seen flag, false only those with it.
|
|
Unread *bool
|
|
|
|
// OrderBy and OrderDesc (issue 1230, AC7, AC11, AC17's schema) choose Search's sort: empty OrderBy means
|
|
// "date" with OrderDesc ignored, kept descending (today's newest-first default, AC1); given explicitly, one
|
|
// of "date", "sender" (by its first address), "subject" or "size", ascending unless OrderDesc. Ties always
|
|
// break ascending on (account, folder, uid). GroupBySender ignores both: its own order is fixed (AC10).
|
|
OrderBy string
|
|
OrderDesc bool
|
|
|
|
// InReplyTo (issue 1231, AC8) matches the In-Reply-To header (in_reply_to, schema.sql) exactly; empty means
|
|
// no filter. Combined with From, it finds the replies from one recipient; alone, every reply in the thread's
|
|
// direct answer set.
|
|
InReplyTo string
|
|
|
|
// FromSomeoneIWroteTo (issue 1231, AC14) is true when the sender's address is a recipient (to or cc) of some
|
|
// mail in the same account's Sent folder; false (the default) means no filter.
|
|
FromSomeoneIWroteTo bool
|
|
}
|
|
|
|
// SenderGroup is one line of mail_search's group-by-sender answer (issue 1230, AC10): the sender's address and
|
|
// name (as last seen on a matching message), how many matching messages it sent, and the newest one's date.
|
|
type SenderGroup struct {
|
|
Address string `json:"address"`
|
|
Name string `json:"name,omitempty"`
|
|
Count int `json:"count"`
|
|
Newest time.Time `json:"newest"`
|
|
}
|
|
|
|
// 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 by default (OrderBy, OrderDesc choose otherwise), without their bodies.
|
|
Search(ctx context.Context, q Query) ([]Message, error)
|
|
// Newest is one account's newest n mails, without their bodies, each (account, message-id) counted once the
|
|
// same way Search answers with no folder named (novox/hq ADR 0439 §3, issue 1282): for the mail bucket.
|
|
Newest(ctx context.Context, account string, n int) ([]Message, error)
|
|
// GroupBySender is 1230/AC10: one line per sender among the messages Query's other filters match, by count
|
|
// descending, Limit bounding the number of lines.
|
|
GroupBySender(ctx context.Context, q Query) ([]SenderGroup, 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
|
|
}
|
|
|
|
// matchesQuery is every filter of Query but its order and its limit, shared by Search and GroupBySender so the
|
|
// two never drift apart (mirrored, condition for condition, by PgStore's own WHERE builder, pgstore.go). wroteTo
|
|
// is wroteToByAccount's answer, needed only when q.FromSomeoneIWroteTo is set.
|
|
func matchesQuery(m Message, q Query, words []string, wroteTo map[string]map[string]bool) bool {
|
|
if !contains(q.Accounts, m.Account) || (q.Folder != "" && q.Folder != m.Folder) {
|
|
return false
|
|
}
|
|
// 1231/AC8: the In-Reply-To header, matched exactly.
|
|
if q.InReplyTo != "" && m.InReplyTo != q.InReplyTo {
|
|
return false
|
|
}
|
|
// 1231/AC14: the sender's address is a recipient (to or cc) of some mail in the same account's Sent folder.
|
|
if q.FromSomeoneIWroteTo && !anyAddressIn(m.From, wroteTo[m.Account]) {
|
|
return false
|
|
}
|
|
// 1228/AC2, 1228/AC3, 1228/AC4, 1228/AC6(cc); 1230/AC11 (From's several domains): the address and date-range
|
|
// filters, mirroring PgStore.Search.
|
|
if !addressMatchesAny(m.From, q.From) || !addressMatches(m.To, q.To) || !addressMatches(m.Cc, q.Cc) {
|
|
return false
|
|
}
|
|
if !q.OnOrAfter.IsZero() && m.Date.Before(q.OnOrAfter) {
|
|
return false
|
|
}
|
|
if !q.Before.IsZero() && !m.Date.Before(q.Before) {
|
|
return false
|
|
}
|
|
// 1229/AC13: the subject-only and body-only word filters, mirroring PgStore.Search.
|
|
if !wordMatches([]string{m.Subject}, q.SubjectWords, q.SubjectWordsAny) ||
|
|
!wordMatches([]string{m.Body}, q.BodyWords, q.BodyWordsAny) {
|
|
return false
|
|
}
|
|
// 1229/AC5, AC6(words), AC12, AC15 (review round 1 finding 3): a word found in subject or body.
|
|
if !wordMatches([]string{m.Subject, m.Body}, q.Words, q.WordsAny) {
|
|
return false
|
|
}
|
|
// 1229/AC9: the attachment and read/unread filters, mirroring PgStore.Search.
|
|
if !attachmentMatches(m.Attachments, q.HasAttachment, q.AttachmentType, q.AttachmentNamePattern) {
|
|
return false
|
|
}
|
|
if q.Unread != nil && hasSeenFlag(m.Flags) == *q.Unread {
|
|
return false
|
|
}
|
|
hay := strings.ToLower(m.Subject + " " + m.Body + " " + peopleText(m.From) + " " + peopleText(m.To) + " " + peopleText(m.Cc))
|
|
for _, w := range words {
|
|
if !strings.Contains(hay, w) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *memStore) Search(_ context.Context, q Query) ([]Message, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
words := strings.Fields(strings.ToLower(q.Text))
|
|
wroteTo := s.wroteToByAccount(q.FromSomeoneIWroteTo)
|
|
var out []Message
|
|
for _, m := range s.messages {
|
|
if matchesQuery(m, q, words, wroteTo) {
|
|
m.Body = ""
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
// 1248/AC1, AC2, AC4-AC7: no folder named, so answer each (account, message-id) once, before order and limit
|
|
// are applied to the de-duplicated set; a named folder (AC3) skips this.
|
|
if q.Folder == "" {
|
|
out = dedupeArchive(out)
|
|
}
|
|
// 1230: order by date (the default), sender, subject or size, ascending or descending, tied-broken on
|
|
// (account, folder, uid) for a stable order.
|
|
sort.Slice(out, func(i, j int) bool { return orderLess(out[i], out[j], q.OrderBy, q.OrderDesc) })
|
|
if q.Limit > 0 && len(out) > q.Limit {
|
|
out = out[:q.Limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Newest is 1282: one account's newest n mails, via Search's own ordering (newest first) and deduplication (no
|
|
// folder named, so dedupeArchive applies — the same copy Search itself would answer).
|
|
func (s *memStore) Newest(ctx context.Context, account string, n int) ([]Message, error) {
|
|
return s.Search(ctx, Query{Accounts: []string{account}, Limit: n})
|
|
}
|
|
|
|
// orderLess is 1230 (AC7, AC11, AC17's schema): date (the default when orderBy is empty), sender (by its first
|
|
// address), subject or size, ascending unless desc; ties break on (account, folder, uid), always ascending, so
|
|
// the order is stable regardless of the map order Go would otherwise give equal messages.
|
|
func orderLess(a, b Message, orderBy string, desc bool) bool {
|
|
if orderBy == "" {
|
|
// Unspecified order keeps the newest-first default (AC1), regardless of OrderDesc.
|
|
desc = true
|
|
}
|
|
var cmp int
|
|
switch orderBy {
|
|
case "sender":
|
|
cmp = strings.Compare(firstAddress(a.From), firstAddress(b.From))
|
|
case "subject":
|
|
cmp = strings.Compare(a.Subject, b.Subject)
|
|
case "size":
|
|
cmp = compareInt64(a.Size, b.Size)
|
|
default: // "date", or empty (the default)
|
|
cmp = compareTime(a.Date, b.Date)
|
|
}
|
|
if desc {
|
|
cmp = -cmp
|
|
}
|
|
if cmp != 0 {
|
|
return cmp < 0
|
|
}
|
|
if a.Account != b.Account {
|
|
return a.Account < b.Account
|
|
}
|
|
if a.Folder != b.Folder {
|
|
return a.Folder < b.Folder
|
|
}
|
|
return a.UID < b.UID
|
|
}
|
|
|
|
func firstAddress(ps []Person) string {
|
|
if len(ps) == 0 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(ps[0].Address)
|
|
}
|
|
|
|
func compareInt64(a, b int64) int {
|
|
switch {
|
|
case a < b:
|
|
return -1
|
|
case a > b:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func compareTime(a, b time.Time) int {
|
|
switch {
|
|
case a.Before(b):
|
|
return -1
|
|
case a.After(b):
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// GroupBySender is 1230 (AC10): one line per sender among the messages Query's other filters match (its OrderBy
|
|
// and OrderDesc are not used; the lines are always ordered by count descending, address ascending to break ties),
|
|
// Limit bounding the number of lines, not the number of messages.
|
|
func (s *memStore) GroupBySender(_ context.Context, q Query) ([]SenderGroup, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
words := strings.Fields(strings.ToLower(q.Text))
|
|
wroteTo := s.wroteToByAccount(q.FromSomeoneIWroteTo)
|
|
var matched []Message
|
|
for _, m := range s.messages {
|
|
if matchesQuery(m, q, words, wroteTo) {
|
|
matched = append(matched, m)
|
|
}
|
|
}
|
|
// 1248/AC4: no folder named, so a mail held in two folders (one of them the archive) counts once.
|
|
if q.Folder == "" {
|
|
matched = dedupeArchive(matched)
|
|
}
|
|
byAddress := map[string]*SenderGroup{}
|
|
var order []string
|
|
for _, m := range matched {
|
|
for _, p := range m.From {
|
|
addr := strings.ToLower(p.Address)
|
|
g, ok := byAddress[addr]
|
|
if !ok {
|
|
g = &SenderGroup{}
|
|
byAddress[addr] = g
|
|
order = append(order, addr)
|
|
}
|
|
g.Count++
|
|
// Review round 1 finding 6: Address and Name are the newest mail's own, never whichever message a
|
|
// Go map's (randomised) iteration order happened to visit first.
|
|
if g.Newest.IsZero() || m.Date.After(g.Newest) {
|
|
g.Newest = m.Date
|
|
g.Address = p.Address
|
|
g.Name = p.Name
|
|
}
|
|
}
|
|
}
|
|
out := make([]SenderGroup, 0, len(order))
|
|
for _, addr := range order {
|
|
out = append(out, *byAddress[addr])
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Count != out[j].Count {
|
|
return out[i].Count > out[j].Count
|
|
}
|
|
return strings.ToLower(out[i].Address) < strings.ToLower(out[j].Address)
|
|
})
|
|
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
|
|
}
|
|
|
|
// addressMatches is 1228/AC2, 1228/AC3, 1228/AC4, 1228/AC6(cc): filter is an address (matched exactly) or a domain
|
|
// (matched as the suffix after "@"), case-insensitive; empty filter matches every message.
|
|
func addressMatches(ps []Person, filter string) bool {
|
|
if filter == "" {
|
|
return true
|
|
}
|
|
f := strings.ToLower(filter)
|
|
for _, p := range ps {
|
|
addr := strings.ToLower(p.Address)
|
|
if addr == f || strings.HasSuffix(addr, "@"+f) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// addressMatchesAny is 1230/AC11: From's several domains (or addresses), OR'd together; empty/nil matches every
|
|
// message, otherwise at least one entry (each matched as addressMatches does) must match.
|
|
func addressMatchesAny(ps []Person, filters []string) bool {
|
|
if len(filters) == 0 {
|
|
return true
|
|
}
|
|
for _, f := range filters {
|
|
if addressMatches(ps, f) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sentFolderNames (issue 1231, AC14; review round 1 finding 1) are the names the live index holds a Sent folder
|
|
// under, matched case-insensitively: "Sent" for mesh and mediahuis (mailu), "[Gmail]/Sent Mail" for gmail. A
|
|
// future account naming it a third way is for the tool layer to surface (the context's "say what you chose"),
|
|
// not this store.
|
|
var sentFolderNames = []string{"Sent", "[Gmail]/Sent Mail"}
|
|
|
|
func isSentFolder(folder string) bool {
|
|
for _, n := range sentFolderNames {
|
|
if strings.EqualFold(folder, n) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// archiveFolderNames (issue 1248, AC1-AC7) are the folders where an account keeps a second copy of every mail
|
|
// (gmail's special-use \All, called "[Gmail]/All Mail" in its UI): searched only when named (AC3). A search
|
|
// naming no folder answers each (account, message-id) once, preferring the copy outside this list (AC1), and
|
|
// falling back to the archive copy only when that is the only one (AC2). Matched case-insensitively, like
|
|
// sentFolderNames; a future account naming its archive a third way is for the tool layer to surface, not this
|
|
// store.
|
|
var archiveFolderNames = []string{"[Gmail]/All Mail"}
|
|
|
|
func isArchiveFolder(folder string) bool {
|
|
for _, n := range archiveFolderNames {
|
|
if strings.EqualFold(folder, n) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dedupeArchive is 1248/AC1, AC2, AC4-AC7: ms (already matching a Query with no Folder named) with each
|
|
// (account, message-id) kept once — the copy outside archiveFolderNames, or the archive copy when that is the
|
|
// only copy. An empty message-id (AC5) is never merged with anything: every such mail is its own group, keyed by
|
|
// its own (account, folder, uid), which msgKey already makes unique. Which copy wins when a message-id sits in
|
|
// two non-archive folders too (e.g. INBOX and a label folder such as "INBOX/Telenet"; review round 1 finding 1)
|
|
// is decided by dedupeBetter's fixed rule, the same one dedupedMessagesSQL (pgstore.go) uses, never by the order
|
|
// ms happens to arrive in — which, from a Go map, is randomised per run.
|
|
func dedupeArchive(ms []Message) []Message {
|
|
type groupKey struct{ account, id string }
|
|
kept := map[groupKey]int{} // groupKey -> index into out
|
|
var out []Message
|
|
for _, m := range ms {
|
|
id := m.MessageID
|
|
if id == "" {
|
|
id = "\x00" + m.Folder + "\x00" + itoa(int64(m.UID))
|
|
}
|
|
k := groupKey{m.Account, id}
|
|
if i, ok := kept[k]; ok {
|
|
if dedupeBetter(m, out[i]) {
|
|
out[i] = m
|
|
}
|
|
continue
|
|
}
|
|
kept[k] = len(out)
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// dedupeBetter is 1248 (review round 1, finding 1): whether a is the copy dedupeArchive should keep over b,
|
|
// within one (account, message-id) group — non-archive before archive, then folder ascending, then uid
|
|
// ascending, the same order dedupedMessagesSQL's "order by ... dedup_archived, folder, uid" picks in Postgres,
|
|
// so the two stores never answer a different copy of the same mail.
|
|
func dedupeBetter(a, b Message) bool {
|
|
aArchived, bArchived := isArchiveFolder(a.Folder), isArchiveFolder(b.Folder)
|
|
if aArchived != bArchived {
|
|
return !aArchived
|
|
}
|
|
if a.Folder != b.Folder {
|
|
return a.Folder < b.Folder
|
|
}
|
|
return a.UID < b.UID
|
|
}
|
|
|
|
// wroteToByAccount is 1231/AC14's own data: for each account, every address (lowercased) that account has
|
|
// written to (as a "to" or a "cc") from a folder named one of sentFolderNames. Computed only when needed (asked
|
|
// is false otherwise), since it walks every message.
|
|
func (s *memStore) wroteToByAccount(asked bool) map[string]map[string]bool {
|
|
if !asked {
|
|
return nil
|
|
}
|
|
out := map[string]map[string]bool{}
|
|
for _, m := range s.messages {
|
|
if !isSentFolder(m.Folder) {
|
|
continue
|
|
}
|
|
set, ok := out[m.Account]
|
|
if !ok {
|
|
set = map[string]bool{}
|
|
out[m.Account] = set
|
|
}
|
|
for _, p := range append(append([]Person{}, m.To...), m.Cc...) {
|
|
set[strings.ToLower(p.Address)] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// anyAddressIn is 1231/AC14: whether any of ps's addresses (case-insensitive) is in set; an unset or empty set
|
|
// (the account wrote to nobody, or wroteTo was never asked for) matches nobody.
|
|
func anyAddressIn(ps []Person, set map[string]bool) bool {
|
|
for _, p := range ps {
|
|
if set[strings.ToLower(p.Address)] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// wordRegexp is a whole-word, case-insensitive match (issue 1229, AC12): \b, Go's RE2 word boundary, treats a
|
|
// dash as a non-word character, so a hyphenated word such as "4711-882" is bounded at both ends and never matched
|
|
// by only its digits.
|
|
func wordRegexp(word string) *regexp.Regexp {
|
|
return regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(word) + `\b`)
|
|
}
|
|
|
|
// wordMatches is 1229/AC5, AC6(words), AC9, AC12, AC13, AC15; review round 1 finding 3: an empty list matches
|
|
// every message; otherwise every word of the list must be found in at least one of texts ("all"), or at least
|
|
// one word found in at least one text when any is true. SubjectWords/BodyWords call it with one text (their own
|
|
// field, and only that field); Words calls it with both Subject and Body, so a word counts in either — "the
|
|
// word in subject or body", as the acceptance criteria ask it.
|
|
func wordMatches(texts []string, words []string, any bool) bool {
|
|
if len(words) == 0 {
|
|
return true
|
|
}
|
|
for _, w := range words {
|
|
re := wordRegexp(w)
|
|
found := false
|
|
for _, t := range texts {
|
|
if re.MatchString(t) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if any && found {
|
|
return true
|
|
}
|
|
if !any && !found {
|
|
return false
|
|
}
|
|
}
|
|
return !any
|
|
}
|
|
|
|
// attachmentMatches is 1229/AC5, AC9: hasAttachment requires at least one attachment; typ and namePattern, when
|
|
// either is given, each require one attachment matching it, but when both are given either suffices for the same
|
|
// attachment (AC9's "type text/calendar or name *.ics").
|
|
func attachmentMatches(atts []AttachmentRef, hasAttachment bool, typ, namePattern string) bool {
|
|
if hasAttachment && len(atts) == 0 {
|
|
return false
|
|
}
|
|
if typ == "" && namePattern == "" {
|
|
return true
|
|
}
|
|
for _, a := range atts {
|
|
if typ != "" && strings.EqualFold(a.Type, typ) {
|
|
return true
|
|
}
|
|
if namePattern != "" && globMatches(namePattern, a.Name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// globMatches is 1229/AC5, AC9: a shell glob such as "*.pdf" or "*.ics", case-insensitive.
|
|
func globMatches(pattern, name string) bool {
|
|
ok, _ := path.Match(strings.ToLower(pattern), strings.ToLower(name))
|
|
return ok
|
|
}
|
|
|
|
// hasSeenFlag is 1229 (carrying 1228/AC2's flag part): whether the message is read, by IMAP's own \Seen flag.
|
|
func hasSeenFlag(flags []string) bool {
|
|
for _, f := range flags {
|
|
if strings.EqualFold(f, `\Seen`) {
|
|
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) }
|