571 lines
15 KiB
Go
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, ¬) {
|
|
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, " ")
|
|
}
|