Files

571 lines
15 KiB
Go

package imapserver
import (
"errors"
"net/mail"
"sort"
"strings"
"github.com/emersion/go-imap/v2"
goimapserver "github.com/emersion/go-imap/v2/imapserver"
"mailgoserver/internal/abuseguard"
"mailgoserver/internal/db"
)
const inboxName = "INBOX"
var _ goimapserver.Session = (*Session)(nil)
// Session implements goimapserver.Session against one mailbox's messages via
// mailstore. Sequence numbers are recomputed fresh from the DB on every command
// rather than cached/tracked across concurrent updates.
// ponytail: no MailboxTracker/IDLE push support — Idle just blocks until the client
// sends DONE, so a connected client still gets new mail via NOOP/periodic re-SELECT,
// just not an instant push. Add a tracker if that matters.
type Session struct {
backend *Backend
peerIP string
mailbox *db.Mailbox // set once Login succeeds
selectedFolder string // set by Select; defaults to INBOX if empty
}
func (s *Session) Close() error { return nil }
// Login accepts only an app password (esrv_mailbox_app_passwords) — never the
// mailbox's own portal password, since IMAP AUTH has no interactive MFA step.
func (s *Session) Login(username, password string) error {
mbox, err := s.backend.DB.VerifyMailboxAppPassword(username, password)
if err != nil {
_ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Authentication error: "+err.Error())
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return err
}
if mbox == nil {
_ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, false, "Invalid credentials")
abuseguard.RecordFailureAndMaybeBlacklist(s.backend.DB, s.backend.Cfg, s.backend.Logger, s.peerIP)
return goimapserver.ErrAuthFailed
}
_ = s.backend.DB.LogAuthAttempt("imap_login", username, s.peerIP, true, "Successful IMAP app-password authentication")
s.mailbox = mbox
return nil
}
func (s *Session) requireAuth() error {
if s.mailbox == nil {
return errors.New("not authenticated")
}
return nil
}
func notFoundErr() error {
return &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeNonExistent, Text: "No such mailbox"}
}
func isInbox(name string) bool { return strings.EqualFold(name, inboxName) }
// folderExists reports whether name is a real folder for this mailbox — INBOX always
// is (even empty), any other name only if a filter rule's move_to_folder action has
// actually delivered something there (see mailstore's ApplyRules). Returns the
// canonical stored name (case as written in the DB) so callers use it consistently.
func (s *Session) folderExists(name string) (string, bool, error) {
if isInbox(name) {
return inboxName, true, nil
}
folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID)
if err != nil {
return "", false, err
}
for _, f := range folders {
if strings.EqualFold(f, name) {
return f, true, nil
}
}
return "", false, nil
}
// messages loads every stored message in the currently selected folder, ordered
// ascending by UID — this ordering IS the sequence-number mapping (index+1 == seqNum).
func (s *Session) messages() ([]db.MailboxMessage, error) {
folder := s.selectedFolder
if folder == "" {
folder = inboxName
}
return s.backend.DB.ListMessagesInFolder(s.mailbox.ID, folder)
}
func (s *Session) Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error) {
if err := s.requireAuth(); err != nil {
return nil, err
}
folder, ok, err := s.folderExists(mailbox)
if err != nil {
return nil, err
}
if !ok {
return nil, notFoundErr()
}
s.selectedFolder = folder
msgs, err := s.messages()
if err != nil {
return nil, err
}
flagSet := map[imap.Flag]struct{}{}
var firstUnseen uint32
for i, m := range msgs {
for _, f := range splitFlags(m.Flags) {
flagSet[f] = struct{}{}
}
if firstUnseen == 0 && !hasFlag(m.Flags, imap.FlagSeen) {
firstUnseen = uint32(i) + 1
}
}
var flags []imap.Flag
for f := range flagSet {
flags = append(flags, f)
}
sort.Slice(flags, func(i, j int) bool { return flags[i] < flags[j] })
permanent := append(append([]imap.Flag{}, flags...), imap.FlagWildcard)
return &imap.SelectData{
Flags: flags,
PermanentFlags: permanent,
NumMessages: uint32(len(msgs)),
FirstUnseenSeqNum: firstUnseen,
UIDNext: nextUID(msgs),
UIDValidity: uint32(s.mailbox.ID),
}, nil
}
func (s *Session) Unselect() error {
s.selectedFolder = ""
return nil
}
func (s *Session) Create(mailbox string, options *imap.CreateOptions) error {
return errors.New("creating mailboxes is not supported")
}
func (s *Session) Delete(mailbox string) error {
return errors.New("deleting mailboxes is not supported")
}
func (s *Session) Rename(mailbox, newName string, options *imap.RenameOptions) error {
return errors.New("renaming mailboxes is not supported")
}
func (s *Session) Subscribe(mailbox string) error {
_, ok, err := s.folderExists(mailbox)
if err != nil {
return err
}
if !ok {
return notFoundErr()
}
return nil
}
func (s *Session) Unsubscribe(mailbox string) error {
_, ok, err := s.folderExists(mailbox)
if err != nil {
return err
}
if !ok {
return notFoundErr()
}
return nil
}
// List reports every folder that actually has mail (plus INBOX, always) — a filter
// rule's move_to_folder action is what creates a second folder; there's no IMAP
// CREATE/manual folder management.
func (s *Session) List(w *goimapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
if err := s.requireAuth(); err != nil {
return err
}
folders, err := s.backend.DB.DistinctFoldersForMailbox(s.mailbox.ID)
if err != nil {
return err
}
if len(patterns) == 0 {
patterns = []string{""}
}
for _, pattern := range patterns {
if pattern == "" {
continue
}
for _, folder := range folders {
if !goimapserver.MatchList(folder, '/', ref, pattern) {
continue
}
if err := w.WriteList(&imap.ListData{Mailbox: folder, Delim: '/'}); err != nil {
return err
}
}
}
return nil
}
func (s *Session) Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) {
if err := s.requireAuth(); err != nil {
return nil, err
}
folder, ok, err := s.folderExists(mailbox)
if err != nil {
return nil, err
}
if !ok {
return nil, notFoundErr()
}
msgs, err := s.backend.DB.ListMessagesInFolder(s.mailbox.ID, folder)
if err != nil {
return nil, err
}
data := &imap.StatusData{Mailbox: folder, UIDValidity: uint32(s.mailbox.ID), UIDNext: nextUID(msgs)}
if options.NumMessages {
n := uint32(len(msgs))
data.NumMessages = &n
}
if options.NumUnseen {
var n uint32
for _, m := range msgs {
if !hasFlag(m.Flags, imap.FlagSeen) {
n++
}
}
data.NumUnseen = &n
}
if options.Size {
var size int64
for _, m := range msgs {
size += m.SizeBytes
}
data.Size = &size
}
return data, nil
}
func (s *Session) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
return nil, errors.New("APPEND is not supported yet")
}
func (s *Session) Poll(w *goimapserver.UpdateWriter, allowExpunge bool) error { return nil }
func (s *Session) Idle(w *goimapserver.UpdateWriter, stop <-chan struct{}) error {
<-stop
return nil
}
// Expunge permanently removes every \Deleted-flagged message matched by uids (or all
// of them, if uids is nil) — this is how a client actually deletes mail (STORE
// \Deleted, then EXPUNGE), calling all the way down to mailstore so the on-disk
// ciphertext is removed too, not just the index row.
func (s *Session) Expunge(w *goimapserver.ExpungeWriter, uids *imap.UIDSet) error {
if err := s.requireAuth(); err != nil {
return err
}
msgs, err := s.messages()
if err != nil {
return err
}
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if uids != nil && !uids.Contains(imap.UID(m.ID)) {
continue
}
if !hasFlag(m.Flags, imap.FlagDeleted) {
continue
}
if err := s.backend.Mailstore.DeleteMessage(s.mailbox.ID, m.ID); err != nil {
return err
}
if err := w.WriteExpunge(uint32(i) + 1); err != nil {
return err
}
}
return nil
}
// Search supports structural criteria (sequence/UID sets, flags, size, boolean
// combinators) without decrypting anything.
// ponytail: no header/body/text/date matching (would require decrypting every
// candidate message) — SEARCH FROM/SUBJECT/BODY/SINCE etc. are treated as always
// matching rather than filtering. Add if a client's search relies on it.
func (s *Session) Search(kind goimapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
if err := s.requireAuth(); err != nil {
return nil, err
}
msgs, err := s.messages()
if err != nil {
return nil, err
}
var data imap.SearchData
var seqSet imap.SeqSet
var uidSet imap.UIDSet
for i, m := range msgs {
seqNum := uint32(i) + 1
if !matchesSearch(m, seqNum, criteria) {
continue
}
uidSet.AddNum(imap.UID(m.ID))
seqSet.AddNum(seqNum)
data.Count++
num := seqNum
if kind == goimapserver.NumKindUID {
num = uint32(m.ID)
}
if data.Min == 0 || num < data.Min {
data.Min = num
}
if num > data.Max {
data.Max = num
}
}
if kind == goimapserver.NumKindUID {
data.All = uidSet
} else {
data.All = seqSet
}
return &data, nil
}
func matchesSearch(m db.MailboxMessage, seqNum uint32, c *imap.SearchCriteria) bool {
if c == nil {
return true
}
for _, ss := range c.SeqNum {
if !ss.Contains(seqNum) {
return false
}
}
for _, us := range c.UID {
if !us.Contains(imap.UID(m.ID)) {
return false
}
}
for _, f := range c.Flag {
if !hasFlag(m.Flags, f) {
return false
}
}
for _, f := range c.NotFlag {
if hasFlag(m.Flags, f) {
return false
}
}
if c.Larger > 0 && m.SizeBytes <= c.Larger {
return false
}
if c.Smaller > 0 && m.SizeBytes >= c.Smaller {
return false
}
for _, not := range c.Not {
if matchesSearch(m, seqNum, &not) {
return false
}
}
for _, or := range c.Or {
if !matchesSearch(m, seqNum, &or[0]) && !matchesSearch(m, seqNum, &or[1]) {
return false
}
}
return true
}
// Fetch streams FETCH responses for every message matched by numSet. Fetching a
// non-peek body section marks the message \Seen, mirroring standard IMAP semantics.
func (s *Session) Fetch(w *goimapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error {
if err := s.requireAuth(); err != nil {
return err
}
msgs, err := s.messages()
if err != nil {
return err
}
markSeen := false
for _, bs := range options.BodySection {
if !bs.Peek {
markSeen = true
}
}
for i, m := range msgs {
seqNum := uint32(i) + 1
if !numSetContains(numSet, seqNum, imap.UID(m.ID)) {
continue
}
if markSeen && !hasFlag(m.Flags, imap.FlagSeen) {
newFlags := addFlag(m.Flags, imap.FlagSeen)
if err := s.backend.DB.SetMessageFlags(s.mailbox.ID, m.ID, newFlags); err != nil {
return err
}
m.Flags = newFlags
}
if err := s.writeFetch(w.CreateMessage(seqNum), m, options); err != nil {
return err
}
}
return nil
}
// writeFetch writes one message's FETCH response.
// ponytail: no MIME-aware sub-part/header-only body-section extraction — any
// requested BODY[...] section returns the full raw RFC822 message regardless of the
// requested part/specifier. Real clients' basic "fetch the whole message" flow works
// fine with this; upgrade to real section extraction if header-only/sub-part fetches
// are needed later.
func (s *Session) writeFetch(w *goimapserver.FetchResponseWriter, m db.MailboxMessage, options *imap.FetchOptions) error {
w.WriteUID(imap.UID(m.ID))
if options.Flags {
w.WriteFlags(splitFlags(m.Flags))
}
if options.InternalDate {
w.WriteInternalDate(m.InternalDate)
}
if options.RFC822Size {
w.WriteRFC822Size(m.SizeBytes)
}
if options.Envelope {
w.WriteEnvelope(buildEnvelope(m))
}
if len(options.BodySection) > 0 {
raw, err := s.backend.Mailstore.FetchMessage(s.mailbox.ID, m.ID)
if err != nil {
return err
}
for _, bs := range options.BodySection {
wc := w.WriteBodySection(bs, int64(len(raw)))
if _, werr := wc.Write(raw); werr != nil {
wc.Close()
return werr
}
if cerr := wc.Close(); cerr != nil {
return cerr
}
}
}
return w.Close()
}
func buildEnvelope(m db.MailboxMessage) *imap.Envelope {
env := &imap.Envelope{Date: m.InternalDate, Subject: m.CachedSubject, MessageID: m.MessageIDHeader}
if addr, err := mail.ParseAddress(m.CachedFrom); err == nil {
mailbox, host := splitAddr(addr.Address)
env.From = []imap.Address{{Name: addr.Name, Mailbox: mailbox, Host: host}}
}
return env
}
func splitAddr(addr string) (mailbox, host string) {
i := strings.LastIndex(addr, "@")
if i < 0 {
return addr, ""
}
return addr[:i], addr[i+1:]
}
// Store applies a flag change to every message matched by numSet, then (unless
// .SILENT was requested) reports the resulting flags back via a FETCH response,
// mirroring standard STORE semantics.
func (s *Session) Store(w *goimapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error {
if err := s.requireAuth(); err != nil {
return err
}
msgs, err := s.messages()
if err != nil {
return err
}
for i, m := range msgs {
seqNum := uint32(i) + 1
if !numSetContains(numSet, seqNum, imap.UID(m.ID)) {
continue
}
newFlags := applyStoreFlags(m.Flags, flags)
if err := s.backend.DB.SetMessageFlags(s.mailbox.ID, m.ID, newFlags); err != nil {
return err
}
}
if flags.Silent {
return nil
}
return s.Fetch(w, numSet, &imap.FetchOptions{Flags: true})
}
func (s *Session) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) {
return nil, errors.New("COPY is not supported")
}
func nextUID(msgs []db.MailboxMessage) imap.UID {
if len(msgs) == 0 {
return 1
}
return imap.UID(msgs[len(msgs)-1].ID) + 1
}
func numSetContains(numSet imap.NumSet, seqNum uint32, uid imap.UID) bool {
switch ns := numSet.(type) {
case imap.SeqSet:
return ns.Contains(seqNum)
case imap.UIDSet:
return ns.Contains(uid)
}
return false
}
func splitFlags(s string) []imap.Flag {
if s == "" {
return nil
}
parts := strings.Fields(s)
out := make([]imap.Flag, len(parts))
for i, p := range parts {
out[i] = imap.Flag(p)
}
return out
}
func hasFlag(flags string, target imap.Flag) bool {
for _, f := range strings.Fields(flags) {
if imap.Flag(f) == target {
return true
}
}
return false
}
func addFlag(flags string, f imap.Flag) string {
if hasFlag(flags, f) {
return flags
}
if flags == "" {
return string(f)
}
return flags + " " + string(f)
}
func applyStoreFlags(current string, store *imap.StoreFlags) string {
set := map[imap.Flag]struct{}{}
for _, f := range splitFlags(current) {
set[f] = struct{}{}
}
switch store.Op {
case imap.StoreFlagsSet:
set = map[imap.Flag]struct{}{}
fallthrough
case imap.StoreFlagsAdd:
for _, f := range store.Flags {
set[f] = struct{}{}
}
case imap.StoreFlagsDel:
for _, f := range store.Flags {
delete(set, f)
}
}
out := make([]string, 0, len(set))
for f := range set {
out = append(out, string(f))
}
sort.Strings(out)
return strings.Join(out, " ")
}