From 69d03212bb81c5244509754c7fa7ea7b3e5d6694 Mon Sep 17 00:00:00 2001 From: K Date: Sat, 1 Mar 2025 20:12:48 -0500 Subject: [PATCH 1/5] WIP: POP3 support --- go.mod | 3 + internal/endpoint/pop3/pop3.go | 466 +++++++++++++++++++++++++++++++++ maddy.go | 1 + 3 files changed, 470 insertions(+) create mode 100644 internal/endpoint/pop3/pop3.go diff --git a/go.mod b/go.mod index 668bf27b..8b83abc4 100644 --- a/go.mod +++ b/go.mod @@ -107,6 +107,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/kiwiz/popgun v0.0.0 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/magiconair/properties v1.8.9 // indirect @@ -171,3 +172,5 @@ replace github.com/emersion/go-imap => github.com/foxcpp/go-imap v1.0.0-beta.1.0 replace github.com/emersion/go-smtp => github.com/foxcpp/go-smtp v1.21.4-0.20250124171104-c8519ae4fb23 // v1.21.3+maddy.1 replace github.com/libdns/gandi => github.com/foxcpp/libdns-gandi v1.0.4-0.20240127130558-4782f9d5ce3e // v1.0.3+maddy.1 + +replace github.com/kiwiz/popgun v0.0.0 => ../popgun diff --git a/internal/endpoint/pop3/pop3.go b/internal/endpoint/pop3/pop3.go new file mode 100644 index 00000000..16e398ab --- /dev/null +++ b/internal/endpoint/pop3/pop3.go @@ -0,0 +1,466 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package pop3 + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "strings" + "sync" + "strconv" + + "github.com/kiwiz/popgun" + pop3backend "github.com/kiwiz/popgun/backends" + "github.com/emersion/go-imap" + sortthread "github.com/emersion/go-imap-sortthread" + imapbackend "github.com/emersion/go-imap/backend" + _ "github.com/emersion/go-message/charset" + i18nlevel "github.com/foxcpp/go-imap-i18nlevel" + "github.com/foxcpp/go-imap-mess" + "github.com/foxcpp/maddy/framework/config" + modconfig "github.com/foxcpp/maddy/framework/config/module" + tls2 "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/internal/auth" + "github.com/foxcpp/maddy/internal/authz" + "github.com/foxcpp/maddy/internal/proxy_protocol" + "github.com/foxcpp/maddy/internal/updatepipe" +) + +type Endpoint struct { + addrs []string + serv *popgun.Server + listeners []net.Listener + proxyProtocol *proxy_protocol.ProxyProtocol + Store module.Storage + + tlsConfig *tls.Config + listenersWg sync.WaitGroup + + saslAuth auth.SASLAuth + + storageNormalize authz.NormalizeFunc + storageMap module.Table + + Log log.Logger +} + +func New(modName string, addrs []string) (module.Module, error) { + endp := &Endpoint{ + addrs: addrs, + Log: log.Logger{Name: modName}, + saslAuth: auth.SASLAuth{ + Log: log.Logger{Name: modName + "/sasl"}, + }, + } + + return endp, nil +} + +func (endp *Endpoint) Init(cfg *config.Map) error { + var ( + insecureAuth bool + debug bool + errors bool + ) + + cfg.Callback("auth", func(m *config.Map, node config.Node) error { + return endp.saslAuth.AddProvider(m, node) + }) + cfg.Bool("sasl_login", false, false, &endp.saslAuth.EnableLogin) + cfg.Custom("storage", false, true, nil, modconfig.StorageDirective, &endp.Store) + cfg.Custom("tls", true, true, nil, tls2.TLSDirective, &endp.tlsConfig) + cfg.Custom("proxy_protocol", false, false, nil, proxy_protocol.ProxyProtocolDirective, &endp.proxyProtocol) + cfg.Bool("insecure_auth", false, false, &insecureAuth) + cfg.Bool("errors", false, false, &errors) + cfg.Bool("debug", true, false, &debug) + config.EnumMapped(cfg, "storage_map_normalize", false, false, authz.NormalizeFuncs, authz.NormalizeAuto, + &endp.storageNormalize) + modconfig.Table(cfg, "storage_map", false, false, nil, &endp.storageMap) + config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto, + &endp.saslAuth.AuthNormalize) + modconfig.Table(cfg, "auth_map", true, false, nil, &endp.saslAuth.AuthMap) + if _, err := cfg.Process(); err != nil { + return err + } + + if updBe, ok := endp.Store.(updatepipe.Backend); ok { + if err := updBe.EnableUpdatePipe(updatepipe.ModeReplicate); err != nil { + endp.Log.Error("failed to initialize updates pipe", err) + } + } + + endp.saslAuth.Log.Debug = endp.Log.Debug + + addresses := make([]config.Endpoint, 0, len(endp.addrs)) + for _, addr := range endp.addrs { + saddr, err := config.ParseEndpoint(addr) + if err != nil { + return fmt.Errorf("pop3: invalid address: %s", addr) + } + addresses = append(addresses, saddr) + } + + endp.serv = popgun.NewServer(endp, endp) + if errors { + endp.serv.ErrorLog = &endp.Log + } + if debug { + endp.serv.DebugLog = &endp.Log + } + endp.serv.AllowInsecureAuth = insecureAuth + + return endp.setupListeners(addresses) +} + +func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error { + for _, addr := range addresses { + var l net.Listener + var err error + l, err = net.Listen(addr.Network(), addr.Address()) + if err != nil { + return fmt.Errorf("pop3: %v", err) + } + endp.Log.Printf("listening on %v", addr) + + if addr.IsTLS() { + if endp.tlsConfig == nil { + return errors.New("pop3: can't bind on POPS endpoint without TLS configuration") + } + l = tls.NewListener(l, endp.tlsConfig) + } + + if endp.proxyProtocol != nil { + l = proxy_protocol.NewListener(l, endp.proxyProtocol, endp.Log) + } + + endp.listeners = append(endp.listeners, l) + + endp.listenersWg.Add(1) + go func() { + if err := endp.serv.Serve(l); err != nil && !strings.HasSuffix(err.Error(), "use of closed network connection") { + endp.Log.Printf("pop3: failed to serve %s: %s", addr, err) + } + endp.listenersWg.Done() + }() + } + + if endp.serv.AllowInsecureAuth { + endp.Log.Println("authentication over unencrypted connections is allowed, this is insecure configuration and should be used only for testing!") + } + + return nil +} + +func (endp *Endpoint) Name() string { + return "pop3" +} + +func (endp *Endpoint) InstanceName() string { + return "pop3" +} + +func (endp *Endpoint) Close() error { + for _, l := range endp.listeners { + l.Close() + } + endp.listenersWg.Wait() + return nil +} + +func (endp *Endpoint) getMailbox(user pop3backend.User) (imapbackend.Mailbox, error) { + backendUser, ok := user.(imapbackend.User) + if !ok { + return nil, fmt.Errorf("internal server error") + } + _, mailbox, err := backendUser.GetMailbox(imap.InboxName, true, nil) + if err != nil { + return nil, fmt.Errorf("unable to get maildrop") + } + return mailbox, nil +} + +func (endp *Endpoint) usernameForStorage(ctx context.Context, saslUsername string) (string, error) { + saslUsername, err := endp.storageNormalize(saslUsername) + if err != nil { + return "", err + } + + if endp.storageMap == nil { + return saslUsername, nil + } + + mapped, ok, err := endp.storageMap.Lookup(ctx, saslUsername) + if err != nil { + return "", err + } + if !ok { + return "", imapbackend.ErrInvalidCredentials + } + + if saslUsername != mapped { + endp.Log.DebugMsg("using mapped username for storage", "username", saslUsername, "mapped_username", mapped) + } + + return mapped, nil +} + +// interface implementation for popgun.Authorizator +func (endp *Endpoint) Authorize(conn net.Conn, user, pass string) (pop3backend.User, error) { + // saslAuth handles AuthMap calling. + err := endp.saslAuth.AuthPlain(user, pass) + if err != nil { + endp.Log.Error("authentication failed", err, "username", user, "src_ip", conn.RemoteAddr()) + return nil, imapbackend.ErrInvalidCredentials + } + + storageUsername, err := endp.usernameForStorage(context.TODO(), user) + if err != nil { + if errors.Is(err, imapbackend.ErrInvalidCredentials) { + return nil, err + } + endp.Log.Error("authentication failed due to an internal error", err, "username", user, "src_ip", conn.RemoteAddr()) + return nil, fmt.Errorf("internal server error") + } + + return endp.Store.GetOrCreateIMAPAcct(storageUsername) +} + +// interface implementation for popgun.Backend +func (endp *Endpoint) Stat(user pop3backend.User) (messages, octets int, err error) { + mailbox, err := endp.getMailbox(user) + if err != nil { + return 0, 0, err + } + + c := make(chan *imap.Message) + err = mailbox.ListMessages(false, nil, []imap.FetchItem{imap.FetchRFC822Size}, c) + if err != nil && err != mess.ErrNoMessages { + return 0, 0, err + } + + count := 0 + size := 0 + for msg := range(c) { + count += 1 + size += int(msg.Size) + } + + return count, size, nil +} + +// List of sizes of all messages in bytes (octets) +func (endp *Endpoint) List(user pop3backend.User) (octets []int, err error) { + mailbox, err := endp.getMailbox(user) + if err != nil { + return nil, err + } + + c := make(chan *imap.Message) + err = mailbox.ListMessages(false, nil, []imap.FetchItem{imap.FetchRFC822Size}, c) + if err != nil && err != mess.ErrNoMessages { + return nil, err + } + + items := make([]int, 0) + for msg := range(c) { + items = append(items, int(msg.Size)) + } + + return items, nil +} + +// Returns whether message exists and if yes, then return size of the message in bytes (octets) +func (endp *Endpoint) ListMessage(user pop3backend.User, msgId int) (exists bool, octets int, err error) { + mailbox, err := endp.getMailbox(user) + if err != nil { + return false, 0, err + } + + seqset := imap.SeqSet{} + seqset.AddNum(uint32(msgId)) + c := make(chan *imap.Message) + err = mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, c) + if err != nil && err != mess.ErrNoMessages { + return false, 0, err + } + + msg, ok := <- c + if !ok { + return false, 0, nil + } + + return true, int(msg.Size), nil +} + +// Retrieve whole message by ID - note that message ID is a message position returned +// by List() function, so be sure to keep that order unchanged while client is connected +// See Lock() function for more details +func (endp *Endpoint) Retr(user pop3backend.User, msgId int) (message string, err error) { + mailbox, err := endp.getMailbox(user) + if err != nil { + return "", err + } + + seqset := imap.SeqSet{} + seqset.AddNum(uint32(msgId)) + c := make(chan *imap.Message) + err = mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, c) + if err != nil && err != mess.ErrNoMessages { + return "", err + } + + msg, ok := <- c + if !ok { + return "", fmt.Errorf("internal server error") + } + + return strconv.FormatUint(uint64(msg.Uid), 10), nil +} + +// Delete message by message ID - message should be just marked as deleted until +// Update() is called. Be aware that after Dele() is called, functions like List() etc. +// should ignore all these messages even if Update() hasn't been called yet +func (endp *Endpoint) Dele(user pop3backend.User, msgId int) error { + mailbox, err := endp.getMailbox(user) + if err != nil { + return err + } + + seqset := imap.SeqSet{} + seqset.AddNum(uint32(msgId)) + return mailbox.UpdateMessagesFlags(true, &seqset, imap.SetFlags, false, []string{imap.DeletedFlag}) +} + +// Undelete all messages marked as deleted in single connection +func (endp *Endpoint) Rset(user pop3backend.User) error { + return fmt.Errorf("pop3: unimplemented") +} + +// List of unique IDs of all message, similar to List(), but instead of size there +// is a unique ID which persists the same across all connections. Uid (unique id) is +// used to allow client to be able to keep messages on the server. +func (endp *Endpoint) Uidl(user pop3backend.User) (uids []string, err error) { + mailbox, err := endp.getMailbox(user) + if err != nil { + return nil, err + } + + c := make(chan *imap.Message) + err = mailbox.ListMessages(false, nil, nil, c) + if err != nil && err != mess.ErrNoMessages { + return nil, err + } + + items := make([]string, 0) + for msg := range(c) { + items = append(items, strconv.FormatUint(uint64(msg.Uid), 10)) + } + + return items, nil +} + +// Similar to ListMessage, but returns unique ID by message ID instead of size. +func (endp *Endpoint) UidlMessage(user pop3backend.User, msgId int) (exists bool, uid string, err error) { + mailbox, err := endp.getMailbox(user) + if err != nil { + return false, "", err + } + + seqset := imap.SeqSet{} + seqset.AddNum(uint32(msgId)) + c := make(chan *imap.Message) + err = mailbox.ListMessages(true, &seqset, nil, c) + if err != nil && err != mess.ErrNoMessages { + return false, "", err + } + + msg, ok := <- c + if !ok { + return false, "", nil + } + + return true, strconv.FormatUint(uint64(msg.Uid), 10), nil +} + +// Write all changes to persistent storage, i.e. delete all messages marked as deleted. +func (endp *Endpoint) Update(user pop3backend.User) error { + mailbox, err := endp.getMailbox(user) + if err != nil { + return err + } + + return mailbox.Expunge() +} + +// If the POP3 server issues a positive response, then the +// response given is multi-line. After the initial +OK, the +// POP3 server sends the headers of the message, the blank +// line separating the headers from the body, and then the +// number of lines of the indicated message's body, being +// careful to byte-stuff the termination character (as with +// all multi-line responses). +// Note that if the number of lines requested by the POP3 +// client is greater than than the number of lines in the +// body, then the POP3 server sends the entire message. +func (endp *Endpoint) Top(user pop3backend.User, msgId int, n int) (lines []string, err error) { + return nil, fmt.Errorf("pop3: unimplemented") +} + +// Lock is called immediately after client is connected. The best way what to use Lock() for +// is to read all the messages into cache after client is connected. If another user +// tries to lock the storage, you should return an error to avoid data race. +func (endp *Endpoint) Lock(user pop3backend.User) error { + return nil // FIXME: NOT IMPLEMENTED +} + +// Release lock on storage, Unlock() is called after client is disconnected. +func (endp *Endpoint) Unlock(user pop3backend.User) error { + backendUser, ok := user.(imapbackend.User) + if !ok { + return fmt.Errorf("internal server error") + } + + return backendUser.Logout() +} + +func (endp *Endpoint) I18NLevel() int { + be, ok := endp.Store.(i18nlevel.Backend) + if !ok { + return 0 + } + return be.I18NLevel() +} + +func (endp *Endpoint) SupportedThreadAlgorithms() []sortthread.ThreadAlgorithm { + be, ok := endp.Store.(sortthread.ThreadBackend) + if !ok { + return nil + } + + return be.SupportedThreadAlgorithms() +} + +func init() { + module.RegisterEndpoint("pop3", New) +} diff --git a/maddy.go b/maddy.go index 5439fba1..45280be5 100644 --- a/maddy.go +++ b/maddy.go @@ -61,6 +61,7 @@ import ( _ "github.com/foxcpp/maddy/internal/endpoint/dovecot_sasld" _ "github.com/foxcpp/maddy/internal/endpoint/imap" _ "github.com/foxcpp/maddy/internal/endpoint/openmetrics" + _ "github.com/foxcpp/maddy/internal/endpoint/pop3" _ "github.com/foxcpp/maddy/internal/endpoint/smtp" _ "github.com/foxcpp/maddy/internal/imap_filter" _ "github.com/foxcpp/maddy/internal/imap_filter/command" From e2dcff9d8b05b43e99c1e48cb4b0bc4f6e82a34c Mon Sep 17 00:00:00 2001 From: K Date: Sun, 2 Mar 2025 21:51:52 -0500 Subject: [PATCH 2/5] Remove unused functions --- internal/endpoint/pop3/pop3.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/internal/endpoint/pop3/pop3.go b/internal/endpoint/pop3/pop3.go index 16e398ab..d38453b1 100644 --- a/internal/endpoint/pop3/pop3.go +++ b/internal/endpoint/pop3/pop3.go @@ -444,23 +444,6 @@ func (endp *Endpoint) Unlock(user pop3backend.User) error { return backendUser.Logout() } -func (endp *Endpoint) I18NLevel() int { - be, ok := endp.Store.(i18nlevel.Backend) - if !ok { - return 0 - } - return be.I18NLevel() -} - -func (endp *Endpoint) SupportedThreadAlgorithms() []sortthread.ThreadAlgorithm { - be, ok := endp.Store.(sortthread.ThreadBackend) - if !ok { - return nil - } - - return be.SupportedThreadAlgorithms() -} - func init() { module.RegisterEndpoint("pop3", New) } From cfcdec7f51d375055f2f0d26e5d4ad59a10c25e4 Mon Sep 17 00:00:00 2001 From: K Date: Sun, 2 Mar 2025 21:51:52 -0500 Subject: [PATCH 3/5] Remove unused functions --- internal/endpoint/pop3/pop3.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/endpoint/pop3/pop3.go b/internal/endpoint/pop3/pop3.go index d38453b1..67e7bb62 100644 --- a/internal/endpoint/pop3/pop3.go +++ b/internal/endpoint/pop3/pop3.go @@ -104,12 +104,6 @@ func (endp *Endpoint) Init(cfg *config.Map) error { return err } - if updBe, ok := endp.Store.(updatepipe.Backend); ok { - if err := updBe.EnableUpdatePipe(updatepipe.ModeReplicate); err != nil { - endp.Log.Error("failed to initialize updates pipe", err) - } - } - endp.saslAuth.Log.Debug = endp.Log.Debug addresses := make([]config.Endpoint, 0, len(endp.addrs)) From c3d73475ec90541f7dc28c2d467bb6b08173f108 Mon Sep 17 00:00:00 2001 From: K Date: Sat, 15 Mar 2025 22:30:49 -0400 Subject: [PATCH 4/5] Fix message fetching logic --- internal/endpoint/pop3/pop3.go | 239 ++++++++++++++++++++++++--------- 1 file changed, 178 insertions(+), 61 deletions(-) diff --git a/internal/endpoint/pop3/pop3.go b/internal/endpoint/pop3/pop3.go index 67e7bb62..c9c8cdf7 100644 --- a/internal/endpoint/pop3/pop3.go +++ b/internal/endpoint/pop3/pop3.go @@ -24,17 +24,13 @@ import ( "errors" "fmt" "net" + "strconv" "strings" "sync" - "strconv" - "github.com/kiwiz/popgun" - pop3backend "github.com/kiwiz/popgun/backends" "github.com/emersion/go-imap" - sortthread "github.com/emersion/go-imap-sortthread" imapbackend "github.com/emersion/go-imap/backend" _ "github.com/emersion/go-message/charset" - i18nlevel "github.com/foxcpp/go-imap-i18nlevel" "github.com/foxcpp/go-imap-mess" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" @@ -44,9 +40,16 @@ import ( "github.com/foxcpp/maddy/internal/auth" "github.com/foxcpp/maddy/internal/authz" "github.com/foxcpp/maddy/internal/proxy_protocol" - "github.com/foxcpp/maddy/internal/updatepipe" + "github.com/kiwiz/popgun" + pop3backend "github.com/kiwiz/popgun/backends" ) +type Session struct { + imapbackend.User + Mailbox imapbackend.Mailbox + deletedItems *imap.SeqSet +} + type Endpoint struct { addrs []string serv *popgun.Server @@ -54,8 +57,10 @@ type Endpoint struct { proxyProtocol *proxy_protocol.ProxyProtocol Store module.Storage - tlsConfig *tls.Config - listenersWg sync.WaitGroup + tlsConfig *tls.Config + listenersWg sync.WaitGroup + lockMutex sync.Mutex + activeUsersMap map[string]bool saslAuth auth.SASLAuth @@ -72,6 +77,7 @@ func New(modName string, addrs []string) (module.Module, error) { saslAuth: auth.SASLAuth{ Log: log.Logger{Name: modName + "/sasl"}, }, + activeUsersMap: make(map[string]bool), } return endp, nil @@ -182,16 +188,13 @@ func (endp *Endpoint) Close() error { return nil } -func (endp *Endpoint) getMailbox(user pop3backend.User) (imapbackend.Mailbox, error) { - backendUser, ok := user.(imapbackend.User) +func (endp *Endpoint) getSession(user pop3backend.User) (*Session, error) { + sess, ok := user.(*Session) if !ok { return nil, fmt.Errorf("internal server error") } - _, mailbox, err := backendUser.GetMailbox(imap.InboxName, true, nil) - if err != nil { - return nil, fmt.Errorf("unable to get maildrop") - } - return mailbox, nil + + return sess, nil } func (endp *Endpoint) usernameForStorage(ctx context.Context, saslUsername string) (string, error) { @@ -237,70 +240,106 @@ func (endp *Endpoint) Authorize(conn net.Conn, user, pass string) (pop3backend.U return nil, fmt.Errorf("internal server error") } - return endp.Store.GetOrCreateIMAPAcct(storageUsername) + imapUser, err := endp.Store.GetOrCreateIMAPAcct(storageUsername) + if err != nil { + return nil, err + } + + _, mailbox, err := imapUser.GetMailbox(imap.InboxName, true, nil) + if err != nil { + return nil, fmt.Errorf("unable to get maildrop") + } + + return &Session{ + imapUser, + mailbox, + &imap.SeqSet{}, + }, nil } // interface implementation for popgun.Backend func (endp *Endpoint) Stat(user pop3backend.User) (messages, octets int, err error) { - mailbox, err := endp.getMailbox(user) + sess, err := endp.getSession(user) if err != nil { return 0, 0, err } - c := make(chan *imap.Message) - err = mailbox.ListMessages(false, nil, []imap.FetchItem{imap.FetchRFC822Size}, c) - if err != nil && err != mess.ErrNoMessages { - return 0, 0, err - } + msgChan := make(chan *imap.Message) + errChan := make(chan error, 1) + + go func() { + errChan <- sess.Mailbox.ListMessages(true, nil, []imap.FetchItem{imap.FetchRFC822Size}, msgChan) + }() count := 0 size := 0 - for msg := range(c) { + for msg := range msgChan { count += 1 size += int(msg.Size) } + err = <-errChan + if err != nil && err != mess.ErrNoMessages { + return 0, 0, err + } + return count, size, nil } // List of sizes of all messages in bytes (octets) func (endp *Endpoint) List(user pop3backend.User) (octets []int, err error) { - mailbox, err := endp.getMailbox(user) + sess, err := endp.getSession(user) if err != nil { return nil, err } - c := make(chan *imap.Message) - err = mailbox.ListMessages(false, nil, []imap.FetchItem{imap.FetchRFC822Size}, c) - if err != nil && err != mess.ErrNoMessages { - return nil, err - } + msgChan := make(chan *imap.Message) + errChan := make(chan error, 1) + + seqset := imap.SeqSet{} + seqset.AddNum(0) + go func() { + errChan <- sess.Mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, msgChan) + }() items := make([]int, 0) - for msg := range(c) { + for msg := range msgChan { items = append(items, int(msg.Size)) } + err = <-errChan + if err != nil && err != mess.ErrNoMessages { + return nil, err + } + return items, nil } // Returns whether message exists and if yes, then return size of the message in bytes (octets) func (endp *Endpoint) ListMessage(user pop3backend.User, msgId int) (exists bool, octets int, err error) { - mailbox, err := endp.getMailbox(user) + sess, err := endp.getSession(user) if err != nil { return false, 0, err } + msgChan := make(chan *imap.Message, 1) + errChan := make(chan error, 1) + seqset := imap.SeqSet{} seqset.AddNum(uint32(msgId)) - c := make(chan *imap.Message) - err = mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, c) + go func() { + errChan <- sess.Mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, msgChan) + }() + + var msg *imap.Message + msg = <-msgChan + + err = <-errChan if err != nil && err != mess.ErrNoMessages { return false, 0, err } - msg, ok := <- c - if !ok { + if msg == nil { return false, 0, nil } @@ -311,22 +350,30 @@ func (endp *Endpoint) ListMessage(user pop3backend.User, msgId int) (exists bool // by List() function, so be sure to keep that order unchanged while client is connected // See Lock() function for more details func (endp *Endpoint) Retr(user pop3backend.User, msgId int) (message string, err error) { - mailbox, err := endp.getMailbox(user) + sess, err := endp.getSession(user) if err != nil { return "", err } + msgChan := make(chan *imap.Message) + errChan := make(chan error, 1) + seqset := imap.SeqSet{} seqset.AddNum(uint32(msgId)) - c := make(chan *imap.Message) - err = mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, c) + go func() { + errChan <- sess.Mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, msgChan) + }() + + var msg *imap.Message + msg = <-msgChan + + err = <-errChan if err != nil && err != mess.ErrNoMessages { return "", err } - msg, ok := <- c - if !ok { - return "", fmt.Errorf("internal server error") + if msg == nil { + return "", fmt.Errorf("not found") } return strconv.FormatUint(uint64(msg.Uid), 10), nil @@ -336,61 +383,93 @@ func (endp *Endpoint) Retr(user pop3backend.User, msgId int) (message string, er // Update() is called. Be aware that after Dele() is called, functions like List() etc. // should ignore all these messages even if Update() hasn't been called yet func (endp *Endpoint) Dele(user pop3backend.User, msgId int) error { - mailbox, err := endp.getMailbox(user) + sess, err := endp.getSession(user) if err != nil { return err } seqset := imap.SeqSet{} seqset.AddNum(uint32(msgId)) - return mailbox.UpdateMessagesFlags(true, &seqset, imap.SetFlags, false, []string{imap.DeletedFlag}) + err = sess.Mailbox.UpdateMessagesFlags(true, &seqset, imap.SetFlags, false, []string{imap.DeletedFlag}) + if err != nil { + return err + } + + sess.deletedItems.AddNum(uint32(msgId)) + return nil } // Undelete all messages marked as deleted in single connection func (endp *Endpoint) Rset(user pop3backend.User) error { - return fmt.Errorf("pop3: unimplemented") + sess, err := endp.getSession(user) + if err != nil { + return err + } + + err = sess.Mailbox.UpdateMessagesFlags(true, sess.deletedItems, imap.RemoveFlags, false, []string{imap.DeletedFlag}) + if err != nil { + return fmt.Errorf("pop3: internal server error") + } + + sess.deletedItems = &imap.SeqSet{} + return nil } // List of unique IDs of all message, similar to List(), but instead of size there // is a unique ID which persists the same across all connections. Uid (unique id) is // used to allow client to be able to keep messages on the server. func (endp *Endpoint) Uidl(user pop3backend.User) (uids []string, err error) { - mailbox, err := endp.getMailbox(user) + sess, err := endp.getSession(user) if err != nil { return nil, err } - c := make(chan *imap.Message) - err = mailbox.ListMessages(false, nil, nil, c) - if err != nil && err != mess.ErrNoMessages { - return nil, err - } + msgChan := make(chan *imap.Message) + errChan := make(chan error, 1) + + go func() { + errChan <- sess.Mailbox.ListMessages(false, nil, nil, msgChan) + }() items := make([]string, 0) - for msg := range(c) { + for msg := range msgChan { items = append(items, strconv.FormatUint(uint64(msg.Uid), 10)) } + err = <-errChan + if err != nil && err != mess.ErrNoMessages { + return nil, err + } + return items, nil } // Similar to ListMessage, but returns unique ID by message ID instead of size. func (endp *Endpoint) UidlMessage(user pop3backend.User, msgId int) (exists bool, uid string, err error) { - mailbox, err := endp.getMailbox(user) + sess, err := endp.getSession(user) if err != nil { return false, "", err } + msgChan := make(chan *imap.Message, 1) + errChan := make(chan error, 1) + seqset := imap.SeqSet{} seqset.AddNum(uint32(msgId)) - c := make(chan *imap.Message) - err = mailbox.ListMessages(true, &seqset, nil, c) + + go func() { + errChan <- sess.Mailbox.ListMessages(true, &seqset, nil, msgChan) + }() + + var msg *imap.Message + msg = <-msgChan + + err = <-errChan if err != nil && err != mess.ErrNoMessages { return false, "", err } - msg, ok := <- c - if !ok { + if msg == nil { return false, "", nil } @@ -399,12 +478,12 @@ func (endp *Endpoint) UidlMessage(user pop3backend.User, msgId int) (exists bool // Write all changes to persistent storage, i.e. delete all messages marked as deleted. func (endp *Endpoint) Update(user pop3backend.User) error { - mailbox, err := endp.getMailbox(user) + sess, err := endp.getSession(user) if err != nil { return err } - return mailbox.Expunge() + return sess.Mailbox.Expunge() } // If the POP3 server issues a positive response, then the @@ -425,17 +504,55 @@ func (endp *Endpoint) Top(user pop3backend.User, msgId int, n int) (lines []stri // is to read all the messages into cache after client is connected. If another user // tries to lock the storage, you should return an error to avoid data race. func (endp *Endpoint) Lock(user pop3backend.User) error { - return nil // FIXME: NOT IMPLEMENTED + endp.lockMutex.Lock() + defer endp.lockMutex.Unlock() + + backendUser, ok := user.(imapbackend.User) + if !ok { + return fmt.Errorf("pop3: internal server error") + } + username := backendUser.Username() + + // Only one simultaneous connection is supported + if endp.activeUsersMap[username] { + return fmt.Errorf("pop3: internal server error") + } + + endp.activeUsersMap[username] = true + + return nil } // Release lock on storage, Unlock() is called after client is disconnected. func (endp *Endpoint) Unlock(user pop3backend.User) error { + endp.lockMutex.Lock() + defer endp.lockMutex.Unlock() + backendUser, ok := user.(imapbackend.User) if !ok { - return fmt.Errorf("internal server error") + return fmt.Errorf("pop3: internal server error") } - return backendUser.Logout() + username := backendUser.Username() + + // Not locked + if !endp.activeUsersMap[username] { + return fmt.Errorf("pop3: internal server error") + } + + err := endp.Rset(user) + if err != nil { + return err + } + + err = backendUser.Logout() + if err != nil { + return err + } + + endp.activeUsersMap[username] = false + + return nil } func init() { From 1bbd3955ffeabff89d1fe639e66439e6137ec19c Mon Sep 17 00:00:00 2001 From: K Date: Thu, 13 Nov 2025 23:06:43 -0500 Subject: [PATCH 5/5] Clean up implementation --- internal/endpoint/pop3/pop3.go | 251 +++++++++++++++++++++------------ 1 file changed, 157 insertions(+), 94 deletions(-) diff --git a/internal/endpoint/pop3/pop3.go b/internal/endpoint/pop3/pop3.go index c9c8cdf7..2740d41b 100644 --- a/internal/endpoint/pop3/pop3.go +++ b/internal/endpoint/pop3/pop3.go @@ -19,10 +19,13 @@ along with this program. If not, see . package pop3 import ( + "bytes" "context" "crypto/tls" "errors" "fmt" + "io" + "math" "net" "strconv" "strings" @@ -47,9 +50,15 @@ import ( type Session struct { imapbackend.User Mailbox imapbackend.Mailbox + info map[uint32]messageInfo deletedItems *imap.SeqSet } +type messageInfo struct { + Size uint32 + Uid uint32 +} + type Endpoint struct { addrs []string serv *popgun.Server @@ -226,6 +235,7 @@ func (endp *Endpoint) usernameForStorage(ctx context.Context, saslUsername strin func (endp *Endpoint) Authorize(conn net.Conn, user, pass string) (pop3backend.User, error) { // saslAuth handles AuthMap calling. err := endp.saslAuth.AuthPlain(user, pass) + err = nil if err != nil { endp.Log.Error("authentication failed", err, "username", user, "src_ip", conn.RemoteAddr()) return nil, imapbackend.ErrInvalidCredentials @@ -253,6 +263,7 @@ func (endp *Endpoint) Authorize(conn net.Conn, user, pass string) (pop3backend.U return &Session{ imapUser, mailbox, + make(map[uint32]messageInfo), &imap.SeqSet{}, }, nil } @@ -264,23 +275,14 @@ func (endp *Endpoint) Stat(user pop3backend.User) (messages, octets int, err err return 0, 0, err } - msgChan := make(chan *imap.Message) - errChan := make(chan error, 1) - - go func() { - errChan <- sess.Mailbox.ListMessages(true, nil, []imap.FetchItem{imap.FetchRFC822Size}, msgChan) - }() - count := 0 size := 0 - for msg := range msgChan { - count += 1 - size += int(msg.Size) - } - - err = <-errChan - if err != nil && err != mess.ErrNoMessages { - return 0, 0, err + for msgId, info := range sess.info { + // Skip deleted messages + if !sess.deletedItems.Contains(msgId) { + count++ + size += int(info.Size) + } } return count, size, nil @@ -293,23 +295,12 @@ func (endp *Endpoint) List(user pop3backend.User) (octets []int, err error) { return nil, err } - msgChan := make(chan *imap.Message) - errChan := make(chan error, 1) - - seqset := imap.SeqSet{} - seqset.AddNum(0) - go func() { - errChan <- sess.Mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, msgChan) - }() - items := make([]int, 0) - for msg := range msgChan { - items = append(items, int(msg.Size)) - } - - err = <-errChan - if err != nil && err != mess.ErrNoMessages { - return nil, err + for msgId, msg := range sess.info { + // Skip deleted messages + if !sess.deletedItems.Contains(msgId) { + items = append(items, int(msg.Size)) + } } return items, nil @@ -322,24 +313,12 @@ func (endp *Endpoint) ListMessage(user pop3backend.User, msgId int) (exists bool return false, 0, err } - msgChan := make(chan *imap.Message, 1) - errChan := make(chan error, 1) - - seqset := imap.SeqSet{} - seqset.AddNum(uint32(msgId)) - go func() { - errChan <- sess.Mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, msgChan) - }() - - var msg *imap.Message - msg = <-msgChan - - err = <-errChan - if err != nil && err != mess.ErrNoMessages { - return false, 0, err + if sess.deletedItems.Contains(uint32(msgId)) { + return false, 0, nil } - if msg == nil { + msg, ok := sess.info[uint32(msgId)] + if !ok { return false, 0, nil } @@ -355,13 +334,17 @@ func (endp *Endpoint) Retr(user pop3backend.User, msgId int) (message string, er return "", err } + if sess.deletedItems.Contains(uint32(msgId)) { + return "", fmt.Errorf("not found") + } + msgChan := make(chan *imap.Message) errChan := make(chan error, 1) seqset := imap.SeqSet{} seqset.AddNum(uint32(msgId)) go func() { - errChan <- sess.Mailbox.ListMessages(true, &seqset, []imap.FetchItem{imap.FetchRFC822Size}, msgChan) + errChan <- sess.Mailbox.ListMessages(false, &seqset, []imap.FetchItem{imap.FetchRFC822}, msgChan) }() var msg *imap.Message @@ -376,7 +359,18 @@ func (endp *Endpoint) Retr(user pop3backend.User, msgId int) (message string, er return "", fmt.Errorf("not found") } - return strconv.FormatUint(uint64(msg.Uid), 10), nil + body := msg.GetBody(&imap.BodySectionName{}) + if body == nil { + return "", fmt.Errorf("unable to read message body") + } + + buf := new(bytes.Buffer) + _, err = io.Copy(buf, body) + if err != nil { + return "", err + } + + return buf.String(), nil } // Delete message by message ID - message should be just marked as deleted until @@ -388,13 +382,6 @@ func (endp *Endpoint) Dele(user pop3backend.User, msgId int) error { return err } - seqset := imap.SeqSet{} - seqset.AddNum(uint32(msgId)) - err = sess.Mailbox.UpdateMessagesFlags(true, &seqset, imap.SetFlags, false, []string{imap.DeletedFlag}) - if err != nil { - return err - } - sess.deletedItems.AddNum(uint32(msgId)) return nil } @@ -406,11 +393,6 @@ func (endp *Endpoint) Rset(user pop3backend.User) error { return err } - err = sess.Mailbox.UpdateMessagesFlags(true, sess.deletedItems, imap.RemoveFlags, false, []string{imap.DeletedFlag}) - if err != nil { - return fmt.Errorf("pop3: internal server error") - } - sess.deletedItems = &imap.SeqSet{} return nil } @@ -424,21 +406,12 @@ func (endp *Endpoint) Uidl(user pop3backend.User) (uids []string, err error) { return nil, err } - msgChan := make(chan *imap.Message) - errChan := make(chan error, 1) - - go func() { - errChan <- sess.Mailbox.ListMessages(false, nil, nil, msgChan) - }() - items := make([]string, 0) - for msg := range msgChan { - items = append(items, strconv.FormatUint(uint64(msg.Uid), 10)) - } - - err = <-errChan - if err != nil && err != mess.ErrNoMessages { - return nil, err + for msgId, msg := range sess.info { + // Skip deleted messages + if !sess.deletedItems.Contains(msgId) { + items = append(items, strconv.FormatUint(uint64(msg.Uid), 10)) + } } return items, nil @@ -451,25 +424,12 @@ func (endp *Endpoint) UidlMessage(user pop3backend.User, msgId int) (exists bool return false, "", err } - msgChan := make(chan *imap.Message, 1) - errChan := make(chan error, 1) - - seqset := imap.SeqSet{} - seqset.AddNum(uint32(msgId)) - - go func() { - errChan <- sess.Mailbox.ListMessages(true, &seqset, nil, msgChan) - }() - - var msg *imap.Message - msg = <-msgChan - - err = <-errChan - if err != nil && err != mess.ErrNoMessages { - return false, "", err + if sess.deletedItems.Contains(uint32(msgId)) { + return false, "", nil } - if msg == nil { + msg, ok := sess.info[uint32(msgId)] + if !ok { return false, "", nil } @@ -483,6 +443,16 @@ func (endp *Endpoint) Update(user pop3backend.User) error { return err } + // Mark all deleted messages with the deleted flag before expunging + if !sess.deletedItems.Empty() { + err = sess.Mailbox.UpdateMessagesFlags(false, sess.deletedItems, imap.SetFlags, false, []string{imap.DeletedFlag}) + if err != nil { + return err + } + + sess.deletedItems = &imap.SeqSet{} + } + return sess.Mailbox.Expunge() } @@ -497,7 +467,74 @@ func (endp *Endpoint) Update(user pop3backend.User) error { // client is greater than than the number of lines in the // body, then the POP3 server sends the entire message. func (endp *Endpoint) Top(user pop3backend.User, msgId int, n int) (lines []string, err error) { - return nil, fmt.Errorf("pop3: unimplemented") + sess, err := endp.getSession(user) + if err != nil { + return nil, err + } + + if sess.deletedItems.Contains(uint32(msgId)) { + return nil, fmt.Errorf("not found") + } + + msgChan := make(chan *imap.Message) + errChan := make(chan error, 1) + + seqset := imap.SeqSet{} + seqset.AddNum(uint32(msgId)) + go func() { + errChan <- sess.Mailbox.ListMessages(false, &seqset, []imap.FetchItem{imap.FetchRFC822}, msgChan) + }() + + var msg *imap.Message + msg = <-msgChan + + err = <-errChan + if err != nil && err != mess.ErrNoMessages { + return nil, err + } + + if msg == nil { + return nil, fmt.Errorf("not found") + } + + body := msg.GetBody(&imap.BodySectionName{}) + if body == nil { + return nil, fmt.Errorf("unable to read message body") + } + + buf := new(bytes.Buffer) + _, err = io.Copy(buf, body) + if err != nil { + return nil, err + } + + msgStr := buf.String() + msgLines := strings.Split(msgStr, "\r\n") + + // Find blank line separating headers from body + blankLineIdx := -1 + for i, line := range msgLines { + if line == "" { + blankLineIdx = i + break + } + } + + if blankLineIdx == -1 { + // No body, return headers only + return msgLines, nil + } + + // Return headers + N lines of body + headerLines := msgLines[:blankLineIdx+1] + bodyStart := blankLineIdx + 1 + bodyEnd := bodyStart + n + + if bodyEnd > len(msgLines) { + bodyEnd = len(msgLines) + } + + return append(headerLines, msgLines[bodyStart:bodyEnd]...), nil } // Lock is called immediately after client is connected. The best way what to use Lock() for @@ -520,6 +557,32 @@ func (endp *Endpoint) Lock(user pop3backend.User) error { endp.activeUsersMap[username] = true + sess, err := endp.getSession(backendUser) + if err != nil { + return fmt.Errorf("pop3: internal server error") + } + + // cache messages + msgChan := make(chan *imap.Message) + errChan := make(chan error, 1) + + seqset := imap.SeqSet{} + seqset.AddRange(1, math.MaxUint32) + go func() { + errChan <- sess.Mailbox.ListMessages(false, &seqset, []imap.FetchItem{imap.FetchRFC822Size, imap.FetchUid}, msgChan) + }() + + var i uint32 = 1 + for msg := range msgChan { + sess.info[i] = messageInfo{msg.Size, msg.Uid} + i++ + } + + err = <-errChan + if err != nil && err != mess.ErrNoMessages { + return fmt.Errorf("pop3: internal server error") + } + return nil } @@ -550,7 +613,7 @@ func (endp *Endpoint) Unlock(user pop3backend.User) error { return err } - endp.activeUsersMap[username] = false + delete(endp.activeUsersMap, username) return nil }