466 lines
12 KiB
Go
466 lines
12 KiB
Go
package pop3_server
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"mail_go/config"
|
|
"mail_go/internal/db"
|
|
"mail_go/internal/store"
|
|
)
|
|
|
|
// POP3Server implements a simple POP3 mail server over TCP.
|
|
type POP3Server struct {
|
|
listener net.Listener
|
|
stores *store.Stores
|
|
cfg config.POP3Config
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewPOP3Server creates a new POP3 server instance.
|
|
func NewPOP3Server(cfg config.POP3Config, stores *store.Stores) *POP3Server {
|
|
return &POP3Server{
|
|
stores: stores,
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
// Start starts the POP3 server on the configured plain-text port.
|
|
func (s *POP3Server) Start() error {
|
|
var err error
|
|
s.listener, err = net.Listen("tcp", s.cfg.Addr)
|
|
if err != nil {
|
|
return fmt.Errorf("POP3 listen failed: %w", err)
|
|
}
|
|
|
|
log.Printf("POP3 server listening on %s", s.cfg.Addr)
|
|
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
for {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
// Listener closed
|
|
return
|
|
}
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.handleConn(conn)
|
|
}()
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// StartTLS starts the POP3 server on the configured TLS port.
|
|
func (s *POP3Server) StartTLS() error {
|
|
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
|
|
return fmt.Errorf("POP3 TLS certificate or key not configured")
|
|
}
|
|
return fmt.Errorf("POP3 TLS not yet implemented")
|
|
}
|
|
|
|
// handleConn handles a single POP3 client connection.
|
|
func (s *POP3Server) handleConn(conn net.Conn) {
|
|
defer conn.Close()
|
|
|
|
// Set read/write deadlines
|
|
conn.SetDeadline(time.Now().Add(10 * time.Minute))
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
// Session state
|
|
var user *db.User
|
|
var messages []pop3Message
|
|
var deleted map[int]bool
|
|
|
|
// Send greeting
|
|
sendResponse(conn, "+OK MailGo POP3 server ready")
|
|
|
|
// Main command loop
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return // connection closed or error
|
|
}
|
|
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse command
|
|
parts := strings.SplitN(line, " ", 2)
|
|
cmd := strings.ToUpper(parts[0])
|
|
arg := ""
|
|
if len(parts) > 1 {
|
|
arg = strings.TrimSpace(parts[1])
|
|
}
|
|
|
|
switch cmd {
|
|
case "USER":
|
|
user, messages, deleted = s.handleUSER(conn, arg, user)
|
|
case "PASS":
|
|
user, messages, deleted = s.handlePASS(conn, arg, user)
|
|
case "STAT":
|
|
s.handleSTAT(conn, messages, deleted)
|
|
case "LIST":
|
|
s.handleLIST(conn, arg, messages, deleted)
|
|
case "RETR":
|
|
s.handleRETR(conn, arg, messages, deleted)
|
|
case "DELE":
|
|
s.handleDELE(conn, arg, deleted)
|
|
case "NOOP":
|
|
sendResponse(conn, "+OK")
|
|
case "RSET":
|
|
deleted = make(map[int]bool)
|
|
sendResponse(conn, "+OK")
|
|
case "QUIT":
|
|
// In UPDATE state, actually delete marked messages
|
|
s.expungeDeleted(messages, deleted, user)
|
|
sendResponse(conn, "+OK MailGo POP3 server signing off")
|
|
return
|
|
case "CAPA":
|
|
sendResponse(conn, "+OK Capability list follows")
|
|
sendResponse(conn, "USER")
|
|
sendResponse(conn, ".")
|
|
case "TOP":
|
|
s.handleTOP(conn, arg, messages, deleted)
|
|
case "UIDL":
|
|
s.handleUIDL(conn, arg, messages, deleted)
|
|
default:
|
|
sendResponse(conn, "-ERR unknown command")
|
|
}
|
|
}
|
|
}
|
|
|
|
// pop3Message holds a message and its computed size for POP3.
|
|
type pop3Message struct {
|
|
id uint
|
|
raw string
|
|
size int
|
|
message *db.Message
|
|
}
|
|
|
|
// loadMessages loads all INBOX messages for a user.
|
|
func (s *POP3Server) loadMessages(user *db.User) []pop3Message {
|
|
if user == nil {
|
|
return nil
|
|
}
|
|
|
|
dbMsgs, err := s.stores.Mails.ListAllByUserAndFolder(user.ID, "INBOX")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
msgs := make([]pop3Message, 0, len(dbMsgs))
|
|
for i := range dbMsgs {
|
|
raw := string(buildRawMessage(&dbMsgs[i]))
|
|
msgs = append(msgs, pop3Message{
|
|
id: dbMsgs[i].ID,
|
|
raw: raw,
|
|
size: len(raw),
|
|
message: &dbMsgs[i],
|
|
})
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
// handleUSER processes the USER command.
|
|
func (s *POP3Server) handleUSER(conn net.Conn, username string, currentUser *db.User) (*db.User, []pop3Message, map[int]bool) {
|
|
if username == "" {
|
|
sendResponse(conn, "-ERR missing username")
|
|
return currentUser, nil, nil
|
|
}
|
|
|
|
// Look up the user by email
|
|
user, err := s.stores.Users.GetByEmail(username)
|
|
if err != nil {
|
|
// Don't reveal whether user exists yet (standard POP3 behavior)
|
|
sendResponse(conn, "+OK")
|
|
// Store the attempted username for PASS to use
|
|
return &db.User{Username: username}, nil, nil
|
|
}
|
|
|
|
sendResponse(conn, "+OK")
|
|
return user, nil, nil
|
|
}
|
|
|
|
// handlePASS processes the PASS command.
|
|
func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (*db.User, []pop3Message, map[int]bool) {
|
|
if user == nil {
|
|
sendResponse(conn, "-ERR no username given")
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// Authenticate
|
|
authUser, err := s.stores.Users.Authenticate(user.Username, password)
|
|
if err != nil {
|
|
// If username was an email, try using it directly
|
|
if strings.Contains(user.Username, "@") {
|
|
authUser, err = s.stores.Users.Authenticate(user.Username, password)
|
|
}
|
|
if err != nil {
|
|
sendResponse(conn, "-ERR authentication failed")
|
|
return nil, nil, nil
|
|
}
|
|
}
|
|
|
|
// Load messages
|
|
messages := s.loadMessages(authUser)
|
|
deleted := make(map[int]bool)
|
|
|
|
sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages)))
|
|
return authUser, messages, deleted
|
|
}
|
|
|
|
// handleSTAT processes the STAT command.
|
|
func (s *POP3Server) handleSTAT(conn net.Conn, messages []pop3Message, deleted map[int]bool) {
|
|
count := 0
|
|
totalSize := 0
|
|
for i, msg := range messages {
|
|
if !deleted[i+1] {
|
|
count++
|
|
totalSize += msg.size
|
|
}
|
|
}
|
|
sendResponse(conn, fmt.Sprintf("+OK %d %d", count, totalSize))
|
|
}
|
|
|
|
// handleLIST processes the LIST command (with optional message number).
|
|
func (s *POP3Server) handleLIST(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
|
if arg == "" {
|
|
// List all messages
|
|
sendResponse(conn, "+OK message list follows")
|
|
for i, msg := range messages {
|
|
if !deleted[i+1] {
|
|
sendResponse(conn, fmt.Sprintf("%d %d", i+1, msg.size))
|
|
}
|
|
}
|
|
sendResponse(conn, ".")
|
|
} else {
|
|
// List specific message
|
|
num, err := strconv.Atoi(arg)
|
|
if err != nil || num < 1 || num > len(messages) {
|
|
sendResponse(conn, "-ERR no such message")
|
|
return
|
|
}
|
|
if deleted[num] {
|
|
sendResponse(conn, "-ERR message deleted")
|
|
return
|
|
}
|
|
sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].size))
|
|
}
|
|
}
|
|
|
|
// handleRETR processes the RETR command.
|
|
func (s *POP3Server) handleRETR(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
|
num, err := strconv.Atoi(arg)
|
|
if err != nil || num < 1 || num > len(messages) {
|
|
sendResponse(conn, "-ERR no such message")
|
|
return
|
|
}
|
|
if deleted[num] {
|
|
sendResponse(conn, "-ERR message deleted")
|
|
return
|
|
}
|
|
|
|
msg := messages[num-1]
|
|
sendResponse(conn, fmt.Sprintf("+OK %d octets", msg.size))
|
|
|
|
// Send the raw message, dot-stuffing any lines that start with "."
|
|
scanner := bufio.NewScanner(strings.NewReader(msg.raw))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, ".") {
|
|
conn.Write([]byte("."))
|
|
}
|
|
conn.Write([]byte(line + "\r\n"))
|
|
}
|
|
conn.Write([]byte(".\r\n"))
|
|
}
|
|
|
|
// handleDELE processes the DELE command.
|
|
func (s *POP3Server) handleDELE(conn net.Conn, arg string, deleted map[int]bool) {
|
|
num, err := strconv.Atoi(arg)
|
|
if err != nil || num < 1 {
|
|
sendResponse(conn, "-ERR invalid message number")
|
|
return
|
|
}
|
|
if deleted == nil {
|
|
deleted = make(map[int]bool)
|
|
}
|
|
if deleted[num] {
|
|
sendResponse(conn, "-ERR message already deleted")
|
|
return
|
|
}
|
|
deleted[num] = true
|
|
sendResponse(conn, "+OK message marked for deletion")
|
|
}
|
|
|
|
// handleTOP processes the TOP command (headers + first N lines of body).
|
|
func (s *POP3Server) handleTOP(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
|
// Format: TOP msgNum [n]
|
|
parts := strings.Fields(arg)
|
|
if len(parts) < 1 {
|
|
sendResponse(conn, "-ERR syntax error")
|
|
return
|
|
}
|
|
|
|
num, err := strconv.Atoi(parts[0])
|
|
if err != nil || num < 1 || num > len(messages) {
|
|
sendResponse(conn, "-ERR no such message")
|
|
return
|
|
}
|
|
if deleted != nil && deleted[num] {
|
|
sendResponse(conn, "-ERR message deleted")
|
|
return
|
|
}
|
|
|
|
nLines := 0
|
|
if len(parts) > 1 {
|
|
nLines, _ = strconv.Atoi(parts[1])
|
|
}
|
|
|
|
msg := messages[num-1]
|
|
sendResponse(conn, "+OK top of message follows")
|
|
|
|
// Find blank line separating headers from body
|
|
headerEnd := strings.Index(msg.raw, "\r\n\r\n")
|
|
if headerEnd == -1 {
|
|
headerEnd = strings.Index(msg.raw, "\n\n")
|
|
if headerEnd == -1 {
|
|
// No body, just send everything
|
|
sendResponse(conn, msg.raw)
|
|
sendResponse(conn, ".")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Send headers
|
|
header := msg.raw[:headerEnd]
|
|
scanner := bufio.NewScanner(strings.NewReader(header))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, ".") {
|
|
conn.Write([]byte("."))
|
|
}
|
|
conn.Write([]byte(line + "\r\n"))
|
|
}
|
|
conn.Write([]byte("\r\n"))
|
|
|
|
// Send up to N lines of body
|
|
if nLines > 0 {
|
|
body := msg.raw[headerEnd:]
|
|
scanner := bufio.NewScanner(strings.NewReader(body))
|
|
lineCount := 0
|
|
for scanner.Scan() && lineCount < nLines {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, ".") {
|
|
conn.Write([]byte("."))
|
|
}
|
|
conn.Write([]byte(line + "\r\n"))
|
|
lineCount++
|
|
}
|
|
}
|
|
|
|
conn.Write([]byte(".\r\n"))
|
|
}
|
|
|
|
// handleUIDL processes the UIDL command.
|
|
func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) {
|
|
if arg == "" {
|
|
// List all UIDs
|
|
sendResponse(conn, "+OK UIDL list follows")
|
|
for i, msg := range messages {
|
|
if deleted == nil || !deleted[i+1] {
|
|
sendResponse(conn, fmt.Sprintf("%d %d", i+1, msg.id))
|
|
}
|
|
}
|
|
sendResponse(conn, ".")
|
|
} else {
|
|
// Specific message UID
|
|
num, err := strconv.Atoi(arg)
|
|
if err != nil || num < 1 || num > len(messages) {
|
|
sendResponse(conn, "-ERR no such message")
|
|
return
|
|
}
|
|
if deleted != nil && deleted[num] {
|
|
sendResponse(conn, "-ERR message deleted")
|
|
return
|
|
}
|
|
sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].id))
|
|
}
|
|
}
|
|
|
|
// expungeDeleted actually deletes messages that were marked for deletion.
|
|
func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) {
|
|
if deleted == nil || user == nil {
|
|
return
|
|
}
|
|
for seqNum, msgDeleted := range deleted {
|
|
if msgDeleted && seqNum >= 1 && seqNum <= len(messages) {
|
|
if err := s.stores.Mails.Delete(messages[seqNum-1].id); err != nil {
|
|
log.Printf("POP3: failed to delete message %d: %v", messages[seqNum-1].id, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendResponse writes a POP3 response line to the connection.
|
|
func sendResponse(conn net.Conn, line string) {
|
|
conn.Write([]byte(line + "\r\n"))
|
|
}
|
|
|
|
// buildRawMessage reconstructs a raw RFC822 message from a db.Message.
|
|
// This is a local copy to avoid importing from the imap_server package.
|
|
func buildRawMessage(msg *db.Message) []byte {
|
|
var buf strings.Builder
|
|
|
|
buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr))
|
|
buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr))
|
|
if msg.CcAddr != "" {
|
|
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", msg.CcAddr))
|
|
}
|
|
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
|
|
buf.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700")))
|
|
if msg.MessageID != "" {
|
|
buf.WriteString(fmt.Sprintf("Message-ID: %s\r\n", msg.MessageID))
|
|
}
|
|
buf.WriteString("MIME-Version: 1.0\r\n")
|
|
|
|
if msg.HtmlBody != "" && msg.TextBody != "" {
|
|
boundary := fmt.Sprintf("mailgo_%d", msg.ID)
|
|
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
|
buf.WriteString("\r\n")
|
|
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
|
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
|
buf.WriteString(msg.TextBody)
|
|
buf.WriteString("\r\n")
|
|
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
|
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
|
buf.WriteString(msg.HtmlBody)
|
|
buf.WriteString("\r\n")
|
|
buf.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
|
} else if msg.TextBody != "" {
|
|
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
|
buf.WriteString(msg.TextBody)
|
|
} else if msg.HtmlBody != "" {
|
|
buf.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
|
buf.WriteString(msg.HtmlBody)
|
|
} else {
|
|
buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
|
}
|
|
|
|
return []byte(buf.String())
|
|
}
|