diff --git a/README.md b/README.md index 281d951..6f06c7c 100644 --- a/README.md +++ b/README.md @@ -97,55 +97,88 @@ If you want to change them - check available options in the help message (`!pm h
Full list of available commands -* **!pm help** - Show help message -* **!pm stop** - Disable bridge for the room and clear all configuration +> The following section is visible to all allowed users + +* **`!pm help`** - Show this help message +* **`!pm stop`** - Disable bridge for the room and clear all configuration +* **`!pm send`** - Send email --- -* **!pm mailbox** - Get or set mailbox of the room -* **!pm domain** - Get or set default domain of the room -* **!pm owner** - Get or set owner of the room -* **!pm password** - Get or set SMTP password of the room's mailbox +#### mailbox ownership + +> The following section is visible to the mailbox owners only + +* **`!pm mailbox`** - Get or set mailbox of the room +* **`!pm domain`** - Get or set default domain of the room +* **`!pm owner`** - Get or set owner of the room +* **`!pm password`** - Get or set SMTP password of the room's mailbox --- -* **!pm nosend** - Get or set `nosend` of the room (`true` - disable email sending; `false` - enable email sending) -* **!pm noreplies** - Get or set `noreplies` of the room (`true` - ignore matrix replies; `false` - parse matrix replies) -* **!pm nosender** - Get or set `nosender` of the room (`true` - hide email sender; `false` - show email sender) -* **!pm norecipient** - Get or set `norecipient` of the room (`true` - hide recipient; `false` - show recipient) -* **!pm nocc** - Get or set `nocc` of the room (`true` - hide CC; `false` - show CC) -* **!pm nosubject** - Get or set `nosubject` of the room (`true` - hide email subject; `false` - show email subject) -* **!pm nohtml** - Get or set `nohtml` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails) -* **!pm nothreads** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads) -* **!pm nofiles** - Get or set `nofiles` of the room (`true` - ignore email attachments; `false` - upload email attachments) -* **!pm noinlines** - Get or set `noinlines` of the room (`true` - ignore inline attachments; `false` - upload inline attachments) +#### mailbox options + +> The following section is visible to the mailbox owners only + +* **`!pm nosend`** - Get or set `nosend` of the room (`true` - disable email sending; `false` - enable email sending) +* **`!pm noreplies`** - Get or set `noreplies` of the room (`true` - ignore matrix replies; `false` - parse matrix replies) +* **`!pm nosender`** - Get or set `nosender` of the room (`true` - hide email sender; `false` - show email sender) +* **`!pm norecipient`** - Get or set `norecipient` of the room (`true` - hide recipient; `false` - show recipient) +* **`!pm nocc`** - Get or set `nocc` of the room (`true` - hide CC; `false` - show CC) +* **`!pm nosubject`** - Get or set `nosubject` of the room (`true` - hide email subject; `false` - show email subject) +* **`!pm nohtml`** - Get or set `nohtml` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails) +* **`!pm nothreads`** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads) +* **`!pm nofiles`** - Get or set `nofiles` of the room (`true` - ignore email attachments; `false` - upload email attachments) +* **`!pm noinlines`** - Get or set `noinlines` of the room (`true` - ignore inline attachments; `false` - upload inline attachments) --- -* **!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:spf** - only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable) -* **!pm spamcheck:dkim** - only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`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** - Get or set `spamlist` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,noreply@*` +#### mailbox security checks + +> The following section is visible to the mailbox owners only + +* **`!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:spf`** - only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable) +* **`!pm spamcheck:dkim`** - only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`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 adminroom** - Get or set admin room -* **!pm dkim** - Get DKIM signature -* **!pm catch-all** - Configure catch-all mailbox -* **!pm queue:batch** - max amount of emails to process on each queue check -* **!pm queue:retries** - max amount of tries per email in queue before removal -* **!pm users** - Get or set allowed users patterns -* **!pm mailboxes** - Show the list of all mailboxes -* **!pm delete** <mailbox> - Delete specific mailbox +#### mailbox anti-spam + +> The following section is visible to the mailbox owners only + +* **`!pm spam:list`** - Show comma-separated spamlist of the room, eg: `spammer@example.com,*@spammer.org,spam@*` +* **`!pm spam:add`** - Mark an email address (or pattern) as spam +* **`!pm spam:remove`** - Unmark an email address (or pattern) as spam +* **`!pm spam:reset`** - Reset spamlist --- -* **!pm greylist** - Set automatic greylisting duration in minutes (0 - disabled) -* **!pm banlist** - Enable/disable banlist and show current values -* **!pm banlist:add** - Ban an IP -* **!pm banlist:remove** - Unban an IP -* **!pm banlist:reset** - Reset banlist +#### server options + +> The following section is visible to the bridge admins only + +* **`!pm adminroom`** - Get or set admin room +* **`!pm users`** - Get or set allowed users +* **`!pm dkim`** - Get DKIM signature +* **`!pm catch-all`** - Get or set catch-all mailbox +* **`!pm queue:batch`** - max amount of emails to process on each queue check +* **`!pm queue:retries`** - max amount of tries per email in queue before removal +* **`!pm mailboxes`** - Show the list of all mailboxes +* **`!pm delete`** - Delete specific mailbox + +--- + +#### server antispam + +> The following section is visible to the bridge admins only + +* **`!pm greylist`** - Set automatic greylisting duration in minutes (0 - disabled) +* **`!pm banlist`** - Enable/disable banlist and show current values +* **`!pm banlist:add`** - Ban an IP +* **`!pm banlist:remove`** - Unban an IP +* **`!pm banlist:reset`** - Reset banlist
diff --git a/bot/command.go b/bot/command.go index 10ca203..58f9318 100644 --- a/bot/command.go +++ b/bot/command.go @@ -16,20 +16,24 @@ import ( ) const ( - commandHelp = "help" - commandStop = "stop" - commandSend = "send" - commandDKIM = "dkim" - commandCatchAll = config.BotCatchAll - commandUsers = config.BotUsers - commandQueueBatch = config.BotQueueBatch - commandQueueRetries = config.BotQueueRetries - commandDelete = "delete" - commandBanlist = "banlist" - commandBanlistAdd = "banlist:add" - commandBanlistRemove = "banlist:remove" - commandBanlistReset = "banlist:reset" - commandMailboxes = "mailboxes" + commandHelp = "help" + commandStop = "stop" + commandSend = "send" + commandDKIM = "dkim" + commandCatchAll = config.BotCatchAll + commandUsers = config.BotUsers + commandQueueBatch = config.BotQueueBatch + commandQueueRetries = config.BotQueueRetries + commandSpamlist = "spam:list" + commandSpamlistAdd = "spam:add" + commandSpamlistRemove = "spam:remove" + commandSpamlistReset = "spam:reset" + commandDelete = "delete" + commandBanlist = "banlist" + commandBanlistAdd = "banlist:add" + commandBanlistRemove = "banlist:remove" + commandBanlistReset = "banlist:reset" + commandMailboxes = "mailboxes" ) type ( @@ -185,7 +189,7 @@ func (b *Bot) initCommands() commandList { sanitizer: utils.SanitizeBoolString, allowed: b.allowOwner, }, - {allowed: b.allowOwner, description: "mailbox antispam"}, // delimiter + {allowed: b.allowOwner, description: "mailbox security checks"}, // delimiter { key: config.RoomSpamcheckMX, description: "only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)", @@ -210,14 +214,27 @@ func (b *Bot) initCommands() commandList { sanitizer: utils.SanitizeBoolString, allowed: b.allowOwner, }, + {allowed: b.allowOwner, description: "mailbox anti-spam"}, // delimiter { - key: config.RoomSpamlist, - description: fmt.Sprintf( - "Get or set `%s` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,spam@*`", - config.RoomSpamlist, - ), - sanitizer: utils.SanitizeStringSlice, - allowed: b.allowOwner, + key: commandSpamlist, + description: "Show comma-separated spamlist of the room, eg: `spammer@example.com,*@spammer.org,spam@*`", + sanitizer: utils.SanitizeStringSlice, + allowed: b.allowOwner, + }, + { + key: commandSpamlistAdd, + description: "Mark an email address (or pattern) as spam", + allowed: b.allowOwner, + }, + { + key: commandSpamlistRemove, + description: "Unmark an email address (or pattern) as spam", + allowed: b.allowOwner, + }, + { + key: commandSpamlistReset, + description: "Reset spamlist", + allowed: b.allowOwner, }, {allowed: b.allowAdmin, description: "server options"}, // delimiter { @@ -340,6 +357,12 @@ func (b *Bot) handle(ctx context.Context) { b.runSend(ctx) case commandDKIM: b.runDKIM(ctx, commandSlice) + case commandSpamlistAdd: + b.runSpamlistAdd(ctx, commandSlice) + case commandSpamlistRemove: + b.runSpamlistRemove(ctx, commandSlice) + case commandSpamlistReset: + b.runSpamlistReset(ctx) case config.BotAdminRoom: b.runAdminRoom(ctx, commandSlice) case commandUsers: @@ -427,7 +450,12 @@ func (b *Bot) sendHelp(ctx context.Context) { msg.WriteString(" ") msg.WriteString(cmd.key) msg.WriteString("`**") - value := cfg.Get(cmd.key) + + name := cmd.key + if name == commandSpamlist { + name = config.RoomSpamlist + } + value := cfg.Get(name) if cmd.sanitizer != nil { switch value != "" { case false: diff --git a/bot/command_owner.go b/bot/command_owner.go index 4198a45..ef4462c 100644 --- a/bot/command_owner.go +++ b/bot/command_owner.go @@ -3,7 +3,9 @@ package bot import ( "context" "fmt" + "slices" "strconv" + "strings" "github.com/raja/argon2pw" @@ -52,6 +54,10 @@ func (b *Bot) getOption(ctx context.Context, name string) { return } + if name == commandSpamlist { + name = config.RoomSpamlist + } + value := cfg.Get(name) if value == "" { msg := fmt.Sprintf("`%s` is not set, kupo.\n"+ @@ -138,3 +144,100 @@ func (b *Bot) setOption(ctx context.Context, name, value string) { } b.SendNotice(ctx, evt.RoomID, msg) } + +func (b *Bot) runSpamlistAdd(ctx context.Context, commandSlice []string) { + evt := eventFromContext(ctx) + if len(commandSlice) < 2 { + b.getOption(ctx, config.RoomSpamlist) + return + } + roomCfg, err := b.cfg.GetRoom(evt.RoomID) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot get room settings: %v", err) + return + } + spamlist := utils.StringSlice(roomCfg[config.RoomSpamlist]) + for _, newItem := range commandSlice[1:] { + newItem = strings.TrimSpace(newItem) + if slices.Contains(spamlist, newItem) { + continue + } + spamlist = append(spamlist, newItem) + } + + roomCfg.Set(config.RoomSpamlist, utils.SliceString(spamlist)) + err = b.cfg.SetRoom(evt.RoomID, roomCfg) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot store room settings: %v", err) + return + } + + b.SendNotice(ctx, evt.RoomID, "spamlist has been updated, kupo") +} + +func (b *Bot) runSpamlistRemove(ctx context.Context, commandSlice []string) { + evt := eventFromContext(ctx) + if len(commandSlice) < 2 { + b.getOption(ctx, config.RoomSpamlist) + return + } + roomCfg, err := b.cfg.GetRoom(evt.RoomID) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot get room settings: %v", err) + return + } + toRemove := map[int]struct{}{} + spamlist := utils.StringSlice(roomCfg[config.RoomSpamlist]) + for _, item := range commandSlice[1:] { + item = strings.TrimSpace(item) + idx := slices.Index(spamlist, item) + if idx < 0 { + continue + } + toRemove[idx] = struct{}{} + } + if len(toRemove) == 0 { + b.SendNotice(ctx, evt.RoomID, "nothing new, kupo.") + return + } + + updatedSpamlist := []string{} + for i, item := range spamlist { + if _, ok := toRemove[i]; ok { + continue + } + updatedSpamlist = append(updatedSpamlist, item) + } + + roomCfg.Set(config.RoomSpamlist, utils.SliceString(updatedSpamlist)) + err = b.cfg.SetRoom(evt.RoomID, roomCfg) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot store room settings: %v", err) + return + } + + b.SendNotice(ctx, evt.RoomID, "spamlist has been updated, kupo") +} + +func (b *Bot) runSpamlistReset(ctx context.Context) { + evt := eventFromContext(ctx) + roomCfg, err := b.cfg.GetRoom(evt.RoomID) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot get room settings: %v", err) + return + } + spamlist := utils.StringSlice(roomCfg[config.RoomSpamlist]) + if len(spamlist) == 0 { + b.SendNotice(ctx, evt.RoomID, "spamlist is empty, kupo.") + return + } + + roomCfg.Set(config.RoomSpamlist, "") + err = b.cfg.SetRoom(evt.RoomID, roomCfg) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot store room settings: %v", err) + return + } + + b.SendNotice(ctx, evt.RoomID, "spamlist has been reset, kupo.") +} diff --git a/bot/config/room.go b/bot/config/room.go index fd55b88..0539559 100644 --- a/bot/config/room.go +++ b/bot/config/room.go @@ -168,7 +168,7 @@ func (s Room) MigrateSpamlistSettings() { for item := range uniq { spamlist = append(spamlist, item) } - s.Set(RoomSpamlist, strings.Join(spamlist, ",")) + s.Set(RoomSpamlist, utils.SliceString(spamlist)) } // ContentOptions converts room display settings to content options diff --git a/utils/utils.go b/utils/utils.go index d418edf..489e7c4 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -2,6 +2,7 @@ package utils import ( "net" + "sort" "strconv" "strings" @@ -96,6 +97,20 @@ func SanitizeIntString(str string) string { return strconv.Itoa(Int(str)) } +// SliceString converts slice into comma-separated string +func SliceString(strs []string) string { + res := []string{} + for _, str := range strs { + str = strings.TrimSpace(str) + if str == "" { + continue + } + res = append(res, str) + } + sort.Strings(res) + return strings.Join(res, ",") +} + // StringSlice converts comma-separated string to slice func StringSlice(str string) []string { if str == "" { @@ -107,19 +122,15 @@ func StringSlice(str string) []string { 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 - } - + parts := strings.Split(str, ",") for i, part := range parts { parts[i] = strings.TrimSpace(part) } - return strings.Join(parts, ",") + return parts +} + +// SanitizeBoolString converts string to slice and back to string +func SanitizeStringSlice(str string) string { + return SliceString(StringSlice(str)) }