diff --git a/README.md b/README.md index 903153c..40800a7 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,14 @@ If you want to change them - check available options in the help message (`!pm h --- +* **!pm spamcheck:mx** - only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable) +* **!pm spamcheck:smtp** - only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable) +* **!pm spamlist:emails** - Get or set `spamlist:emails` of the room (comma-separated list), eg: `spammer@example.com,sspam@example.org` +* **!pm spamlist:hosts** - Get or set `spamlist:hosts` of the room (comma-separated list), eg: `spammer.com,scammer.com,morespam.com` +* **!pm spamlist:mailboxes** - Get or set `spamlist:mailboxes` of the room (comma-separated list), eg: `notspam,noreply,no-reply` + +--- + * **!pm dkim** - Get DKIM signature * **!pm catch-all** - Configure catch-all mailbox * **!pm users** - Get or set allowed users patterns diff --git a/bot/bot.go b/bot/bot.go index dbcb11c..fe8f7a8 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -73,7 +73,9 @@ func (b *Bot) Error(ctx context.Context, roomID id.RoomID, message string, args b.log.Error(message, args...) err := fmt.Errorf(message, args...) - sentry.GetHubFromContext(ctx).CaptureException(err) + if hub := sentry.GetHubFromContext(ctx); hub != nil { + sentry.GetHubFromContext(ctx).CaptureException(err) + } if roomID != "" { b.SendError(ctx, roomID, err.Error()) } diff --git a/bot/command.go b/bot/command.go index fc92b5b..df135b3 100644 --- a/bot/command.go +++ b/bot/command.go @@ -143,6 +143,46 @@ func (b *Bot) initCommands() commandList { sanitizer: utils.SanitizeBoolString, allowed: b.allowOwner, }, + {allowed: b.allowOwner}, // delimiter + { + key: roomOptionSpamcheckMX, + description: "only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)", + sanitizer: utils.SanitizeBoolString, + allowed: b.allowOwner, + }, + { + key: roomOptionSpamcheckSMTP, + description: "only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)", + sanitizer: utils.SanitizeBoolString, + allowed: b.allowOwner, + }, + { + key: roomOptionSpamlistEmails, + description: fmt.Sprintf( + "Get or set `%s` of the room (comma-separated list), eg: `spammer@example.com,sspam@example.org`", + roomOptionSpamlistEmails, + ), + sanitizer: utils.SanitizeStringSlice, + allowed: b.allowOwner, + }, + { + key: roomOptionSpamlistHosts, + description: fmt.Sprintf( + "Get or set `%s` of the room (comma-separated list), eg: `spammer.com,scammer.com,morespam.com`", + roomOptionSpamlistHosts, + ), + sanitizer: utils.SanitizeStringSlice, + allowed: b.allowOwner, + }, + { + key: roomOptionSpamlistLocalparts, + description: fmt.Sprintf( + "Get or set `%s` of the room (comma-separated list), eg: `notspam,noreply,no-reply`", + roomOptionSpamlistLocalparts, + ), + sanitizer: utils.SanitizeStringSlice, + allowed: b.allowOwner, + }, {allowed: b.allowAdmin}, // delimiter { key: botOptionUsers, diff --git a/bot/email.go b/bot/email.go index b946795..9f9c892 100644 --- a/bot/email.go +++ b/bot/email.go @@ -59,6 +59,17 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) { return roomID, ok } +// GetIFOptions returns incoming email filtering options (room settings) +func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions { + cfg, err := b.getRoomSettings(roomID) + if err != nil { + b.log.Error("cannot retrieve room settings: %v", err) + return roomSettings{} + } + + 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)) diff --git a/bot/settings_room.go b/bot/settings_room.go index 9f4ccb1..fcf9c8f 100644 --- a/bot/settings_room.go +++ b/bot/settings_room.go @@ -13,16 +13,21 @@ const acRoomSettingsKey = "cc.etke.postmoogle.settings" // option keys const ( - roomOptionOwner = "owner" - roomOptionMailbox = "mailbox" - roomOptionNoSend = "nosend" - roomOptionNoSender = "nosender" - roomOptionNoRecipient = "norecipient" - roomOptionNoSubject = "nosubject" - roomOptionNoHTML = "nohtml" - roomOptionNoThreads = "nothreads" - roomOptionNoFiles = "nofiles" - roomOptionPassword = "password" + roomOptionOwner = "owner" + roomOptionMailbox = "mailbox" + roomOptionNoSend = "nosend" + roomOptionNoSender = "nosender" + roomOptionNoRecipient = "norecipient" + roomOptionNoSubject = "nosubject" + roomOptionNoHTML = "nohtml" + roomOptionNoThreads = "nothreads" + roomOptionNoFiles = "nofiles" + roomOptionPassword = "password" + roomOptionSpamcheckSMTP = "spamcheck:smtp" + roomOptionSpamcheckMX = "spamcheck:mx" + roomOptionSpamlistEmails = "spamlist:emails" + roomOptionSpamlistHosts = "spamlist:hosts" + roomOptionSpamlistLocalparts = "spamlist:mailboxes" ) type roomSettings map[string]string @@ -77,6 +82,26 @@ func (s roomSettings) NoFiles() bool { return utils.Bool(s.Get(roomOptionNoFiles)) } +func (s roomSettings) SpamcheckSMTP() bool { + return utils.Bool(s.Get(roomOptionSpamcheckSMTP)) +} + +func (s roomSettings) SpamcheckMX() bool { + return utils.Bool(s.Get(roomOptionSpamcheckMX)) +} + +func (s roomSettings) SpamlistEmails() []string { + return utils.StringSlice(s.Get(roomOptionSpamlistEmails)) +} + +func (s roomSettings) SpamlistHosts() []string { + return utils.StringSlice(s.Get(roomOptionSpamlistHosts)) +} + +func (s roomSettings) SpamlistLocalparts() []string { + return utils.StringSlice(s.Get(roomOptionSpamlistLocalparts)) +} + // ContentOptions converts room display settings to content options func (s roomSettings) ContentOptions() *utils.ContentOptions { return &utils.ContentOptions{ diff --git a/go.mod b/go.mod index 5f198db..01a9a63 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( gitlab.com/etke.cc/go/mxidwc v1.0.0 gitlab.com/etke.cc/go/secgen v1.1.1 gitlab.com/etke.cc/go/trysmtp v1.0.0 + gitlab.com/etke.cc/go/validator v1.0.1 gitlab.com/etke.cc/linkpearl v0.0.0-20221008191655-865ae3362a01 golang.org/x/net v0.0.0-20221004154528-8021a29435af maunium.net/go/mautrix v0.12.1 diff --git a/go.sum b/go.sum index afdda18..a38bcf0 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ gitlab.com/etke.cc/go/secgen v1.1.1 h1:RmKOki725HIhWJHzPtAc9X4YvBneczndchpMgoDkE gitlab.com/etke.cc/go/secgen v1.1.1/go.mod h1:3pJqRGeWApzx7qXjABqz2o2SMCNpKSZao/gXVdasqE8= gitlab.com/etke.cc/go/trysmtp v1.0.0 h1:f/7gSmzohKniVeLSLevI+ZsySYcPUGkT9cRlOTwjOr8= gitlab.com/etke.cc/go/trysmtp v1.0.0/go.mod h1:KqRuIB2IPElEEbAxXmFyKtm7S5YiuEb4lxwWthccqyE= +gitlab.com/etke.cc/go/validator v1.0.1 h1:xp1tAzgCu9A1pga8rFUo7hODaEcCR1nkkodw96+dYuA= +gitlab.com/etke.cc/go/validator v1.0.1/go.mod h1:3vdssRG4LwgdTr9IHz9MjGSEO+3/FO9hXPGMuSeweJ8= gitlab.com/etke.cc/linkpearl v0.0.0-20221008191655-865ae3362a01 h1:rlcxjSCCG18sbNT2CsCRKjtwQ2UjkuTutkRHSCGhhxs= gitlab.com/etke.cc/linkpearl v0.0.0-20221008191655-865ae3362a01/go.mod h1:HkUHUkhbkDueEJVc7h/zBfz2hjhl4xxjQKv9Itrdf9k= golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= diff --git a/smtp/msasession.go b/smtp/msasession.go index 93b293f..51d98be 100644 --- a/smtp/msasession.go +++ b/smtp/msasession.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -43,6 +44,7 @@ func (s *msasession) Mail(from string, opts smtp.MailOptions) error { func (s *msasession) Rcpt(to string) error { sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to) + s.to = to if s.incoming { if utils.Hostname(to) != s.domain { @@ -50,14 +52,18 @@ func (s *msasession) Rcpt(to string) error { return smtp.ErrAuthRequired } - _, ok := s.bot.GetMapping(utils.Mailbox(to)) + 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.to = to s.log.Debug("mail to %s", to) return nil } @@ -75,6 +81,21 @@ func (s *msasession) parseAttachments(parts []*enmime.Part) []*utils.File { return files } +func (s *msasession) validate(options utils.IncomingFilteringOptions) bool { + spam := validator.Spam{ + Emails: options.SpamlistEmails(), + Hosts: options.SpamlistHosts(), + Localparts: options.SpamlistLocalparts(), + } + enforce := validator.Enforce{ + MX: options.SpamcheckMX(), + SMTP: options.SpamcheckMX(), + } + v := validator.New(spam, 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) diff --git a/smtp/mta.go b/smtp/mta.go index a8647cb..c219b77 100644 --- a/smtp/mta.go +++ b/smtp/mta.go @@ -16,6 +16,7 @@ import ( 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) } diff --git a/utils/email.go b/utils/email.go index b415929..12c9814 100644 --- a/utils/email.go +++ b/utils/email.go @@ -19,6 +19,15 @@ type MTA interface { Send(from, to, data string) error } +// IncomingFilteringOptions for incoming mail +type IncomingFilteringOptions interface { + SpamcheckSMTP() bool + SpamcheckMX() bool + SpamlistEmails() []string + SpamlistHosts() []string + SpamlistLocalparts() []string +} + // Email object type Email struct { Date string diff --git a/utils/utils.go b/utils/utils.go index 93271da..f49939a 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -33,3 +33,31 @@ func Bool(str string) bool { func SanitizeBoolString(str string) string { return strconv.FormatBool(Bool(str)) } + +// StringSlice converts comma-separated string to slice +func StringSlice(str string) []string { + if str == "" { + return nil + } + + str = strings.TrimSpace(str) + if strings.IndexByte(str, ',') == -1 { + return []string{str} + } + + return strings.Split(str, ",") +} + +// SanitizeBoolString converts string to slice and back to string +func SanitizeStringSlice(str string) string { + parts := StringSlice(str) + if len(parts) == 0 { + return str + } + + for i, part := range parts { + parts[i] = strings.TrimSpace(part) + } + + return strings.Join(parts, ",") +}