diff --git a/README.md b/README.md index d1be7aa..d09d1ef 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ env vars * **POSTMOOGLE_LOGLEVEL** - log level * **POSTMOOGLE_DB_DSN** - database connection string * **POSTMOOGLE_DB_DIALECT** - database dialect (postgres, sqlite3) -* **POSTMOOGLE_MAILBOXES_RESERVED** - space separated list of reserved mailboxes (e.g.: `postmaster admin root`), nobody can create them +* **POSTMOOGLE_MAILBOXES_RESERVED** - space separated list of reserved mailboxes, [docs/mailboxes.md](docs/mailboxes.md) +* **POSTMOOGLE_MAILBOXES_ACTIVATION** - activation flow for new mailboxes, [docs/mailboxes.md](docs/mailboxes.md) * **POSTMOOGLE_MAXSIZE** - max email size (including attachments) in megabytes * **POSTMOOGLE_ADMINS** - a space-separated list of admin users. See `POSTMOOGLE_USERS` for syntax examples @@ -117,6 +118,7 @@ If you want to change them - check available options in the help message (`!pm h --- +* **!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 diff --git a/bot/access.go b/bot/access.go index aaca84e..0a00d76 100644 --- a/bot/access.go +++ b/bot/access.go @@ -74,7 +74,7 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool { } func (b *Bot) isReserved(mailbox string) bool { - for _, reserved := range b.reservedMailboxes { + for _, reserved := range b.mbxc.Reserved { if mailbox == reserved { return true } diff --git a/bot/activation.go b/bot/activation.go new file mode 100644 index 0000000..96f6771 --- /dev/null +++ b/bot/activation.go @@ -0,0 +1,54 @@ +package bot + +import ( + "fmt" + + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" +) + +type activationFlow func(id.UserID, id.RoomID, string) bool + +func (b *Bot) getActivationFlow() activationFlow { + switch b.mbxc.Activation { + case "none": + return b.activateNone + case "notify": + return b.activateNotify + default: + return b.activateNone + } +} + +// ActivateMailbox using the configured flow +func (b *Bot) ActivateMailbox(ownerID id.UserID, roomID id.RoomID, mailbox string) bool { + flow := b.getActivationFlow() + return flow(ownerID, roomID, mailbox) +} + +func (b *Bot) activateNone(ownerID id.UserID, roomID id.RoomID, mailbox string) bool { + b.log.Debug("activating mailbox %q (%q) of %q through flow 'none'", mailbox, roomID, ownerID) + b.rooms.Store(mailbox, roomID) + + return true +} + +func (b *Bot) activateNotify(ownerID id.UserID, roomID id.RoomID, mailbox string) bool { + b.log.Debug("activating mailbox %q (%q) of %q through flow 'notify'", mailbox, roomID, ownerID) + b.rooms.Store(mailbox, roomID) + if len(b.adminRooms) == 0 { + return true + } + + msg := fmt.Sprintf("Mailbox %q has been registered by %q for the room %q", mailbox, ownerID, roomID) + for _, adminRoom := range b.adminRooms { + content := format.RenderMarkdown(msg, true, true) + _, err := b.lp.Send(adminRoom, &content) + if err != nil { + b.log.Info("cannot send mailbox activation notification to the admin room %q", adminRoom) + continue + } + break + } + return true +} diff --git a/bot/bot.go b/bot/bot.go index c9eee4e..82a4d48 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -14,13 +14,20 @@ import ( "maunium.net/go/mautrix/id" ) +// Mailboxes config +type MBXConfig struct { + Reserved []string + Activation string +} + // Bot represents matrix bot type Bot struct { prefix string + mbxc MBXConfig domains []string allowedUsers []*regexp.Regexp allowedAdmins []*regexp.Regexp - reservedMailboxes []string + adminRooms []id.RoomID commands commandList banlist bglist rooms sync.Map @@ -37,17 +44,18 @@ func New( log *logger.Logger, prefix string, domains []string, - reserved []string, admins []string, + mbxc MBXConfig, ) (*Bot, error) { b := &Bot{ - reservedMailboxes: reserved, - domains: domains, - prefix: prefix, - rooms: sync.Map{}, - log: log, - lp: lp, - mu: map[string]*sync.Mutex{}, + domains: domains, + prefix: prefix, + rooms: sync.Map{}, + adminRooms: []id.RoomID{}, + mbxc: mbxc, + log: log, + lp: lp, + mu: map[string]*sync.Mutex{}, } users, err := b.initBotUsers() if err != nil { diff --git a/bot/command.go b/bot/command.go index dc4af3d..66ffeba 100644 --- a/bot/command.go +++ b/bot/command.go @@ -189,6 +189,11 @@ func (b *Bot) initCommands() commandList { allowed: b.allowOwner, }, {allowed: b.allowAdmin}, // delimiter + { + key: botOptionAdminRoom, + description: "Get or set admin room", + allowed: b.allowAdmin, + }, { key: botOptionUsers, description: "Get or set allowed users", @@ -280,6 +285,8 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice b.runSend(ctx) case commandDKIM: b.runDKIM(ctx, commandSlice) + case botOptionAdminRoom: + b.runAdminRoom(ctx, commandSlice) case commandUsers: b.runUsers(ctx, commandSlice) case commandCatchAll: diff --git a/bot/command_admin.go b/bot/command_admin.go index fd52c2f..e7e9088 100644 --- a/bot/command_admin.go +++ b/bot/command_admin.go @@ -208,6 +208,40 @@ func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) { b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s` (%s).", mailbox, utils.EmailsList(mailbox, ""))) } +func (b *Bot) runAdminRoom(ctx context.Context, commandSlice []string) { + evt := eventFromContext(ctx) + cfg := b.getBotSettings() + if len(commandSlice) < 2 { + var msg strings.Builder + msg.WriteString("Currently: `") + if cfg.AdminRoom() != "" { + msg.WriteString(cfg.AdminRoom().String()) + } else { + msg.WriteString("not set") + } + msg.WriteString("`\n\n") + msg.WriteString("Usage: `") + msg.WriteString(b.prefix) + msg.WriteString(" adminroom ROOM_ID`") + msg.WriteString("where ROOM_ID is valid and existing matrix room id\n") + + b.SendNotice(ctx, evt.RoomID, msg.String()) + return + } + + roomID := b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case + cfg.Set(botOptionAdminRoom, roomID) + err := b.setBotSettings(cfg) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err) + return + } + + b.adminRooms = append([]id.RoomID{id.RoomID(roomID)}, b.adminRooms...) // make it the first room in list on the fly + + b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Admin Room is set to: `%s`.", roomID)) +} + func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) { cfg := b.getBotSettings() greylist := b.getGreylist() diff --git a/bot/command_owner.go b/bot/command_owner.go index a0e4970..6c99959 100644 --- a/bot/command_owner.go +++ b/bot/command_owner.go @@ -3,6 +3,7 @@ package bot import ( "context" "fmt" + "strconv" "github.com/raja/argon2pw" @@ -84,6 +85,10 @@ func (b *Bot) setOption(ctx context.Context, name, value string) { } evt := eventFromContext(ctx) + // ignore request + if name == roomOptionActive { + return + } if name == roomOptionMailbox { existingID, ok := b.getMapping(value) if (ok && existingID != "" && existingID != evt.RoomID) || b.isReserved(value) { @@ -115,7 +120,8 @@ func (b *Bot) setOption(ctx context.Context, name, value string) { if old != "" { b.rooms.Delete(old) } - b.rooms.Store(value, evt.RoomID) + active := b.ActivateMailbox(evt.Sender, evt.RoomID, value) + cfg.Set(roomOptionActive, strconv.FormatBool(active)) value = fmt.Sprintf("%s@%s", value, utils.SanitizeDomain(cfg.Domain())) } diff --git a/bot/data.go b/bot/data.go index c10eede..2327fec 100644 --- a/bot/data.go +++ b/bot/data.go @@ -1,5 +1,7 @@ package bot +import "maunium.net/go/mautrix/id" + var migrations = []string{} func (b *Bot) migrate() error { @@ -32,20 +34,30 @@ func (b *Bot) migrate() error { } func (b *Bot) syncRooms() error { + adminRoom := b.getBotSettings().AdminRoom() + if adminRoom != "" { + b.adminRooms = append(b.adminRooms, adminRoom) + } + resp, err := b.lp.GetClient().JoinedRooms() if err != nil { return err } for _, roomID := range resp.JoinedRooms { + b.migrateRoomSettings(roomID) cfg, serr := b.getRoomSettings(roomID) if serr != nil { continue } - b.migrateRoomSettings(roomID) mailbox := cfg.Mailbox() - if mailbox != "" { + active := cfg.Active() + if mailbox != "" && active { b.rooms.Store(mailbox, roomID) } + + if cfg.Owner() != "" && b.allowAdmin(id.UserID(cfg.Owner()), "") { + b.adminRooms = append(b.adminRooms, roomID) + } } return nil diff --git a/bot/settings_bot.go b/bot/settings_bot.go index c55a1a7..dd6bede 100644 --- a/bot/settings_bot.go +++ b/bot/settings_bot.go @@ -3,6 +3,8 @@ package bot import ( "strings" + "maunium.net/go/mautrix/id" + "gitlab.com/etke.cc/postmoogle/utils" ) @@ -11,6 +13,7 @@ const acBotSettingsKey = "cc.etke.postmoogle.config" // bot options keys const ( + botOptionAdminRoom = "adminroom" botOptionUsers = "users" botOptionCatchAll = "catch-all" botOptionDKIMSignature = "dkim.pub" @@ -52,6 +55,11 @@ func (s botSettings) CatchAll() string { return s.Get(botOptionCatchAll) } +// AdminRoom option +func (s botSettings) AdminRoom() id.RoomID { + return id.RoomID(s.Get(botOptionAdminRoom)) +} + // BanlistEnabled option func (s botSettings) BanlistEnabled() bool { return utils.Bool(s.Get(botOptionBanlistEnabled)) diff --git a/bot/settings_room.go b/bot/settings_room.go index bd62091..ffdae3d 100644 --- a/bot/settings_room.go +++ b/bot/settings_room.go @@ -14,6 +14,7 @@ const acRoomSettingsKey = "cc.etke.postmoogle.settings" // option keys const ( + roomOptionActive = ".active" roomOptionOwner = "owner" roomOptionMailbox = "mailbox" roomOptionDomain = "domain" @@ -55,6 +56,10 @@ func (s roomSettings) Owner() string { return s.Get(roomOptionOwner) } +func (s roomSettings) Active() bool { + return utils.Bool(s.Get(roomOptionActive)) +} + func (s roomSettings) Password() string { return s.Get(roomOptionPassword) } @@ -188,6 +193,9 @@ func (b *Bot) migrateRoomSettings(roomID id.RoomID) { b.log.Error("cannot retrieve room settings: %v", err) return } + if _, ok := cfg[roomOptionActive]; !ok { + cfg.Set(roomOptionActive, "true") + } if cfg["spamlist:emails"] == "" && cfg["spamlist:localparts"] == "" && cfg["spamlist:hosts"] == "" { return diff --git a/cmd/cmd.go b/cmd/cmd.go index 60a682b..40316a6 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -98,7 +98,7 @@ func initBot(cfg *config.Config) { log.Fatal("cannot initialize matrix bot: %v", err) } - mxb, err = bot.New(lp, mxlog, cfg.Prefix, cfg.Domains, cfg.Mailboxes.Reserved, cfg.Admins) + mxb, err = bot.New(lp, mxlog, cfg.Prefix, cfg.Domains, cfg.Admins, bot.MBXConfig(cfg.Mailboxes)) if err != nil { // nolint // Fatal = panic, not os.Exit() log.Fatal("cannot start matrix bot: %v", err) diff --git a/config/config.go b/config/config.go index 63f1b04..e00c398 100644 --- a/config/config.go +++ b/config/config.go @@ -23,7 +23,8 @@ func New() *Config { StatusMsg: env.String("statusmsg", defaultConfig.StatusMsg), Admins: env.Slice("admins"), Mailboxes: Mailboxes{ - Reserved: env.Slice("mailboxes.reserved"), + Reserved: env.Slice("mailboxes.reserved"), + Activation: env.String("mailboxes.activation", defaultConfig.Mailboxes.Activation), }, TLS: TLS{ Certs: env.Slice("tls.cert"), diff --git a/config/defaults.go b/config/defaults.go index 41db4c6..8dcce79 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -7,6 +7,9 @@ var defaultConfig = &Config{ Prefix: "!pm", MaxSize: 1024, StatusMsg: "Delivering emails", + Mailboxes: Mailboxes{ + Activation: "none", + }, DB: DB{ DSN: "local.db", Dialect: "sqlite3", diff --git a/config/types.go b/config/types.go index 764212b..bc4396d 100644 --- a/config/types.go +++ b/config/types.go @@ -62,5 +62,6 @@ type Sentry struct { // Mailboxes config type Mailboxes struct { - Reserved []string + Reserved []string + Activation string } diff --git a/docs/mailboxes.md b/docs/mailboxes.md new file mode 100644 index 0000000..152773c --- /dev/null +++ b/docs/mailboxes.md @@ -0,0 +1,25 @@ +# Mailboxes configuration + +## `POSTMOOGLE_MAILBOXES_RESERVED` + +Space separated list of reserved mailboxes, example: + +```bash +export POSTMOOGLE_MAILBOXES_RESERVED=admin root postmaster +``` + +Nobody can create a mailbox from that list + +## `POSTMOOGLE_MAILBOXES_ACTIVATION` + +Type of activation flow: + +### `none` (default) + +If `POSTMOOGLE_MAILBOXES_ACTIVATION=none` mailbox will be just created as is, without any additional checks. + +### `notify` + +If `POSTMOOGLE_MAILBOXES_ACTIVATION=notify`, mailbox will be created as in `none` case **and** notification will be sent to one of the mailboxes managed by a postmoogle admin. + +To make it work, a postmoogle admin (or multiple admins) should either set `!pm adminroom` or create at least one mailbox.