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..7ff12e3 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ An Email to Matrix bridge. 1 room = 1 mailbox. -Postmoogle is an actual SMTP server that allows you to receive emails on your matrix server. -It can't be used with arbitrary email providers, but setup your own provider "with matrix interface" instead. +Postmoogle is an actual SMTP server that allows you to send and receive emails on your matrix server. +It can't be used with arbitrary email providers, because it acts as an actual email provider itself, +so you can use it to send emails from your apps and scripts as well. ## Roadmap @@ -30,6 +31,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 +239,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..0e05c7f 100644 --- a/bot/access.go +++ b/bot/access.go @@ -3,9 +3,13 @@ package bot import ( "context" "regexp" + "strings" + "github.com/raja/argon2pw" "gitlab.com/etke.cc/go/mxidwc" "maunium.net/go/mautrix/id" + + "gitlab.com/etke.cc/postmoogle/utils" ) func parseMXIDpatterns(patterns []string, defaultPattern string) ([]*regexp.Regexp, error) { @@ -65,3 +69,26 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool { return !cfg.NoSend() } + +// AllowAuth check if SMTP login (email) and password are valid +func (b *Bot) AllowAuth(email, password string) bool { + if !strings.HasSuffix(email, "@"+b.domain) { + return false + } + + roomID, ok := b.GetMapping(utils.Mailbox(email)) + if !ok { + return false + } + cfg, err := b.getRoomSettings(roomID) + if err != nil { + b.log.Error("failed to retrieve settings: %v", err) + return false + } + + allow, err := argon2pw.CompareHashWithPassword(cfg.Password(), password) + if err != nil { + b.log.Warn("Password for %s is not valid: %v", email, err) + } + return allow +} diff --git a/bot/command.go b/bot/command.go index ca7fb83..61003df 100644 --- a/bot/command.go +++ b/bot/command.go @@ -73,6 +73,11 @@ 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", + allowed: b.allowOwner, + }, {allowed: b.allowOwner}, // delimiter { key: roomOptionNoSend, @@ -293,6 +298,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/command_owner.go b/bot/command_owner.go index 6d979a6..e282f0e 100644 --- a/bot/command_owner.go +++ b/bot/command_owner.go @@ -3,6 +3,8 @@ package bot import ( "context" "fmt" + + "github.com/raja/argon2pw" ) func (b *Bot) runStop(ctx context.Context) { @@ -62,12 +64,20 @@ func (b *Bot) getOption(ctx context.Context, name string) { msg := fmt.Sprintf("`%s` of this room is `%s`\n"+ "To set it to a new value, send a `%s %s VALUE` command.", name, value, b.prefix, name) + if name == roomOptionPassword { + msg = fmt.Sprintf("There is an SMTP password already set for this room/mailbox. "+ + "It's stored in a secure hashed manner, so we can't tell you what the original raw password was. "+ + "To find the raw password, try to find your old message which had originally set it, "+ + "or just set a new one with `%s %s NEW_PASSWORD`.", + b.prefix, name) + } b.SendNotice(ctx, evt.RoomID, msg) } +//nolint:gocognit func (b *Bot) setOption(ctx context.Context, name, value string) { cmd := b.commands.get(name) - if cmd != nil { + if cmd != nil && cmd.sanitizer != nil { value = cmd.sanitizer(value) } @@ -86,6 +96,14 @@ func (b *Bot) setOption(ctx context.Context, name, value string) { return } + if name == roomOptionPassword { + value, err = argon2pw.GenerateSaltedHash(value) + if err != nil { + b.Error(ctx, evt.RoomID, "failed to hash password: %v", err) + return + } + } + old := cfg.Get(name) cfg.Set(name, value) @@ -104,5 +122,9 @@ func (b *Bot) setOption(ctx context.Context, name, value string) { return } - b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("`%s` of this room set to `%s`", name, value)) + msg := fmt.Sprintf("`%s` of this room set to `%s`", name, value) + if name == roomOptionPassword { + msg = "SMTP password has been set" + } + b.SendNotice(ctx, evt.RoomID, msg) } diff --git a/bot/email.go b/bot/email.go index 6b391af..311701b 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, incoming bool) error { + roomID, ok := b.GetMapping(email.Mailbox(incoming)) 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 !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) @@ -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 !incoming { + 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/go.mod b/go.mod index 143822a..ff8d7a0 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,14 @@ go 1.18 require ( git.sr.ht/~xn/cache/v2 v2.0.0 github.com/emersion/go-msgauth v0.6.6 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-smtp v0.15.0 github.com/gabriel-vasile/mimetype v1.4.1 github.com/getsentry/sentry-go v0.13.0 github.com/jhillyerd/enmime v0.10.0 github.com/lib/pq v1.10.6 github.com/mattn/go-sqlite3 v1.14.15 + github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39 gitlab.com/etke.cc/go/env v1.0.0 gitlab.com/etke.cc/go/logger v1.1.0 gitlab.com/etke.cc/go/mxidwc v1.0.0 @@ -22,7 +24,6 @@ require ( require ( github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect - github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect github.com/google/go-cmp v0.5.8 // indirect github.com/gorilla/mux v1.8.0 // indirect diff --git a/go.sum b/go.sum index 4db88fe..e5efcb0 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39 h1:2by0+lF6NfaNWhlpsv1DfBQzwbAyYUPIsMWYapek/Sk= +github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39/go.mod h1:idX/fPqwjX31YMTF2iIpEpNApV2YbQhSFr4iIhJaqp4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= diff --git a/smtp/msa.go b/smtp/msa.go index dd18f92..71d50b2 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,21 +16,33 @@ type msa struct { log *logger.Logger domain string bot Bot + mta utils.MTA } -func (m *msa) newSession() *msasession { +func (m *msa) newSession(from string, incoming bool) *msasession { return &msasession{ - ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()), - log: m.log, - bot: m.bot, - domain: m.domain, + ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()), + mta: m.mta, + from: from, + incoming: incoming, + log: m.log, + bot: m.bot, + domain: m.domain, } } 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 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(), nil + return m.newSession("", true), nil } diff --git a/smtp/msasession.go b/smtp/msasession.go index 746568a..93b293f 100644 --- a/smtp/msasession.go +++ b/smtp/msasession.go @@ -2,6 +2,7 @@ package smtp import ( "context" + "errors" "io" "github.com/emersion/go-smtp" @@ -12,35 +13,48 @@ import ( "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 domain string - ctx context.Context - to string - from 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) - 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.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) - if utils.Hostname(to) != s.domain { - s.log.Debug("wrong domain of %s", to) - return smtp.ErrAuthRequired - } + if s.incoming { + 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 +94,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.incoming) } func (s *msasession) Reset() {} diff --git a/smtp/mta.go b/smtp/mta.go index 0ad6fac..dcf6475 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, incoming bool) error SetMTA(mta utils.MTA) } diff --git a/smtp/server.go b/smtp/server.go index 6a840f0..dfab3db 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, } @@ -50,6 +51,7 @@ func NewServer(cfg *Config) *Server { 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" { diff --git a/utils/email.go b/utils/email.go index 62fbbe7..88fdb0f 100644 --- a/utils/email.go +++ b/utils/email.go @@ -4,6 +4,7 @@ import ( "crypto" "crypto/x509" "encoding/pem" + "net/mail" "strings" "time" @@ -46,6 +47,12 @@ type ContentOptions struct { FromKey string } +// AddressValid checks if email address is valid +func AddressValid(email string) bool { + _, err := mail.ParseAddress(email) + return err == nil +} + // NewEmail constructs Email object func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files []*File) *Email { email := &Email{ @@ -71,6 +78,14 @@ func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files return email } +// Mailbox returns postmoogle's mailbox, parsing it from FROM (if incoming=false) or TO (incoming=true) +func (e *Email) Mailbox(incoming bool) string { + if incoming { + 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