Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/store.go
T

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) }