refactor smtp
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
29
bot/email.go
29
bot/email.go
@@ -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) {
|
||||
|
||||
12
cmd/cmd.go
12
cmd/cmd.go
@@ -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
139
smtp/manager.go
Normal 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
|
||||
}
|
||||
48
smtp/msa.go
48
smtp/msa.go
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
60
smtp/mta.go
60
smtp/mta.go
@@ -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
|
||||
}
|
||||
174
smtp/server.go
174
smtp/server.go
@@ -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
169
smtp/session.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user