added IMAP, LetsEncrypt, update layout
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
package imapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/mail"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
goimapserver "github.com/emersion/go-imap/v2/imapserver"
|
||||
|
||||
"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
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if mbox == nil {
|
||||
return goimapserver.ErrAuthFailed
|
||||
}
|
||||
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, " ")
|
||||
}
|
||||
Reference in New Issue
Block a user