diff --git a/.gitignore b/.gitignore index a1cf83c..8bcb162 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /local.db /local.db-journal /cover.out +/e2e/main.go diff --git a/README.md b/README.md index ee00a53..df88abd 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ It can't be used with arbitrary email providers, but setup your own provider "wi ### Send - [x] SMTP client +- [x] SMTP server (you can use Postmoogle as general purpose SMTP server to send emails from your scripts or apps) - [x] Send a message to matrix room with special format to send a new email - [ ] Reply to matrix thread sends reply into email thread @@ -237,6 +238,7 @@ If you want to change them - check available options in the help message (`!pm h * **!pm mailbox** - Get or set mailbox of the room * **!pm owner** - Get or set owner of the room +* **!pm password** - Get or set SMTP password of the room's mailbox --- diff --git a/bot/access.go b/bot/access.go index 6bb987b..5121b64 100644 --- a/bot/access.go +++ b/bot/access.go @@ -65,3 +65,18 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool { return !cfg.NoSend() } + +// AllowAuth check if SMTP login (mailbox) and password are valid +func (b *Bot) AllowAuth(mailbox, password string) bool { + roomID, ok := b.GetMapping(mailbox) + if !ok { + return false + } + cfg, err := b.getRoomSettings(roomID) + if err != nil { + b.log.Error("failed to retrieve settings: %v", err) + return false + } + + return cfg.Password() != "" && cfg.Password() == password +} diff --git a/bot/command.go b/bot/command.go index c986bbe..26242e5 100644 --- a/bot/command.go +++ b/bot/command.go @@ -73,6 +73,12 @@ func (b *Bot) initCommands() commandList { sanitizer: func(s string) string { return s }, allowed: b.allowOwner, }, + { + key: roomOptionPassword, + description: "Get or set SMTP password of the room's mailbox", + sanitizer: func(s string) string { return strings.TrimSpace(s) }, + allowed: b.allowOwner, + }, {allowed: b.allowOwner}, // delimiter { key: roomOptionNoSend, @@ -293,6 +299,11 @@ func (b *Bot) runSend(ctx context.Context) { return } + if !utils.AddressValid(to) { + b.Error(ctx, evt.RoomID, "email address is not valid") + return + } + cfg, err := b.getRoomSettings(evt.RoomID) if err != nil { b.Error(ctx, evt.RoomID, "failed to retrieve room settings: %v", err) diff --git a/bot/email.go b/bot/email.go index 6b391af..a4d5877 100644 --- a/bot/email.go +++ b/bot/email.go @@ -46,8 +46,8 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) { } // Send email to matrix room -func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email) error { - roomID, ok := b.GetMapping(utils.Mailbox(email.To)) +func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, local bool) error { + roomID, ok := b.GetMapping(email.Mailbox(local)) if !ok { return errors.New("room not found") } @@ -59,6 +59,10 @@ func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email) error { b.Error(ctx, roomID, "cannot get settings: %v", err) } + if !local && 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) @@ -81,6 +85,11 @@ func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email) error { if !cfg.NoFiles() { b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID) } + + if !local { + email.MessageID = fmt.Sprintf("<%s@%s>", eventID, b.domain) + return b.mta.Send(email.From, email.To, email.Compose(b.getBotSettings().DKIMPrivateKey())) + } return nil } diff --git a/bot/settings_room.go b/bot/settings_room.go index f2a6b6c..fe471d4 100644 --- a/bot/settings_room.go +++ b/bot/settings_room.go @@ -21,6 +21,7 @@ const ( roomOptionNoHTML = "nohtml" roomOptionNoThreads = "nothreads" roomOptionNoFiles = "nofiles" + roomOptionPassword = "password" ) type roomSettings map[string]string @@ -43,6 +44,10 @@ func (s roomSettings) Owner() string { return s.Get(roomOptionOwner) } +func (s roomSettings) Password() string { + return s.Get(roomOptionPassword) +} + func (s roomSettings) NoSend() bool { return utils.Bool(s.Get(roomOptionNoSend)) } diff --git a/smtp/msa.go b/smtp/msa.go index dd18f92..a683ce3 100644 --- a/smtp/msa.go +++ b/smtp/msa.go @@ -2,10 +2,13 @@ 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 @@ -13,11 +16,15 @@ type msa struct { log *logger.Logger domain string bot Bot + mta utils.MTA } -func (m *msa) newSession() *msasession { +func (m *msa) newSession(from string, local bool) *msasession { return &msasession{ ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()), + mta: m.mta, + from: from, + local: local, log: m.log, bot: m.bot, domain: m.domain, @@ -25,9 +32,18 @@ func (m *msa) newSession() *msasession { } func (m *msa) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { - return nil, smtp.ErrAuthUnsupported + if !utils.AddressValid(username) { + return nil, errors.New("please, provide email address") + } + + mailbox := utils.Mailbox(username) + if !m.bot.AllowAuth(mailbox, 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(), nil + return m.newSession("", true), nil } diff --git a/smtp/msasession.go b/smtp/msasession.go index 746568a..aac0b24 100644 --- a/smtp/msasession.go +++ b/smtp/msasession.go @@ -2,6 +2,7 @@ package smtp import ( "context" + "errors" "io" "github.com/emersion/go-smtp" @@ -15,32 +16,41 @@ import ( type msasession struct { log *logger.Logger bot Bot + mta utils.MTA domain string - ctx context.Context - to string - from string + ctx context.Context + local bool + to string + from string } func (s *msasession) Mail(from string, opts smtp.MailOptions) error { sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from) - s.from = from - s.log.Debug("mail from %s, options: %+v", from, opts) + if !utils.AddressValid(from) { + return errors.New("please, provide email address") + } + if s.local { + 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) - if utils.Hostname(to) != s.domain { - s.log.Debug("wrong domain of %s", to) - return smtp.ErrAuthRequired - } + if s.local { + if utils.Hostname(to) != s.domain { + s.log.Debug("wrong domain of %s", to) + return smtp.ErrAuthRequired + } - _, ok := s.bot.GetMapping(utils.Mailbox(to)) - if !ok { - s.log.Debug("mapping for %s not found", to) - return smtp.ErrAuthRequired + _, ok := s.bot.GetMapping(utils.Mailbox(to)) + if !ok { + s.log.Debug("mapping for %s not found", to) + return smtp.ErrAuthRequired + } } s.to = to @@ -80,7 +90,7 @@ func (s *msasession) Data(r io.Reader) error { eml.HTML, files) - return s.bot.Send2Matrix(s.ctx, email) + return s.bot.Send2Matrix(s.ctx, email, s.local) } func (s *msasession) Reset() {} diff --git a/smtp/mta.go b/smtp/mta.go index 0ad6fac..b63a9b7 100644 --- a/smtp/mta.go +++ b/smtp/mta.go @@ -17,8 +17,9 @@ import ( // Bot interface to send emails into matrix type Bot interface { + AllowAuth(string, string) bool GetMapping(string) (id.RoomID, bool) - Send2Matrix(ctx context.Context, email *utils.Email) error + Send2Matrix(ctx context.Context, email *utils.Email, local bool) error SetMTA(mta utils.MTA) } diff --git a/smtp/server.go b/smtp/server.go index 864dde5..bbb8024 100644 --- a/smtp/server.go +++ b/smtp/server.go @@ -40,6 +40,7 @@ func NewServer(cfg *Config) *Server { sender := NewMTA(cfg.LogLevel) receiver := &msa{ log: log, + mta: sender, bot: cfg.Bot, domain: cfg.Domain, } @@ -51,6 +52,7 @@ func NewServer(cfg *Config) *Server { s.WriteTimeout = 10 * time.Second s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024 s.EnableREQUIRETLS = cfg.TLSRequired + s.AllowInsecureAuth = !cfg.TLSRequired if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" { s.Debug = os.Stdout } diff --git a/utils/email.go b/utils/email.go index 8d5a33e..3efcb49 100644 --- a/utils/email.go +++ b/utils/email.go @@ -4,6 +4,7 @@ import ( "crypto" "crypto/x509" "encoding/pem" + "regexp" "strings" "time" @@ -13,6 +14,8 @@ import ( "maunium.net/go/mautrix/id" ) +var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + // MTA is mail transfer agent type MTA interface { Send(from, to, data string) error @@ -46,6 +49,11 @@ type ContentOptions struct { FromKey string } +// AddressValid checks if email address is valid +func AddressValid(email string) bool { + return !emailRegex.MatchString(email) +} + // NewEmail constructs Email object func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files []*File) *Email { email := &Email{ @@ -71,6 +79,14 @@ func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files return email } +// Mailbox returns postmoogle's mailbox, parsing it from FROM (if local=false) or TO (local=true) +func (e *Email) Mailbox(local bool) string { + if local { + return Mailbox(e.To) + } + return Mailbox(e.From) +} + // Content converts the email object to a Matrix event content func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Content { var text strings.Builder