refactor smtp

This commit is contained in:
Aine
2022-11-10 13:26:12 +02:00
parent e6722dd5e8
commit 307aca7f23
11 changed files with 395 additions and 376 deletions

View File

@@ -12,8 +12,6 @@ import (
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// Bot represents matrix bot
@@ -24,7 +22,7 @@ type Bot struct {
allowedAdmins []*regexp.Regexp
commands commandList
rooms sync.Map
mta utils.MTA
sendmail func(string, string, string) error
log *logger.Logger
lp *linkpearl.Linkpearl
mu map[id.RoomID]*sync.Mutex

View File

@@ -365,7 +365,7 @@ func (b *Bot) runSend(ctx context.Context) {
data := utils.
NewEmail(ID, "", subject, from, to, body, "", nil).
Compose(b.getBotSettings().DKIMPrivateKey())
err = b.mta.Send(from, to, data)
err = b.sendmail(from, to, data)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err)
} else {

View File

@@ -26,9 +26,14 @@ const (
eventFromKey = "cc.etke.postmoogle.from"
)
// SetMTA sets mail transfer agent instance to the bot
func (b *Bot) SetMTA(mta utils.MTA) {
b.mta = mta
// SetSendmail sets mail sending func to the bot
func (b *Bot) SetSendmail(sendmail func(string, string, string) error) {
b.sendmail = sendmail
}
// GetDKIMprivkey returns DKIM private key
func (b *Bot) GetDKIMprivkey() string {
return b.getBotSettings().DKIMPrivateKey()
}
func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
@@ -70,9 +75,9 @@ func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions {
return cfg
}
// Send email to matrix room
func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) error {
roomID, ok := b.GetMapping(email.Mailbox(incoming))
// IncomingEmail sends incoming email to matrix room
func (b *Bot) IncomingEmail(ctx context.Context, email *utils.Email) error {
roomID, ok := b.GetMapping(email.Mailbox(true))
if !ok {
return errors.New("room not found")
}
@@ -84,10 +89,6 @@ func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, incoming bool
b.Error(ctx, roomID, "cannot get settings: %v", err)
}
if !incoming && cfg.NoSend() {
return errors.New("that mailbox is receive-only")
}
var threadID id.EventID
if email.InReplyTo != "" && !cfg.NoThreads() {
threadID = b.getThreadID(roomID, email.InReplyTo)
@@ -111,10 +112,6 @@ func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, incoming bool
b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID)
}
if !incoming {
email.MessageID = fmt.Sprintf("<%s@%s>", eventID, b.domains[0])
return b.mta.Send(email.From, email.To, email.Compose(b.getBotSettings().DKIMPrivateKey()))
}
return nil
}
@@ -156,7 +153,7 @@ func (b *Bot) getParentEmail(evt *event.Event) (string, string, string) {
// Send2Email sends message to email
// TODO rewrite to thread replies only
func (b *Bot) Send2Email(ctx context.Context, to, subject, body string) error {
func (b *Bot) SendEmailReply(ctx context.Context, to, subject, body string) error {
var inReplyTo string
evt := eventFromContext(ctx)
cfg, err := b.getRoomSettings(evt.RoomID)
@@ -193,7 +190,7 @@ func (b *Bot) Send2Email(ctx context.Context, to, subject, body string) error {
data := utils.
NewEmail(ID, inReplyTo, subject, from, to, body, "", nil).
Compose(b.getBotSettings().DKIMPrivateKey())
return b.mta.Send(from, to, data)
return b.sendmail(from, to, data)
}
func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) {

View File

@@ -20,9 +20,9 @@ import (
)
var (
mxb *bot.Bot
smtpserv *smtp.Server
log *logger.Logger
mxb *bot.Bot
smtpm *smtp.Manager
log *logger.Logger
)
func main() {
@@ -45,7 +45,7 @@ func main() {
go startBot(cfg.StatusMsg)
if err := smtpserv.Start(); err != nil {
if err := smtpm.Start(); err != nil {
//nolint:gocritic
log.Fatal("SMTP server crashed: %v", err)
}
@@ -96,7 +96,7 @@ func initBot(cfg *config.Config) {
}
func initSMTP(cfg *config.Config) {
smtpserv = smtp.NewServer(&smtp.Config{
smtpm = smtp.NewManager(&smtp.Config{
Domains: cfg.Domains,
Port: cfg.Port,
TLSCert: cfg.TLS.Cert,
@@ -132,7 +132,7 @@ func startBot(statusMsg string) {
func shutdown() {
log.Info("Shutting down...")
smtpserv.Stop()
smtpm.Stop()
mxb.Stop()
sentry.Flush(5 * time.Second)

139
smtp/manager.go Normal file
View File

@@ -0,0 +1,139 @@
package smtp
import (
"context"
"crypto/tls"
"net"
"os"
"time"
"github.com/emersion/go-smtp"
"gitlab.com/etke.cc/go/logger"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
type Config struct {
Domains []string
Port string
TLSCert string
TLSKey string
TLSPort string
TLSRequired bool
LogLevel string
MaxSize int
Bot matrixbot
}
type Manager struct {
log *logger.Logger
smtp *smtp.Server
errs chan error
port string
tlsPort string
tlsCfg *tls.Config
}
type matrixbot interface {
AllowAuth(string, string) bool
GetMapping(string) (id.RoomID, bool)
GetIFOptions(id.RoomID) utils.IncomingFilteringOptions
IncomingEmail(context.Context, *utils.Email) error
SetSendmail(func(string, string, string) error)
GetDKIMprivkey() string
}
// NewManager creates new SMTP server manager
func NewManager(cfg *Config) *Manager {
log := logger.New("smtp.", cfg.LogLevel)
mailsrv := &mailServer{
log: log,
bot: cfg.Bot,
domains: cfg.Domains,
}
cfg.Bot.SetSendmail(mailsrv.SendEmail)
s := smtp.NewServer(mailsrv)
s.Domain = cfg.Domains[0]
s.ReadTimeout = 10 * time.Second
s.WriteTimeout = 10 * time.Second
s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024
s.AllowInsecureAuth = !cfg.TLSRequired
s.EnableREQUIRETLS = cfg.TLSRequired
s.EnableSMTPUTF8 = true
if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
s.Debug = os.Stdout
}
m := &Manager{
smtp: s,
log: log,
port: cfg.Port,
tlsPort: cfg.TLSPort,
}
m.loadTLSConfig(cfg.TLSCert, cfg.TLSKey)
return m
}
// Start SMTP server
func (m *Manager) Start() error {
m.errs = make(chan error, 1)
go m.listen(m.port, nil)
if m.tlsCfg != nil {
go m.listen(m.tlsPort, m.tlsCfg)
}
return <-m.errs
}
// Stop SMTP server
func (m *Manager) Stop() {
err := m.smtp.Close()
if err != nil {
m.log.Error("cannot stop SMTP server properly: %v", err)
}
m.log.Info("SMTP server has been stopped")
}
func (m *Manager) listen(port string, tlsCfg *tls.Config) {
var l net.Listener
var err error
if tlsCfg != nil {
l, err = tls.Listen("tcp", ":"+port, tlsCfg)
} else {
l, err = net.Listen("tcp", ":"+port)
}
if err != nil {
m.log.Error("cannot start listener on %s: %v", port, err)
m.errs <- err
return
}
m.log.Info("Starting SMTP server on port %s", port)
err = m.smtp.Serve(l)
if err != nil {
m.log.Error("cannot start SMTP server on %s: %v", port, err)
m.errs <- err
close(m.errs)
}
}
func (m *Manager) loadTLSConfig(cert, key string) {
if cert == "" || key == "" {
m.log.Warn("SSL certificate is not provided")
return
}
tlsCert, err := tls.LoadX509KeyPair(cert, key)
if err != nil {
m.log.Error("cannot load SSL certificate: %v", err)
return
}
m.tlsCfg = &tls.Config{Certificates: []tls.Certificate{tlsCert}}
m.smtp.TLSConfig = m.tlsCfg
}

View File

@@ -1,48 +0,0 @@
package smtp
import (
"context"
"errors"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/postmoogle/utils"
)
// msa is mail submission agent, implements smtp.Backend
type msa struct {
log *logger.Logger
domains []string
bot Bot
mta utils.MTA
}
func (m *msa) newSession(from string, incoming bool) *msasession {
return &msasession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
mta: m.mta,
from: from,
incoming: incoming,
log: m.log,
bot: m.bot,
domains: m.domains,
}
}
func (m *msa) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
if !utils.AddressValid(username) {
return nil, errors.New("please, provide an email address")
}
if !m.bot.AllowAuth(username, password) {
return nil, errors.New("email or password is invalid")
}
return m.newSession(username, false), nil
}
func (m *msa) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
return m.newSession("", true), nil
}

View File

@@ -1,129 +0,0 @@
package smtp
import (
"context"
"errors"
"io"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"github.com/jhillyerd/enmime"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/validator"
"gitlab.com/etke.cc/postmoogle/utils"
)
// msasession represents an SMTP-submission session.
// This can be used in 2 directions:
// - receiving emails from remote servers, in which case: `incoming = true`
// - sending emails from local users, in which case: `incoming = false`
type msasession struct {
log *logger.Logger
bot Bot
mta utils.MTA
domains []string
ctx context.Context
incoming bool
to string
from string
}
func (s *msasession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
if !utils.AddressValid(from) {
return errors.New("please, provide email address")
}
if s.incoming {
s.from = from
s.log.Debug("mail from %s, options: %+v", from, opts)
}
return nil
}
func (s *msasession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.to = to
//nolint:nestif // TODO
if s.incoming {
var domainok bool
for _, domain := range s.domains {
if utils.Hostname(to) == domain {
domainok = true
break
}
}
if !domainok {
s.log.Debug("wrong domain of %s", to)
return smtp.ErrAuthRequired
}
roomID, ok := s.bot.GetMapping(utils.Mailbox(to))
if !ok {
s.log.Debug("mapping for %s not found", to)
return smtp.ErrAuthRequired
}
validations := s.bot.GetIFOptions(roomID)
if !s.validate(validations) {
return smtp.ErrAuthRequired
}
}
s.log.Debug("mail to %s", to)
return nil
}
func (s *msasession) parseAttachments(parts []*enmime.Part) []*utils.File {
files := make([]*utils.File, 0, len(parts))
for _, attachment := range parts {
for _, err := range attachment.Errors {
s.log.Warn("attachment error: %v", err)
}
file := utils.NewFile(attachment.FileName, attachment.Content)
files = append(files, file)
}
return files
}
func (s *msasession) validate(options utils.IncomingFilteringOptions) bool {
enforce := validator.Enforce{
Email: true,
MX: options.SpamcheckMX(),
SMTP: options.SpamcheckMX(),
}
v := validator.New(options.Spamlist(), enforce, s.to, s.log)
return v.Email(s.from)
}
func (s *msasession) Data(r io.Reader) error {
parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r)
if err != nil {
return err
}
files := s.parseAttachments(eml.Attachments)
email := utils.NewEmail(
eml.GetHeader("Message-Id"),
eml.GetHeader("In-Reply-To"),
eml.GetHeader("Subject"),
s.from,
s.to,
eml.Text,
eml.HTML,
files)
return s.bot.Send2Matrix(s.ctx, email, s.incoming)
}
func (s *msasession) Reset() {}
func (s *msasession) Logout() error {
return nil
}

View File

@@ -1,60 +0,0 @@
package smtp
import (
"context"
"io"
"strings"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/trysmtp"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// Bot interface to send emails into matrix
type Bot interface {
AllowAuth(string, string) bool
GetMapping(string) (id.RoomID, bool)
GetIFOptions(id.RoomID) utils.IncomingFilteringOptions
Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) error
SetMTA(mta utils.MTA)
}
// mta is Mail Transfer Agent
type mta struct {
log *logger.Logger
}
func NewMTA(loglevel string) utils.MTA {
return &mta{
log: logger.New("smtp/mta.", loglevel),
}
}
func (m *mta) Send(from, to, data string) error {
m.log.Debug("Sending email from %s to %s", from, to)
conn, err := trysmtp.Connect(from, to)
if err != nil {
m.log.Error("cannot connect to SMTP server of %s: %v", to, err)
return err
}
defer conn.Close()
var w io.WriteCloser
w, err = conn.Data()
if err != nil {
m.log.Error("cannot send DATA command: %v", err)
return err
}
defer w.Close()
m.log.Debug("sending DATA:\n%s", data)
_, err = strings.NewReader(data).WriteTo(w)
if err != nil {
m.log.Debug("cannot write DATA: %v", err)
return err
}
m.log.Debug("email has been sent")
return nil
}

View File

@@ -1,128 +1,86 @@
package smtp
import (
"crypto/tls"
"net"
"os"
"time"
"context"
"errors"
"io"
"strings"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/trysmtp"
"gitlab.com/etke.cc/postmoogle/utils"
)
type Config struct {
Domains []string
Port string
TLSCert string
TLSKey string
TLSPort string
TLSRequired bool
LogLevel string
MaxSize int
Bot Bot
type mailServer struct {
bot matrixbot
log *logger.Logger
domains []string
}
type Server struct {
log *logger.Logger
msa *smtp.Server
errs chan error
// Login used for outgoing mail submissions only (when you use postmoogle as smtp server in your scripts)
func (m *mailServer) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
if !utils.AddressValid(username) {
return nil, errors.New("please, provide an email address")
}
port string
tlsPort string
tlsCfg *tls.Config
if !m.bot.AllowAuth(username, password) {
return nil, errors.New("email or password is invalid")
}
return &outgoingSession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
sendmail: m.SendEmail,
privkey: m.bot.GetDKIMprivkey(),
from: username,
log: m.log,
domains: m.domains,
}, nil
}
// NewServer creates new SMTP server
func NewServer(cfg *Config) *Server {
log := logger.New("smtp/msa.", cfg.LogLevel)
sender := NewMTA(cfg.LogLevel)
receiver := &msa{
log: log,
mta: sender,
bot: cfg.Bot,
domains: cfg.Domains,
}
receiver.bot.SetMTA(sender)
s := smtp.NewServer(receiver)
s.Domain = cfg.Domains[0]
s.ReadTimeout = 10 * time.Second
s.WriteTimeout = 10 * time.Second
s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024
s.AllowInsecureAuth = !cfg.TLSRequired
s.EnableREQUIRETLS = cfg.TLSRequired
s.EnableSMTPUTF8 = true
if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
s.Debug = os.Stdout
}
server := &Server{
msa: s,
log: log,
port: cfg.Port,
tlsPort: cfg.TLSPort,
}
server.loadTLSConfig(cfg.TLSCert, cfg.TLSKey)
return server
// AnonymousLogin used for incoming mail submissions only
func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
return &incomingSession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
getRoomID: m.bot.GetMapping,
getFilters: m.bot.GetIFOptions,
receiveEmail: m.ReceiveEmail,
log: m.log,
domains: m.domains,
}, nil
}
// Start SMTP server
func (s *Server) Start() error {
s.errs = make(chan error, 1)
go s.listen(s.port, nil)
if s.tlsCfg != nil {
go s.listen(s.tlsPort, s.tlsCfg)
}
return <-s.errs
}
// Stop SMTP server
func (s *Server) Stop() {
err := s.msa.Close()
// SendEmail to external mail server
func (m *mailServer) SendEmail(from, to, data string) error {
m.log.Debug("Sending email from %s to %s", from, to)
conn, err := trysmtp.Connect(from, to)
if err != nil {
s.log.Error("cannot stop SMTP server properly: %v", err)
m.log.Error("cannot connect to SMTP server of %s: %v", to, err)
return err
}
s.log.Info("SMTP server has been stopped")
defer conn.Close()
var w io.WriteCloser
w, err = conn.Data()
if err != nil {
m.log.Error("cannot send DATA command: %v", err)
return err
}
defer w.Close()
m.log.Debug("sending DATA:\n%s", data)
_, err = strings.NewReader(data).WriteTo(w)
if err != nil {
m.log.Debug("cannot write DATA: %v", err)
return err
}
m.log.Debug("email has been sent")
return nil
}
func (s *Server) listen(port string, tlsCfg *tls.Config) {
var l net.Listener
var err error
if tlsCfg != nil {
l, err = tls.Listen("tcp", ":"+port, tlsCfg)
} else {
l, err = net.Listen("tcp", ":"+port)
}
if err != nil {
s.log.Error("cannot start listener on %s: %v", port, err)
s.errs <- err
return
}
s.log.Info("Starting SMTP server on port %s", port)
err = s.msa.Serve(l)
if err != nil {
s.log.Error("cannot start SMTP server on %s: %v", port, err)
s.errs <- err
close(s.errs)
}
}
func (s *Server) loadTLSConfig(cert, key string) {
if cert == "" || key == "" {
s.log.Warn("SSL certificate is not provided")
return
}
tlsCert, err := tls.LoadX509KeyPair(cert, key)
if err != nil {
s.log.Error("cannot load SSL certificate: %v", err)
return
}
s.tlsCfg = &tls.Config{Certificates: []tls.Certificate{tlsCert}}
s.msa.TLSConfig = s.tlsCfg
// ReceiveEmail - incoming mail into matrix room
func (m *mailServer) ReceiveEmail(ctx context.Context, email *utils.Email) error {
return m.bot.IncomingEmail(ctx, email)
}

169
smtp/session.go Normal file
View File

@@ -0,0 +1,169 @@
package smtp
import (
"context"
"errors"
"io"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"github.com/jhillyerd/enmime"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/validator"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// incomingSession represents an SMTP-submission session receiving emails from remote servers
type incomingSession struct {
log *logger.Logger
getRoomID func(string) (id.RoomID, bool)
getFilters func(id.RoomID) utils.IncomingFilteringOptions
receiveEmail func(context.Context, *utils.Email) error
domains []string
ctx context.Context
to string
from string
}
func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
if !utils.AddressValid(from) {
return errors.New("please, provide email address")
}
s.from = from
s.log.Debug("mail from %s, options: %+v", from, opts)
return nil
}
func (s *incomingSession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.to = to
var domainok bool
for _, domain := range s.domains {
if utils.Hostname(to) == domain {
domainok = true
break
}
}
if !domainok {
s.log.Debug("wrong domain of %s", to)
return smtp.ErrAuthRequired
}
roomID, ok := s.getRoomID(utils.Mailbox(to))
if !ok {
s.log.Debug("mapping for %s not found", to)
return smtp.ErrAuthRequired
}
validations := s.getFilters(roomID)
if !validateEmail(s.from, s.to, s.log, validations) {
return smtp.ErrAuthRequired
}
s.log.Debug("mail to %s", to)
return nil
}
func (s *incomingSession) Data(r io.Reader) error {
parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r)
if err != nil {
return err
}
files := parseAttachments(eml.Attachments, s.log)
email := utils.NewEmail(
eml.GetHeader("Message-Id"),
eml.GetHeader("In-Reply-To"),
eml.GetHeader("Subject"),
s.from,
s.to,
eml.Text,
eml.HTML,
files)
return s.receiveEmail(s.ctx, email)
}
func (s *incomingSession) Reset() {}
func (s *incomingSession) Logout() error { return nil }
// outgoingSession represents an SMTP-submission session sending emails from external scripts, using postmoogle as SMTP server
type outgoingSession struct {
log *logger.Logger
sendmail func(string, string, string) error
privkey string
domains []string
ctx context.Context
to string
from string
}
func (s *outgoingSession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
if !utils.AddressValid(from) {
return errors.New("please, provide email address")
}
return nil
}
func (s *outgoingSession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.to = to
s.log.Debug("mail to %s", to)
return nil
}
func (s *outgoingSession) Data(r io.Reader) error {
parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r)
if err != nil {
return err
}
files := parseAttachments(eml.Attachments, s.log)
email := utils.NewEmail(
eml.GetHeader("Message-Id"),
eml.GetHeader("In-Reply-To"),
eml.GetHeader("Subject"),
s.from,
s.to,
eml.Text,
eml.HTML,
files)
return s.sendmail(email.From, email.To, email.Compose(s.privkey))
}
func (s *outgoingSession) Reset() {}
func (s *outgoingSession) Logout() error { return nil }
func validateEmail(from, to string, log *logger.Logger, options utils.IncomingFilteringOptions) bool {
enforce := validator.Enforce{
Email: true,
MX: options.SpamcheckMX(),
SMTP: options.SpamcheckMX(),
}
v := validator.New(options.Spamlist(), enforce, to, log)
return v.Email(from)
}
func parseAttachments(parts []*enmime.Part, log *logger.Logger) []*utils.File {
files := make([]*utils.File, 0, len(parts))
for _, attachment := range parts {
for _, err := range attachment.Errors {
log.Warn("attachment error: %v", err)
}
file := utils.NewFile(attachment.FileName, attachment.Content)
files = append(files, file)
}
return files
}

View File

@@ -14,11 +14,6 @@ import (
"maunium.net/go/mautrix/id"
)
// MTA is mail transfer agent
type MTA interface {
Send(from, to, data string) error
}
// IncomingFilteringOptions for incoming mail
type IncomingFilteringOptions interface {
SpamcheckSMTP() bool