mox/store/account.go

1140 lines
34 KiB
Go
Raw Normal View History

2023-01-30 16:27:06 +03:00
/*
Package store implements storage for accounts, their mailboxes, IMAP
subscriptions and messages, and broadcasts updates (e.g. mail delivery) to
interested sessions (e.g. IMAP connections).
Layout of storage for accounts:
<DataDir>/accounts/<name>/index.db
<DataDir>/accounts/<name>/msg/[a-zA-Z0-9_-]+/<id>
Index.db holds tables for user information, mailboxes, and messages. Messages
are stored in the msg/ subdirectory, each in their own file. The on-disk message
does not contain headers generated during an incoming SMTP transaction, such as
Received and Authentication-Results headers. Those are in the database to
prevent having to rewrite incoming messages (e.g. Authentication-Result for DKIM
signatures can only be determined after having read the message). Messages must
be read through MsgReader, which transparently adds the prefix from the
database.
*/
package store
// todo: make up a function naming scheme that indicates whether caller should broadcast changes.
// todo: fewer (no?) "X" functions, but only explicit error handling.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"golang.org/x/text/unicode/norm"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/publicsuffix"
"github.com/mjl-/mox/scram"
"github.com/mjl-/mox/smtp"
)
var xlog = mlog.New("store")
var (
ErrUnknownMailbox = errors.New("no such mailbox")
ErrUnknownCredentials = errors.New("credentials not found")
ErrAccountUnknown = errors.New("no such account")
)
var subjectpassRand = mox.NewRand()
var InitialMailboxes = []string{"Inbox", "Sent", "Archive", "Trash", "Drafts", "Junk"}
// Password holds a bcrypt hash for logging in with SMTP/IMAP/admin.
type Password struct {
Hash string
SCRAMSHA256 struct {
Salt []byte
Iterations int
SaltedPassword []byte
}
}
// Subjectpass holds the secret key used to sign subjectpass tokens.
type Subjectpass struct {
Email string // Our destination address (canonical, with catchall localpart stripped).
Key string
}
// NextUIDValidity is a singleton record in the database with the next UIDValidity
// to use for the next mailbox.
type NextUIDValidity struct {
ID int // Just a single record with ID 1.
Next uint32
}
// Mailbox is collection of messages, e.g. Inbox or Sent.
type Mailbox struct {
ID int64
// "Inbox" is the name for the special IMAP "INBOX". Slash separated
// for hierarchy.
Name string `bstore:"nonzero,unique"`
// If UIDs are invalidated, e.g. when renaming a mailbox to a previously existing
// name, UIDValidity must be changed. Used by IMAP for synchronization.
UIDValidity uint32
// UID likely to be assigned to next message. Used by IMAP to detect messages
// delivered to a mailbox.
UIDNext UID
// Special-use hints. The mailbox holds these types of messages. Used
// in IMAP LIST (mailboxes) response.
Archive bool
Draft bool
Junk bool
Sent bool
Trash bool
}
// Subscriptions are separate from existence of mailboxes.
type Subscription struct {
Name string
}
// Flags for a mail message.
type Flags struct {
Seen bool
Answered bool
Flagged bool
Forwarded bool
Junk bool
Notjunk bool
Deleted bool
Draft bool
Phishing bool
MDNSent bool
}
// FlagsAll is all flags set, for use as mask.
var FlagsAll = Flags{true, true, true, true, true, true, true, true, true, true}
// Validation of "message From" domain.
type Validation uint8
const (
ValidationUnknown Validation = 0
ValidationStrict Validation = 1 // Like DMARC, with strict policies.
ValidationDMARC Validation = 2 // Actual DMARC policy.
ValidationRelaxed Validation = 3 // Like DMARC, with relaxed policies.
ValidationPass Validation = 4 // For SPF.
ValidationNeutral Validation = 5 // For SPF.
ValidationTemperror Validation = 6
ValidationPermerror Validation = 7
ValidationFail Validation = 8
ValidationSoftfail Validation = 9 // For SPF.
ValidationNone Validation = 10 // E.g. No records.
)
// Message stored in database and per-message file on disk.
//
// Contents are always the combined data from MsgPrefix and the on-disk file named
// based on ID.
//
// Messages always have a header section, even if empty. Incoming messages without
// header section must get an empty header section added before inserting.
type Message struct {
// ID, unchanged over lifetime, determines path to on-disk msg file.
// Set during deliver.
ID int64
UID UID `bstore:"nonzero"` // UID, for IMAP. Set during deliver.
MailboxID int64 `bstore:"nonzero,unique MailboxID+UID,index MailboxID+Received,ref Mailbox"`
// Mailbox message originally delivered to. I.e. not changed when moved to Trash or
// Junk. Useful for per-mailbox reputation. Not a bstore reference to prevent
// having to update all messages in a mailbox when the original mailbox is removed.
// Use of this field requires checking if the mailbox still exists.
MailboxOrigID int64
Received time.Time `bstore:"default now,index"`
// Full IP address of remote SMTP server. Empty if not delivered over
// SMTP.
RemoteIP string
RemoteIPMasked1 string `bstore:"index RemoteIPMasked1+Received"` // For IPv4 /32, for IPv6 /64, for reputation.
RemoteIPMasked2 string `bstore:"index RemoteIPMasked2+Received"` // For IPv4 /26, for IPv6 /48.
RemoteIPMasked3 string `bstore:"index RemoteIPMasked3+Received"` // For IPv4 /21, for IPv6 /32.
EHLODomain string `bstore:"index EHLODomain+Received"` // Only set if present and not an IP address. Unicode string.
MailFrom string // With localpart and domain. Can be empty.
MailFromLocalpart smtp.Localpart // SMTP "MAIL FROM", can be empty.
MailFromDomain string `bstore:"index MailFromDomain+Received"` // Only set if it is a domain, not an IP. Unicode string.
RcptToLocalpart smtp.Localpart // SMTP "RCPT TO", can be empty.
RcptToDomain string // Unicode string.
// Parsed "From" message header, used for reputation along with domain validation.
MsgFromLocalpart smtp.Localpart
MsgFromDomain string `bstore:"index MsgFromDomain+Received"` // Unicode string.
MsgFromOrgDomain string `bstore:"index MsgFromOrgDomain+Received"` // Unicode string.
// Simplified statements of the Validation fields below, used for incoming messages
// to check reputation.
EHLOValidated bool
MailFromValidated bool
MsgFromValidated bool
EHLOValidation Validation // Validation can also take reverse IP lookup into account, not only SPF.
MailFromValidation Validation // Can have SPF-specific validations like ValidationSoftfail.
MsgFromValidation Validation // Desirable validations: Strict, DMARC, Relaxed. Will not be just Pass.
// todo: needs an "in" index, which bstore does not yet support. for performance while checking reputation.
DKIMDomains []string // Domains with verified DKIM signatures. Unicode string.
// Value of Message-Id header. Only set for messages that were
// delivered to the rejects mailbox. For ensuring such messages are
// delivered only once. Value includes <>.
MessageID string `bstore:"index"`
MessageHash []byte // Hash of message. For rejects delivery, so optional like MessageID.
Flags
Size int64
MsgPrefix []byte // Typically holds received headers and/or header separator.
// ParsedBuf message structure. Currently saved as JSON of message.Part because bstore
// cannot yet store recursive types. Created when first needed, and saved in the
// database.
ParsedBuf []byte
}
// LoadPart returns a message.Part by reading from m.ParsedBuf.
func (m Message) LoadPart(r io.ReaderAt) (message.Part, error) {
if m.ParsedBuf == nil {
return message.Part{}, fmt.Errorf("message not parsed")
}
var p message.Part
err := json.Unmarshal(m.ParsedBuf, &p)
if err != nil {
return p, fmt.Errorf("unmarshal message part")
}
p.SetReaderAt(r)
return p, nil
}
// Recipient represents the recipient of a message. It is tracked to allow
// first-time incoming replies from users this account has sent messages to. On
// IMAP append to Sent, the message is parsed and recipients are inserted as
// recipient. Recipients are never removed other than for removing the message. On
// IMAP move/copy, recipients aren't modified either. don't modify anything either.
// This works by the assumption that an IMAP client simply appends messages to the
// Sent mailbox (as opposed to copying messages from some place).
type Recipient struct {
ID int64
MessageID int64 `bstore:"nonzero,ref Message"` // Ref gives it its own index, useful for fast removal as well.
Localpart smtp.Localpart `bstore:"nonzero"`
Domain string `bstore:"nonzero,index Domain+Localpart"` // Unicode string.
OrgDomain string `bstore:"nonzero,index"` // Unicode string.
Sent time.Time `bstore:"nonzero"`
}
// Account holds the information about a user, includings mailboxes, messages, imap subscriptions.
type Account struct {
Name string // Name, according to configuration.
Dir string // Directory where account files, including the database, bloom filter, and mail messages, are stored for this account.
DBPath string // Path to database with mailboxes, messages, etc.
DB *bstore.DB // Open database connection.
// Write lock must be held for account/mailbox modifications including message delivery.
// Read lock for reading mailboxes/messages.
// When making changes to mailboxes/messages, changes must be broadcasted before
// releasing the lock to ensure proper UID ordering.
sync.RWMutex
nused int // Reference count, while >0, this account is alive and shared.
}
func xcheckf(err error, format string, args ...any) {
if err != nil {
msg := fmt.Sprintf(format, args...)
panic(fmt.Errorf("%s: %w", msg, err))
}
}
// InitialUIDValidity returns a UIDValidity used for initializing an account.
// It can be replaced during tests with a predictable value.
var InitialUIDValidity = func() uint32 {
return uint32(time.Now().Unix() >> 1) // A 2-second resolution will get us far enough beyond 2038.
}
var openAccounts = struct {
names map[string]*Account
sync.Mutex
}{
names: map[string]*Account{},
}
func closeAccount(acc *Account) (rerr error) {
openAccounts.Lock()
acc.nused--
defer openAccounts.Unlock()
if acc.nused == 0 {
rerr = acc.DB.Close()
acc.DB = nil
delete(openAccounts.names, acc.Name)
}
return
}
// OpenAccount opens an account by name.
//
// No additional data path prefix or ".db" suffix should be added to the name.
// A single shared account exists per name.
func OpenAccount(name string) (*Account, error) {
openAccounts.Lock()
defer openAccounts.Unlock()
if acc, ok := openAccounts.names[name]; ok {
acc.nused++
return acc, nil
}
if _, ok := mox.Conf.Account(name); !ok {
return nil, ErrAccountUnknown
}
acc, err := openAccount(name)
if err != nil {
return nil, err
}
acc.nused++
openAccounts.names[name] = acc
return acc, nil
}
// openAccount opens an existing account, or creates it if it is missing.
func openAccount(name string) (a *Account, rerr error) {
dir := filepath.Join(mox.DataDirPath("accounts"), name)
dbpath := filepath.Join(dir, "index.db")
// Create account if it doesn't exist yet.
isNew := false
if _, err := os.Stat(dbpath); err != nil && os.IsNotExist(err) {
isNew = true
os.MkdirAll(dir, 0770)
}
db, err := bstore.Open(dbpath, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, NextUIDValidity{}, Message{}, Recipient{}, Mailbox{}, Subscription{}, Password{}, Subjectpass{})
if err != nil {
return nil, err
}
defer func() {
if rerr != nil {
db.Close()
if isNew {
os.Remove(dbpath)
}
}
}()
if isNew {
if err := initAccount(db); err != nil {
return nil, fmt.Errorf("initializing account: %v", err)
}
}
return &Account{
Name: name,
Dir: dir,
DBPath: dbpath,
DB: db,
}, nil
}
func initAccount(db *bstore.DB) error {
return db.Write(func(tx *bstore.Tx) error {
uidvalidity := InitialUIDValidity()
mailboxes := InitialMailboxes
defaultMailboxes := mox.Conf.Static.DefaultMailboxes
if len(defaultMailboxes) > 0 {
mailboxes = []string{"Inbox"}
for _, name := range defaultMailboxes {
if strings.EqualFold(name, "Inbox") {
continue
}
mailboxes = append(mailboxes, name)
}
}
for _, name := range mailboxes {
mb := Mailbox{Name: name, UIDValidity: uidvalidity, UIDNext: 1}
if strings.HasPrefix(name, "Archive") {
mb.Archive = true
} else if strings.HasPrefix(name, "Drafts") {
mb.Draft = true
} else if strings.HasPrefix(name, "Junk") {
mb.Junk = true
} else if strings.HasPrefix(name, "Sent") {
mb.Sent = true
} else if strings.HasPrefix(name, "Trash") {
mb.Trash = true
}
if err := tx.Insert(&mb); err != nil {
return fmt.Errorf("creating mailbox: %w", err)
}
if err := tx.Insert(&Subscription{name}); err != nil {
return fmt.Errorf("adding subscription: %w", err)
}
}
uidvalidity++
if err := tx.Insert(&NextUIDValidity{1, uidvalidity}); err != nil {
return fmt.Errorf("inserting nextuidvalidity: %w", err)
}
return nil
})
}
// Close reduces the reference count, and closes the database connection when
// it was the last user.
func (a *Account) Close() error {
return closeAccount(a)
}
// Conf returns the configuration for this account if it still exists. During
// an SMTP session, a configuration update may drop an account.
func (a *Account) Conf() (config.Account, bool) {
return mox.Conf.Account(a.Name)
}
// NextUIDValidity returns the next new/unique uidvalidity to use for this account.
func (a *Account) NextUIDValidity(tx *bstore.Tx) (uint32, error) {
nuv := NextUIDValidity{ID: 1}
if err := tx.Get(&nuv); err != nil {
return 0, err
}
v := nuv.Next
nuv.Next++
if err := tx.Update(&nuv); err != nil {
return 0, err
}
return v, nil
}
// WithWLock runs fn with account writelock held. Necessary for account/mailbox modification. For message delivery, a read lock is required.
func (a *Account) WithWLock(fn func()) {
a.Lock()
defer a.Unlock()
fn()
}
// WithRLock runs fn with account read lock held. Needed for message delivery.
func (a *Account) WithRLock(fn func()) {
a.RLock()
defer a.RUnlock()
fn()
}
// DeliverX delivers a mail message to the account.
//
// If consumeFile is set, the original msgFile is moved/renamed or copied and
// removed as part of delivery.
//
// The message, with msg.MsgPrefix and msgFile combined, must have a header
// section. The caller is responsible for adding a header separator to
// msg.MsgPrefix if missing from an incoming message.
//
// If isSent is true, the message is parsed for its recipients (to/cc/bcc). Their
// domains are added to Recipients for use in dmarc reputation.
//
// If sync is true, the message file and its directory are synced. Should be true
// for regular mail delivery, but can be false when importing many messages.
//
// if train is true, the junkfilter (if configured) is trained with the message.
// Should be used for regular mail delivery, but can be false when importing many
// messages.
//
// Must be called with account rlock or wlock.
//
// Caller must broadcast new message.
func (a *Account) DeliverX(log *mlog.Log, tx *bstore.Tx, m *Message, msgFile *os.File, consumeFile, isSent, sync, train bool) {
mb := Mailbox{ID: m.MailboxID}
err := tx.Get(&mb)
xcheckf(err, "get mailbox")
m.UID = mb.UIDNext
mb.UIDNext++
err = tx.Update(&mb)
xcheckf(err, "updating mailbox nextuid")
var part *message.Part
if m.ParsedBuf == nil {
mr := FileMsgReader(m.MsgPrefix, msgFile) // We don't close, it would close the msgFile.
p, err := message.EnsurePart(mr, m.Size)
if err != nil {
log.Infox("parsing delivered message", err, mlog.Field("parse", ""), mlog.Field("message", m.ID))
// We continue, p is still valid.
}
part = &p
buf, err := json.Marshal(part)
xcheckf(err, "marshal parsed message")
m.ParsedBuf = buf
}
err = tx.Insert(m)
xcheckf(err, "inserting message")
if isSent {
// Attempt to parse the message for its To/Cc/Bcc headers, which we insert into Recipient.
if part == nil {
var p message.Part
if err := json.Unmarshal(m.ParsedBuf, &p); err != nil {
log.Errorx("unmarshal parsed message for its to,cc,bcc headers, continuing", err, mlog.Field("parse", ""))
} else {
part = &p
}
}
if part != nil && part.Envelope != nil {
e := part.Envelope
sent := e.Date
if sent.IsZero() {
sent = m.Received
}
if sent.IsZero() {
sent = time.Now()
}
addrs := append(append(e.To, e.CC...), e.BCC...)
for _, addr := range addrs {
if addr.User == "" {
// Would trigger error because Recipient.Localpart must be nonzero. todo: we could allow empty localpart in db, and filter by not using FilterNonzero.
log.Info("to/cc/bcc address with empty localpart, not inserting as recipient", mlog.Field("address", addr))
continue
}
d, err := dns.ParseDomain(addr.Host)
if err != nil {
log.Debugx("parsing domain in to/cc/bcc address", err, mlog.Field("address", addr))
continue
}
mr := Recipient{
MessageID: m.ID,
Localpart: smtp.Localpart(addr.User),
Domain: d.Name(),
OrgDomain: publicsuffix.Lookup(context.TODO(), d).Name(),
Sent: sent,
}
err = tx.Insert(&mr)
xcheckf(err, "inserting sent message recipients")
}
}
}
msgPath := a.MessagePath(m.ID)
msgDir := filepath.Dir(msgPath)
os.MkdirAll(msgDir, 0770)
// Sync file data to disk.
if sync {
err = msgFile.Sync()
xcheckf(err, "fsync message file")
}
if consumeFile {
err := os.Rename(msgFile.Name(), msgPath)
xcheckf(err, "moving msg file to destination directory")
} else if err := os.Link(msgFile.Name(), msgPath); err != nil {
// Assume file system does not support hardlinks. Copy it instead.
err := writeFile(msgPath, &moxio.AtReader{R: msgFile})
xcheckf(err, "copying message to new file")
}
if sync {
err = moxio.SyncDir(msgDir)
xcheckf(err, "sync directory")
}
if train {
conf, _ := a.Conf()
if mb.Name != conf.RejectsMailbox {
err := a.Train(log, []Message{*m})
xcheckf(err, "train junkfilter with new message")
}
}
}
// write contents of r to new file dst, for delivering a message.
func writeFile(dst string, r io.Reader) error {
df, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0660)
if err != nil {
return fmt.Errorf("create: %w", err)
}
defer func() {
if df != nil {
df.Close()
}
}()
if _, err := io.Copy(df, r); err != nil {
return fmt.Errorf("copy: %s", err)
} else if err := df.Sync(); err != nil {
return fmt.Errorf("sync: %s", err)
} else if err := df.Close(); err != nil {
return fmt.Errorf("close: %s", err)
}
df = nil
return nil
}
// SetPassword saves a new password for this account. This password is used for
// IMAP, SMTP (submission) sessions and the HTTP account web page.
func (a *Account) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("generating password hash: %w", err)
}
err = a.DB.Write(func(tx *bstore.Tx) error {
if _, err := bstore.QueryTx[Password](tx).Delete(); err != nil {
return fmt.Errorf("deleting existing password: %v", err)
}
var pw Password
pw.Hash = string(hash)
pw.SCRAMSHA256.Salt = scram.MakeRandom()
pw.SCRAMSHA256.Iterations = 4096
pw.SCRAMSHA256.SaltedPassword = scram.SaltPassword(password, pw.SCRAMSHA256.Salt, pw.SCRAMSHA256.Iterations)
if err := tx.Insert(&pw); err != nil {
return fmt.Errorf("inserting new password: %v", err)
}
return nil
})
if err == nil {
xlog.Info("new password set for account", mlog.Field("account", a.Name))
}
return err
}
// Subjectpass returns the signing key for use with subjectpass for the given
// email address with canonical localpart.
func (a *Account) Subjectpass(email string) (key string, err error) {
return key, a.DB.Write(func(tx *bstore.Tx) error {
v := Subjectpass{Email: email}
err := tx.Get(&v)
if err == nil {
key = v.Key
return nil
}
if !errors.Is(err, bstore.ErrAbsent) {
return fmt.Errorf("get subjectpass key from accounts database: %w", err)
}
key = ""
const chars = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for i := 0; i < 16; i++ {
key += string(chars[subjectpassRand.Intn(len(chars))])
}
v.Key = key
return tx.Insert(&v)
})
}
// Ensure mailbox is present in database, adding records for the mailbox and its
// parents if they aren't present.
//
// If subscribe is true, any mailboxes that were created will also be subscribed to.
// Caller must hold account wlock.
// Caller must propagate changes if any.
func (a *Account) MailboxEnsureX(tx *bstore.Tx, name string, subscribe bool) (mb Mailbox, changes []Change) {
if norm.NFC.String(name) != name {
panic("mailbox name not normalized")
}
// Quick sanity check.
if strings.EqualFold(name, "inbox") && name != "Inbox" {
panic("bad casing for inbox")
}
elems := strings.Split(name, "/")
q := bstore.QueryTx[Mailbox](tx)
q.FilterFn(func(mb Mailbox) bool {
return mb.Name == elems[0] || strings.HasPrefix(mb.Name, elems[0]+"/")
})
l, err := q.List()
xcheckf(err, "list mailboxes")
mailboxes := map[string]Mailbox{}
for _, xmb := range l {
mailboxes[xmb.Name] = xmb
}
p := ""
for _, elem := range elems {
if p != "" {
p += "/"
}
p += elem
var ok bool
mb, ok = mailboxes[p]
if ok {
continue
}
uidval, err := a.NextUIDValidity(tx)
xcheckf(err, "next uid validity")
mb = Mailbox{
Name: p,
UIDValidity: uidval,
UIDNext: 1,
}
err = tx.Insert(&mb)
xcheckf(err, "creating new mailbox")
if subscribe {
err := tx.Insert(&Subscription{p})
if err != nil && !errors.Is(err, bstore.ErrUnique) {
xcheckf(err, "subscribing to mailbox")
}
}
changes = append(changes, ChangeAddMailbox{Name: p, Flags: []string{`\Subscribed`}})
}
return
}
// Check if mailbox exists.
// Caller must hold account rlock.
func (a *Account) MailboxExistsX(tx *bstore.Tx, name string) bool {
q := bstore.QueryTx[Mailbox](tx)
q.FilterEqual("Name", name)
exists, err := q.Exists()
xcheckf(err, "checking existence")
return exists
}
// MailboxFindX finds a mailbox by name.
func (a *Account) MailboxFindX(tx *bstore.Tx, name string) *Mailbox {
q := bstore.QueryTx[Mailbox](tx)
q.FilterEqual("Name", name)
mb, err := q.Get()
if err == bstore.ErrAbsent {
return nil
}
xcheckf(err, "lookup mailbox")
return &mb
}
// SubscriptionEnsureX ensures a subscription for name exists. The mailbox does not
// have to exist. Any parents are not automatically subscribed.
// Changes are broadcasted.
func (a *Account) SubscriptionEnsureX(tx *bstore.Tx, name string) []Change {
err := tx.Get(&Subscription{name})
if err == nil {
return nil
}
err = tx.Insert(&Subscription{name})
xcheckf(err, "inserting subscription")
q := bstore.QueryTx[Mailbox](tx)
q.FilterEqual("Name", name)
exists, err := q.Exists()
xcheckf(err, "looking up mailbox for subscription")
if exists {
return []Change{ChangeAddSubscription{name}}
}
return []Change{ChangeAddMailbox{Name: name, Flags: []string{`\Subscribed`, `\NonExistent`}}}
}
// List mailboxes. Only those that exist, so names with only a subscription are not returned.
// Caller must have account rlock held.
func (a *Account) MailboxesX(tx *bstore.Tx) []Mailbox {
l, err := bstore.QueryTx[Mailbox](tx).List()
xcheckf(err, "fetching mailboxes")
return l
}
// MessageRuleset returns the first ruleset (if any) that message the message
// represented by msgPrefix and msgFile, with smtp and validation fields from m.
func MessageRuleset(log *mlog.Log, dest config.Destination, m *Message, msgPrefix []byte, msgFile *os.File) *config.Ruleset {
if len(dest.Rulesets) == 0 {
return nil
}
mr := FileMsgReader(msgPrefix, msgFile) // We don't close, it would close the msgFile.
p, err := message.Parse(mr)
if err != nil {
log.Errorx("parsing message for evaluating rulesets, continuing with headers", err, mlog.Field("parse", ""))
// note: part is still set.
}
// todo optimize: only parse header if needed for rulesets. and probably reuse an earlier parsing.
header, err := p.Header()
if err != nil {
log.Errorx("parsing message headers for evaluating rulesets, delivering to default mailbox", err, mlog.Field("parse", ""))
// todo: reject message?
return nil
}
ruleset:
for _, rs := range dest.Rulesets {
if rs.SMTPMailFromRegexpCompiled != nil {
if !rs.SMTPMailFromRegexpCompiled.MatchString(m.MailFrom) {
continue ruleset
}
}
if !rs.VerifiedDNSDomain.IsZero() {
d := rs.VerifiedDNSDomain.Name()
suffix := "." + d
matchDomain := func(s string) bool {
return s == d || strings.HasSuffix(s, suffix)
}
var ok bool
if m.EHLOValidated && matchDomain(m.EHLODomain) {
ok = true
}
if m.MailFromValidated && matchDomain(m.MailFromDomain) {
ok = true
}
for _, d := range m.DKIMDomains {
if matchDomain(d) {
ok = true
break
}
}
if !ok {
continue ruleset
}
}
header:
for _, t := range rs.HeadersRegexpCompiled {
for k, vl := range header {
k = strings.ToLower(k)
if !t[0].MatchString(k) {
continue
}
for _, v := range vl {
v = strings.ToLower(strings.TrimSpace(v))
if t[1].MatchString(v) {
continue header
}
}
}
continue ruleset
}
return &rs
}
return nil
}
// MessagePath returns the file system path of a message.
func (a *Account) MessagePath(messageID int64) string {
return filepath.Join(a.Dir, "msg", MessagePath(messageID))
}
// MessageReader opens a message for reading, transparently combining the
// message prefix with the original incoming message.
func (a *Account) MessageReader(m Message) *MsgReader {
return &MsgReader{prefix: m.MsgPrefix, path: a.MessagePath(m.ID), size: m.Size}
}
// Deliver delivers an email to dest, based on the configured rulesets.
//
// Caller must hold account wlock (mailbox may be created).
// Message delivery and possible mailbox creation are broadcasted.
func (a *Account) Deliver(log *mlog.Log, dest config.Destination, m *Message, msgFile *os.File, consumeFile bool) error {
var mailbox string
rs := MessageRuleset(log, dest, m, m.MsgPrefix, msgFile)
if rs != nil {
mailbox = rs.Mailbox
} else if dest.Mailbox == "" {
mailbox = "Inbox"
} else {
mailbox = dest.Mailbox
}
return a.DeliverMailbox(log, mailbox, m, msgFile, consumeFile)
}
// DeliverMailbox delivers an email to the specified mailbox.
//
// Caller must hold account wlock (mailbox may be created).
// Message delivery and possible mailbox creation are broadcasted.
func (a *Account) DeliverMailbox(log *mlog.Log, mailbox string, m *Message, msgFile *os.File, consumeFile bool) error {
var changes []Change
err := extransact(a.DB, true, func(tx *bstore.Tx) error {
mb, chl := a.MailboxEnsureX(tx, mailbox, true)
m.MailboxID = mb.ID
m.MailboxOrigID = mb.ID
changes = append(changes, chl...)
a.DeliverX(log, tx, m, msgFile, consumeFile, mb.Sent, true, true)
return nil
})
// todo: if rename succeeded but transaction failed, we should remove the file.
if err != nil {
return err
}
changes = append(changes, ChangeAddUID{m.MailboxID, m.UID, m.Flags})
comm := RegisterComm(a)
defer comm.Unregister()
comm.Broadcast(changes)
return nil
}
// TidyRejectsMailbox removes old reject emails, and returns whether there is space for a new delivery.
//
// Caller most hold account wlock.
// Changes are broadcasted.
func (a *Account) TidyRejectsMailbox(rejectsMailbox string) (hasSpace bool, rerr error) {
var changes []Change
err := extransact(a.DB, true, func(tx *bstore.Tx) error {
mb := a.MailboxFindX(tx, rejectsMailbox)
if mb == nil {
// No messages have been delivered yet.
hasSpace = true
return nil
}
// Gather old messages to remove.
old := time.Now().Add(-24 * time.Hour)
qdel := bstore.QueryTx[Message](tx)
qdel.FilterNonzero(Message{MailboxID: mb.ID})
qdel.FilterLess("Received", old)
remove, err := qdel.List()
xcheckf(err, "listing old messages")
changes = a.xremoveMessages(tx, mb, remove)
// We allow up to n messages.
qcount := bstore.QueryTx[Message](tx)
qcount.FilterNonzero(Message{MailboxID: mb.ID})
qcount.Limit(1000)
n, err := qcount.Count()
xcheckf(err, "counting rejects")
hasSpace = n < 1000
return nil
})
comm := RegisterComm(a)
defer comm.Unregister()
comm.Broadcast(changes)
return hasSpace, err
}
func (a *Account) xremoveMessages(tx *bstore.Tx, mb *Mailbox, l []Message) []Change {
if len(l) == 0 {
return nil
}
ids := make([]int64, len(l))
anyids := make([]any, len(l))
for i, m := range l {
ids[i] = m.ID
anyids[i] = m.ID
}
// Remove any message recipients. Should not happen, but a user can move messages
// from a Sent mailbox to the rejects mailbox...
qdmr := bstore.QueryTx[Recipient](tx)
qdmr.FilterEqual("MessageID", anyids...)
_, err := qdmr.Delete()
xcheckf(err, "deleting from message recipient")
// Actually remove the messages.
qdm := bstore.QueryTx[Message](tx)
qdm.FilterIDs(ids)
_, err = qdm.Delete()
xcheckf(err, "deleting from message recipient")
changes := make([]Change, len(l))
for i, m := range l {
changes[i] = ChangeRemoveUIDs{mb.ID, []UID{m.UID}}
}
return changes
}
// RejectsRemove removes a message from the rejects mailbox if present.
// Caller most hold account wlock.
// Changes are broadcasted.
func (a *Account) RejectsRemove(log *mlog.Log, rejectsMailbox, messageID string) {
var changes []Change
err := extransact(a.DB, true, func(tx *bstore.Tx) error {
mb := a.MailboxFindX(tx, rejectsMailbox)
if mb == nil {
return nil
}
// Note: these cannot have Recipients.
var remove []Message
q := bstore.QueryTx[Message](tx)
q.FilterNonzero(Message{MailboxID: mb.ID, MessageID: messageID})
remove, err := q.List()
xcheckf(err, "listing messages to remove")
changes = a.xremoveMessages(tx, mb, remove)
return err
})
if err != nil {
log.Errorx("removing message from rejects mailbox", err, mlog.Field("account", a.Name), mlog.Field("rejectsMailbox", rejectsMailbox), mlog.Field("messageID", messageID))
}
comm := RegisterComm(a)
defer comm.Unregister()
comm.Broadcast(changes)
}
// We keep a cache of recent successful authentications, so we don't have to bcrypt successful calls each time.
var authCache struct {
sync.Mutex
success map[authKey]string
}
type authKey struct {
email, hash string
}
func init() {
authCache.success = map[authKey]string{}
go func() {
for {
authCache.Lock()
authCache.success = map[authKey]string{}
authCache.Unlock()
time.Sleep(15 * time.Minute)
}
}()
}
// OpenEmailAuth opens an account given an email address and password.
//
// The email address may contain a catchall separator.
func OpenEmailAuth(email string, password string) (acc *Account, rerr error) {
acc, _, rerr = OpenEmail(email)
if rerr != nil {
return
}
defer func() {
if rerr != nil && acc != nil {
acc.Close()
acc = nil
}
}()
pw, err := bstore.QueryDB[Password](acc.DB).Get()
if err != nil {
if err == bstore.ErrAbsent {
return acc, ErrUnknownCredentials
}
return acc, fmt.Errorf("looking up password: %v", err)
}
authCache.Lock()
ok := len(password) >= 8 && authCache.success[authKey{email, pw.Hash}] == password
authCache.Unlock()
if ok {
return
}
if err := bcrypt.CompareHashAndPassword([]byte(pw.Hash), []byte(password)); err != nil {
rerr = ErrUnknownCredentials
} else {
authCache.Lock()
authCache.success[authKey{email, pw.Hash}] = password
authCache.Unlock()
}
return
}
// OpenEmail opens an account given an email address.
//
// The email address may contain a catchall separator.
func OpenEmail(email string) (*Account, config.Destination, error) {
addr, err := smtp.ParseAddress(email)
if err != nil {
return nil, config.Destination{}, fmt.Errorf("%w: %v", ErrUnknownCredentials, err)
}
accountName, _, dest, err := mox.FindAccount(addr.Localpart, addr.Domain, false)
if err != nil && (errors.Is(err, mox.ErrAccountNotFound) || errors.Is(err, mox.ErrDomainNotFound)) {
return nil, config.Destination{}, ErrUnknownCredentials
} else if err != nil {
return nil, config.Destination{}, fmt.Errorf("looking up address: %v", err)
}
acc, err := OpenAccount(accountName)
if err != nil {
return nil, config.Destination{}, err
}
return acc, dest, nil
}
// 64 characters, must be power of 2 for MessagePath
const msgDirChars = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-_"
// MessagePath returns the filename of the on-disk filename, relative to the containing directory such as <account>/msg or queue.
// Returns names like "AB/1".
func MessagePath(messageID int64) string {
v := messageID >> 13 // 8k files per directory.
dir := ""
for {
dir += string(msgDirChars[int(v)&(len(msgDirChars)-1)])
v >>= 6
if v == 0 {
break
}
}
return fmt.Sprintf("%s/%d", dir, messageID)
}
// Set returns a copy of f, with each flag that is true in mask set to the
// value from flags.
func (f Flags) Set(mask, flags Flags) Flags {
set := func(d *bool, m, v bool) {
if m {
*d = v
}
}
r := f
set(&r.Seen, mask.Seen, flags.Seen)
set(&r.Answered, mask.Answered, flags.Answered)
set(&r.Flagged, mask.Flagged, flags.Flagged)
set(&r.Forwarded, mask.Forwarded, flags.Forwarded)
set(&r.Junk, mask.Junk, flags.Junk)
set(&r.Notjunk, mask.Notjunk, flags.Notjunk)
set(&r.Deleted, mask.Deleted, flags.Deleted)
set(&r.Draft, mask.Draft, flags.Draft)
set(&r.Phishing, mask.Phishing, flags.Phishing)
set(&r.MDNSent, mask.MDNSent, flags.MDNSent)
return r
}
// FlagsQuerySet returns a map with the flags that are true in mask, with
// values from flags.
func FlagsQuerySet(mask, flags Flags) map[string]any {
r := map[string]any{}
set := func(f string, m, v bool) {
if m {
r[f] = v
}
}
set("Seen", mask.Seen, flags.Seen)
set("Answered", mask.Answered, flags.Answered)
set("Flagged", mask.Flagged, flags.Flagged)
set("Forwarded", mask.Forwarded, flags.Forwarded)
set("Junk", mask.Junk, flags.Junk)
set("Notjunk", mask.Notjunk, flags.Notjunk)
set("Deleted", mask.Deleted, flags.Deleted)
set("Draft", mask.Draft, flags.Draft)
set("Phishing", mask.Phishing, flags.Phishing)
set("MDNSent", mask.MDNSent, flags.MDNSent)
return r
}