refactor smtp
This commit is contained in:
@@ -12,8 +12,6 @@ import (
|
|||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/format"
|
"maunium.net/go/mautrix/format"
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
"gitlab.com/etke.cc/postmoogle/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bot represents matrix bot
|
// Bot represents matrix bot
|
||||||
@@ -24,7 +22,7 @@ type Bot struct {
|
|||||||
allowedAdmins []*regexp.Regexp
|
allowedAdmins []*regexp.Regexp
|
||||||
commands commandList
|
commands commandList
|
||||||
rooms sync.Map
|
rooms sync.Map
|
||||||
mta utils.MTA
|
sendmail func(string, string, string) error
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
lp *linkpearl.Linkpearl
|
lp *linkpearl.Linkpearl
|
||||||
mu map[id.RoomID]*sync.Mutex
|
mu map[id.RoomID]*sync.Mutex
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ func (b *Bot) runSend(ctx context.Context) {
|
|||||||
data := utils.
|
data := utils.
|
||||||
NewEmail(ID, "", subject, from, to, body, "", nil).
|
NewEmail(ID, "", subject, from, to, body, "", nil).
|
||||||
Compose(b.getBotSettings().DKIMPrivateKey())
|
Compose(b.getBotSettings().DKIMPrivateKey())
|
||||||
err = b.mta.Send(from, to, data)
|
err = b.sendmail(from, to, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err)
|
b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
29
bot/email.go
29
bot/email.go
@@ -26,9 +26,14 @@ const (
|
|||||||
eventFromKey = "cc.etke.postmoogle.from"
|
eventFromKey = "cc.etke.postmoogle.from"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetMTA sets mail transfer agent instance to the bot
|
// SetSendmail sets mail sending func to the bot
|
||||||
func (b *Bot) SetMTA(mta utils.MTA) {
|
func (b *Bot) SetSendmail(sendmail func(string, string, string) error) {
|
||||||
b.mta = mta
|
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) {
|
func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
|
||||||
@@ -70,9 +75,9 @@ func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email to matrix room
|
// IncomingEmail sends incoming email to matrix room
|
||||||
func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) error {
|
func (b *Bot) IncomingEmail(ctx context.Context, email *utils.Email) error {
|
||||||
roomID, ok := b.GetMapping(email.Mailbox(incoming))
|
roomID, ok := b.GetMapping(email.Mailbox(true))
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("room not found")
|
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)
|
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
|
var threadID id.EventID
|
||||||
if email.InReplyTo != "" && !cfg.NoThreads() {
|
if email.InReplyTo != "" && !cfg.NoThreads() {
|
||||||
threadID = b.getThreadID(roomID, email.InReplyTo)
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +153,7 @@ func (b *Bot) getParentEmail(evt *event.Event) (string, string, string) {
|
|||||||
|
|
||||||
// Send2Email sends message to email
|
// Send2Email sends message to email
|
||||||
// TODO rewrite to thread replies only
|
// 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
|
var inReplyTo string
|
||||||
evt := eventFromContext(ctx)
|
evt := eventFromContext(ctx)
|
||||||
cfg, err := b.getRoomSettings(evt.RoomID)
|
cfg, err := b.getRoomSettings(evt.RoomID)
|
||||||
@@ -193,7 +190,7 @@ func (b *Bot) Send2Email(ctx context.Context, to, subject, body string) error {
|
|||||||
data := utils.
|
data := utils.
|
||||||
NewEmail(ID, inReplyTo, subject, from, to, body, "", nil).
|
NewEmail(ID, inReplyTo, subject, from, to, body, "", nil).
|
||||||
Compose(b.getBotSettings().DKIMPrivateKey())
|
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) {
|
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 (
|
var (
|
||||||
mxb *bot.Bot
|
mxb *bot.Bot
|
||||||
smtpserv *smtp.Server
|
smtpm *smtp.Manager
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -45,7 +45,7 @@ func main() {
|
|||||||
|
|
||||||
go startBot(cfg.StatusMsg)
|
go startBot(cfg.StatusMsg)
|
||||||
|
|
||||||
if err := smtpserv.Start(); err != nil {
|
if err := smtpm.Start(); err != nil {
|
||||||
//nolint:gocritic
|
//nolint:gocritic
|
||||||
log.Fatal("SMTP server crashed: %v", err)
|
log.Fatal("SMTP server crashed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ func initBot(cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initSMTP(cfg *config.Config) {
|
func initSMTP(cfg *config.Config) {
|
||||||
smtpserv = smtp.NewServer(&smtp.Config{
|
smtpm = smtp.NewManager(&smtp.Config{
|
||||||
Domains: cfg.Domains,
|
Domains: cfg.Domains,
|
||||||
Port: cfg.Port,
|
Port: cfg.Port,
|
||||||
TLSCert: cfg.TLS.Cert,
|
TLSCert: cfg.TLS.Cert,
|
||||||
@@ -132,7 +132,7 @@ func startBot(statusMsg string) {
|
|||||||
|
|
||||||
func shutdown() {
|
func shutdown() {
|
||||||
log.Info("Shutting down...")
|
log.Info("Shutting down...")
|
||||||
smtpserv.Stop()
|
smtpm.Stop()
|
||||||
mxb.Stop()
|
mxb.Stop()
|
||||||
|
|
||||||
sentry.Flush(5 * time.Second)
|
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
|
package smtp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"context"
|
||||||
"net"
|
"errors"
|
||||||
"os"
|
"io"
|
||||||
"time"
|
"strings"
|
||||||
|
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
"gitlab.com/etke.cc/go/logger"
|
"gitlab.com/etke.cc/go/logger"
|
||||||
|
"gitlab.com/etke.cc/go/trysmtp"
|
||||||
|
|
||||||
|
"gitlab.com/etke.cc/postmoogle/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type mailServer struct {
|
||||||
Domains []string
|
bot matrixbot
|
||||||
Port string
|
log *logger.Logger
|
||||||
|
domains []string
|
||||||
TLSCert string
|
|
||||||
TLSKey string
|
|
||||||
TLSPort string
|
|
||||||
TLSRequired bool
|
|
||||||
|
|
||||||
LogLevel string
|
|
||||||
MaxSize int
|
|
||||||
Bot Bot
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
// Login used for outgoing mail submissions only (when you use postmoogle as smtp server in your scripts)
|
||||||
log *logger.Logger
|
func (m *mailServer) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||||
msa *smtp.Server
|
if !utils.AddressValid(username) {
|
||||||
errs chan error
|
return nil, errors.New("please, provide an email address")
|
||||||
|
}
|
||||||
|
|
||||||
port string
|
if !m.bot.AllowAuth(username, password) {
|
||||||
tlsPort string
|
return nil, errors.New("email or password is invalid")
|
||||||
tlsCfg *tls.Config
|
}
|
||||||
|
|
||||||
|
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
|
// AnonymousLogin used for incoming mail submissions only
|
||||||
func NewServer(cfg *Config) *Server {
|
func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||||
log := logger.New("smtp/msa.", cfg.LogLevel)
|
return &incomingSession{
|
||||||
sender := NewMTA(cfg.LogLevel)
|
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
|
||||||
receiver := &msa{
|
getRoomID: m.bot.GetMapping,
|
||||||
log: log,
|
getFilters: m.bot.GetIFOptions,
|
||||||
mta: sender,
|
receiveEmail: m.ReceiveEmail,
|
||||||
bot: cfg.Bot,
|
log: m.log,
|
||||||
domains: cfg.Domains,
|
domains: m.domains,
|
||||||
}
|
}, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start SMTP server
|
// SendEmail to external mail server
|
||||||
func (s *Server) Start() error {
|
func (m *mailServer) SendEmail(from, to, data string) error {
|
||||||
s.errs = make(chan error, 1)
|
m.log.Debug("Sending email from %s to %s", from, to)
|
||||||
go s.listen(s.port, nil)
|
conn, err := trysmtp.Connect(from, to)
|
||||||
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()
|
|
||||||
if err != nil {
|
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) {
|
// ReceiveEmail - incoming mail into matrix room
|
||||||
var l net.Listener
|
func (m *mailServer) ReceiveEmail(ctx context.Context, email *utils.Email) error {
|
||||||
var err error
|
return m.bot.IncomingEmail(ctx, email)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MTA is mail transfer agent
|
|
||||||
type MTA interface {
|
|
||||||
Send(from, to, data string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncomingFilteringOptions for incoming mail
|
// IncomingFilteringOptions for incoming mail
|
||||||
type IncomingFilteringOptions interface {
|
type IncomingFilteringOptions interface {
|
||||||
SpamcheckSMTP() bool
|
SpamcheckSMTP() bool
|
||||||
|
|||||||
Reference in New Issue
Block a user