39 Commits

Author SHA1 Message Date
Aine
b79fcceb3d Merge branch 'security' into 'main'
Security

See merge request etke.cc/postmoogle!34
2022-10-10 06:56:41 +00:00
Aine
8c2ed1b496 Merge branch 'main' into security 2022-10-10 09:49:36 +03:00
Aine
6f4da59387 feedback, typos, renaming 2022-10-10 09:41:22 +03:00
Aine
7a438bd761 increase linter timeout 2022-10-08 23:12:41 +03:00
Aine
cae3ea04d0 update deps, fixes #37 2022-10-08 22:26:45 +03:00
Aine
4ec51b64eb fix possible nil 2022-10-08 18:22:31 +03:00
Aine
c6049a7451 hotfix panic, fixes #36 2022-10-08 18:20:41 +03:00
Aine
d575552237 update readme 2022-10-08 12:01:03 +03:00
Aine
1dd996e430 rename security options 2022-10-08 11:58:14 +03:00
Aine
0767e7d0c3 security and spam options descriptions 2022-10-08 11:29:10 +03:00
Aine
99e509ea3a Email validations 2022-10-08 00:11:48 +03:00
Aine
6f8e850103 expose security and spam options 2022-10-07 23:24:59 +03:00
Aine
70ef60c934 add 'norecipient' room option, closes #35 2022-10-07 23:07:57 +03:00
Aine
6598e884c4 update roadmap 2022-10-04 21:53:31 +03:00
Aine
d6b6a5dc44 add catch-all mailbox, closes #25 2022-10-04 21:45:52 +03:00
Aine
4c6b7c2c1a set Content-Transfer-Encoding header, fixes #32 2022-10-04 21:10:56 +03:00
Aine
267f5cb949 move MTA SMTP connection to external lib 2022-10-04 12:16:59 +03:00
Aine
f3c5c47e76 room and user account data encryption 2022-10-02 20:15:46 +03:00
Aine
8c2a383421 update deps 2022-10-02 16:15:09 +03:00
Aine
ed5765b42a move seding files and work with account data to the linkpearl level 2022-10-02 13:51:58 +03:00
Aine
f585e6ba06 fix !pm send parsing 2022-09-23 18:16:39 +03:00
Aine
f3873132a7 Merge branch 'expose-mta' into 'main'
use postmoogle as general purpose SMTP server and allow other apps or scripts to send emails through it

See merge request etke.cc/postmoogle!33
2022-09-23 08:31:50 +00:00
Aine
c56c740c1d add password option messages 2022-09-23 11:28:15 +03:00
Aine
4bf0f0dee3 switch to password hashes 2022-09-23 11:17:34 +03:00
Aine
ce53d85806 Merge branch 'main' into expose-mta 2022-09-23 10:44:00 +03:00
Aine
236b23470d add comment 2022-09-23 10:42:17 +03:00
Aine
e368d26fc1 check full email in AllowAuth 2022-09-23 10:37:08 +03:00
Slavi Pantaleev
9129f8e38c Apply 1 suggestion(s) to 1 file(s) 2022-09-23 07:35:35 +00:00
Aine
bd2237d717 fix typo 2022-09-23 10:34:25 +03:00
Aine
3f5a1cd915 rename local to incoming 2022-09-23 10:33:25 +03:00
Aine
d50b79a801 switch email address validation to mail.ParseAddress 2022-09-23 10:29:37 +03:00
Aine
5a19ffad08 securely compare passwords, add notice about message removal 2022-09-23 10:19:25 +03:00
Aine
7473ed9450 send emails in unicode, fixes #31 2022-09-22 22:23:47 +03:00
Aine
90927247fd fix nosend description 2022-09-22 21:40:31 +03:00
Aine
1dc552686d reflect smtp auth changes in radme 2022-09-22 18:26:56 +03:00
Aine
070a6ffc76 use postmoogle as general purpose SMTP server and allow other apps or scripts to send emails through it 2022-09-22 18:21:17 +03:00
Aine
c9c871287d add read receipts and typing notifications, closes #30 2022-09-21 11:18:25 +03:00
Aine
16c577eeb2 update deps 2022-09-21 11:10:41 +03:00
Aine
97aacbf143 export MXID patterns parsing to external lib 2022-09-14 13:46:56 +03:00
28 changed files with 515 additions and 562 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/local.db
/local.db-journal
/cover.out
/e2e/main.go

View File

@@ -1,6 +1,6 @@
run:
concurrency: 4
timeout: 5m
timeout: 30m
issues-exit-code: 1
tests: true
build-tags: []

View File

@@ -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
@@ -16,6 +17,7 @@ It can't be used with arbitrary email providers, but setup your own provider "wi
- [x] Configuration in room's account data
- [x] Receive emails to matrix rooms
- [x] Receive attachments
- [x] Catch-all mailbox
- [x] Map email threads to matrix threads
#### deep dive
@@ -30,6 +32,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
@@ -52,7 +55,8 @@ env vars
* **POSTMOOGLE_TLS_CERT** - path to your SSL certificate (chain)
* **POSTMOOGLE_TLS_KEY** - path to your SSL certificate's private key
* **POSTMOOGLE_TLS_REQUIRED** - require TLS connection, **even** on the non-TLS port (`POSTMOOGLE_PORT`). TLS connections are always required on the TLS port (`POSTMOOGLE_TLS_PORT`) regardless of this setting.
* **POSTMOOGLE_NOENCRYPTION** - disable encryption support
* **POSTMOOGLE_DATA_SECRET** - secure key (password) to encrypt account data, must be 16, 24, or 32 bytes long
* **POSTMOOGLE_NOENCRYPTION** - disable matrix encryption (libolm) support
* **POSTMOOGLE_STATUSMSG** - presence status message
* **POSTMOOGLE_SENTRY_DSN** - sentry DSN
* **POSTMOOGLE_LOGLEVEL** - log level
@@ -237,10 +241,12 @@ 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
---
* **!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 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)
@@ -248,7 +254,16 @@ 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
* **!pm mailboxes** - Show the list of all mailboxes
* **!pm delete** <mailbox> - Delete specific mailbox

View File

@@ -3,7 +3,11 @@ package bot
import (
"context"
"regexp"
"strings"
"github.com/getsentry/sentry-go"
"github.com/raja/argon2pw"
"gitlab.com/etke.cc/go/mxidwc"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
@@ -14,12 +18,12 @@ func parseMXIDpatterns(patterns []string, defaultPattern string) ([]*regexp.Rege
patterns = []string{defaultPattern}
}
return utils.WildcardMXIDsToRegexes(patterns)
return mxidwc.ParsePatterns(patterns)
}
func (b *Bot) allowUsers(actorID id.UserID) bool {
if len(b.allowedUsers) != 0 {
if !utils.Match(actorID.String(), b.allowedUsers) {
if !mxidwc.Match(actorID.String(), b.allowedUsers) {
return false
}
}
@@ -37,7 +41,7 @@ func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
}
cfg, err := b.getRoomSettings(targetRoomID)
if err != nil {
b.Error(context.Background(), targetRoomID, "failed to retrieve settings: %v", err)
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
return false
}
@@ -50,7 +54,7 @@ func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
}
func (b *Bot) allowAdmin(actorID id.UserID, targetRoomID id.RoomID) bool {
return utils.Match(actorID.String(), b.allowedAdmins)
return mxidwc.Match(actorID.String(), b.allowedAdmins)
}
func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
@@ -60,9 +64,32 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
cfg, err := b.getRoomSettings(targetRoomID)
if err != nil {
b.Error(context.Background(), targetRoomID, "failed to retrieve settings: %v", err)
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
return false
}
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
}

View File

@@ -6,7 +6,6 @@ import (
"regexp"
"sync"
"git.sr.ht/~xn/cache/v2"
"github.com/getsentry/sentry-go"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/linkpearl"
@@ -25,8 +24,6 @@ type Bot struct {
allowedAdmins []*regexp.Regexp
commands commandList
rooms sync.Map
botcfg cache.Cache[botSettings]
cfg cache.Cache[roomSettings]
mta utils.MTA
log *logger.Logger
lp *linkpearl.Linkpearl
@@ -46,8 +43,6 @@ func New(
prefix: prefix,
domain: domain,
rooms: sync.Map{},
botcfg: cache.NewLRU[botSettings](1),
cfg: cache.NewLRU[roomSettings](1000),
log: log,
lp: lp,
mu: map[id.RoomID]*sync.Mutex{},
@@ -78,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...)
if hub := sentry.GetHubFromContext(ctx); hub != nil {
sentry.GetHubFromContext(ctx).CaptureException(err)
}
if roomID != "" {
b.SendError(ctx, roomID, err.Error())
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@@ -16,6 +17,7 @@ const (
commandStop = "stop"
commandSend = "send"
commandDKIM = "dkim"
commandCatchAll = botOptionCatchAll
commandUsers = botOptionUsers
commandDelete = "delete"
commandMailboxes = "mailboxes"
@@ -72,11 +74,16 @@ 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,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - enable email sending; `false` - disable email sending)",
"Get or set `%s` of the room (`true` - disable email sending; `false` - enable email sending)",
roomOptionNoSend,
),
sanitizer: utils.SanitizeBoolString,
@@ -91,6 +98,15 @@ func (b *Bot) initCommands() commandList {
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoRecipient,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide recipient; `false` - show recipient)",
roomOptionNoRecipient,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoSubject,
description: fmt.Sprintf(
@@ -127,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,
@@ -138,6 +194,11 @@ func (b *Bot) initCommands() commandList {
description: "Get DKIM signature",
allowed: b.allowAdmin,
},
{
key: commandCatchAll,
description: "Get or set catch-all mailbox",
allowed: b.allowAdmin,
},
{
key: commandMailboxes,
description: "Show the list of all mailboxes",
@@ -156,6 +217,11 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
if cmd == nil {
return
}
_, err := b.lp.GetClient().UserTyping(evt.RoomID, true, 30*time.Second)
if err != nil {
b.log.Error("cannot send typing notification: %v", err)
}
defer b.lp.GetClient().UserTyping(evt.RoomID, false, 30*time.Second) //nolint:errcheck
if !cmd.allowed(evt.Sender, evt.RoomID) {
b.SendNotice(ctx, evt.RoomID, "not allowed to do that, kupo")
@@ -173,6 +239,8 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
b.runDKIM(ctx, commandSlice)
case commandUsers:
b.runUsers(ctx, commandSlice)
case commandCatchAll:
b.runCatchAll(ctx, commandSlice)
case commandDelete:
b.runDelete(ctx, commandSlice)
case commandMailboxes:
@@ -287,6 +355,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)

View File

@@ -166,3 +166,41 @@ func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
"To reset the signature, send `%s dkim reset`",
signature, signature, b.prefix))
}
func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
if cfg.CatchAll() != "" {
msg.WriteString(cfg.CatchAll())
} else {
msg.WriteString("not set")
}
msg.WriteString("`\n\n")
msg.WriteString("Usage: `")
msg.WriteString(b.prefix)
msg.WriteString(" catch-all MAILBOX`")
msg.WriteString("where mailbox is valid and existing mailbox name\n")
b.SendNotice(ctx, evt.RoomID, msg.String())
return
}
mailbox := utils.Mailbox(commandSlice[1])
_, ok := b.GetMapping(mailbox)
if !ok {
b.SendError(ctx, evt.RoomID, "mailbox does not exist, kupo.")
return
}
cfg.Set(botOptionCatchAll, mailbox)
err := b.setBotSettings(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
return
}
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s@%s`.", mailbox, b.domain))
}

View File

@@ -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)
}

View File

@@ -31,12 +31,12 @@ func (b *Bot) SetMTA(mta utils.MTA) {
b.mta = mta
}
// GetMapping returns mapping of mailbox = room
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
v, ok := b.rooms.Load(mailbox)
if !ok {
return "", ok
}
roomID, ok := v.(id.RoomID)
if !ok {
return "", ok
@@ -45,9 +45,34 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
return roomID, ok
}
// GetMapping returns mapping of mailbox = room
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
roomID, ok := b.getMapping(mailbox)
if !ok {
catchAll := b.getBotSettings().CatchAll()
if catchAll == "" {
return roomID, ok
}
return b.getMapping(catchAll)
}
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) 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 +84,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 +110,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
}
@@ -165,20 +199,8 @@ func (b *Bot) Send2Email(ctx context.Context, to, subject, body string) error {
func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) {
for _, file := range files {
req := file.Convert()
resp, err := b.lp.GetClient().UploadMedia(req)
if err != nil {
err := b.lp.SendFile(roomID, req, file.MsgType, utils.RelatesTo(!noThreads, parentID))
b.Error(ctx, roomID, "cannot upload file %s: %v", req.FileName, err)
continue
}
_, err = b.lp.Send(roomID, &event.MessageEventContent{
MsgType: file.MsgType,
Body: req.FileName,
URL: resp.ContentURI.CUString(),
RelatesTo: utils.RelatesTo(!noThreads, parentID),
})
if err != nil {
b.Error(ctx, roomID, "cannot send uploaded file %s: %v", req.FileName, err)
}
}
}

View File

@@ -7,6 +7,11 @@ import (
func (b *Bot) handle(ctx context.Context) {
evt := eventFromContext(ctx)
err := b.lp.GetClient().MarkRead(evt.RoomID, evt.ID)
if err != nil {
b.log.Error("cannot send read receipt: %v", err)
}
content := evt.Content.AsMessage()
if content == nil {
b.Error(ctx, evt.RoomID, "cannot read message")

View File

@@ -12,6 +12,7 @@ const acBotSettingsKey = "cc.etke.postmoogle.config"
// bot options keys
const (
botOptionUsers = "users"
botOptionCatchAll = "catch-all"
botOptionDKIMSignature = "dkim.pub"
botOptionDKIMPrivateKey = "dkim.pem"
)
@@ -42,6 +43,11 @@ func (s botSettings) Users() []string {
return []string{value}
}
// CatchAll option
func (s botSettings) CatchAll() string {
return s.Get(botOptionCatchAll)
}
// DKIMSignature (DNS TXT record)
func (s botSettings) DKIMSignature() string {
return s.Get(botOptionDKIMSignature)
@@ -68,29 +74,17 @@ func (b *Bot) initBotUsers() ([]string, error) {
}
func (b *Bot) getBotSettings() botSettings {
cfg := b.botcfg.Get(acBotSettingsKey)
if cfg != nil {
return cfg
}
config := botSettings{}
err := b.lp.GetClient().GetAccountData(acBotSettingsKey, &config)
config, err := b.lp.GetAccountData(acBotSettingsKey)
if err != nil {
if strings.Contains(err.Error(), "M_NOT_FOUND") {
err = nil
} else {
b.log.Error("cannot get bot settings: %v", utils.UnwrapError(err))
}
}
if err == nil {
b.botcfg.Set(acBotSettingsKey, config)
if config == nil {
config = map[string]string{}
}
return config
}
func (b *Bot) setBotSettings(cfg botSettings) error {
b.botcfg.Set(acBotSettingsKey, cfg)
return utils.UnwrapError(b.lp.GetClient().SetAccountData(acBotSettingsKey, cfg))
return utils.UnwrapError(b.lp.SetAccountData(acBotSettingsKey, cfg))
}

View File

@@ -17,10 +17,17 @@ const (
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
@@ -43,6 +50,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))
}
@@ -51,6 +62,10 @@ func (s roomSettings) NoSender() bool {
return utils.Bool(s.Get(roomOptionNoSender))
}
func (s roomSettings) NoRecipient() bool {
return utils.Bool(s.Get(roomOptionNoRecipient))
}
func (s roomSettings) NoSubject() bool {
return utils.Bool(s.Get(roomOptionNoSubject))
}
@@ -67,11 +82,32 @@ 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{
HTML: !s.NoHTML(),
Sender: !s.NoSender(),
Recipient: !s.NoRecipient(),
Subject: !s.NoSubject(),
Threads: !s.NoThreads(),
@@ -83,30 +119,14 @@ func (s roomSettings) ContentOptions() *utils.ContentOptions {
}
func (b *Bot) getRoomSettings(roomID id.RoomID) (roomSettings, error) {
cfg := b.cfg.Get(roomID.String())
if cfg != nil {
return cfg, nil
}
config := roomSettings{}
err := b.lp.GetClient().GetRoomAccountData(roomID, acRoomSettingsKey, &config)
if err != nil {
if strings.Contains(err.Error(), "M_NOT_FOUND") {
// Suppress `M_NOT_FOUND (HTTP 404): Room account data not found` errors.
// Until some settings are explicitly set, we don't store any.
// In such cases, just return a default (empty) settings object.
err = nil
}
}
if err == nil {
b.cfg.Set(roomID.String(), config)
config, err := b.lp.GetRoomAccountData(roomID, acRoomSettingsKey)
if config == nil {
config = map[string]string{}
}
return config, utils.UnwrapError(err)
}
func (b *Bot) setRoomSettings(roomID id.RoomID, cfg roomSettings) error {
b.cfg.Set(roomID.String(), cfg)
return utils.UnwrapError(b.lp.GetClient().SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
return utils.UnwrapError(b.lp.SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
}

View File

@@ -3,10 +3,9 @@ package bot
import (
"context"
"gitlab.com/etke.cc/go/mxidwc"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"gitlab.com/etke.cc/postmoogle/utils"
)
func (b *Bot) initSync() {
@@ -32,7 +31,7 @@ func (b *Bot) initSync() {
// joinPermit is called by linkpearl when processing "invite" events and deciding if rooms should be auto-joined or not
func (b *Bot) joinPermit(evt *event.Event) bool {
if !utils.Match(evt.Sender.String(), b.allowedUsers) {
if !mxidwc.Match(evt.Sender.String(), b.allowedUsers) {
b.log.Debug("Rejecting room invitation from unallowed user: %s", evt.Sender)
return false
}

View File

@@ -76,6 +76,7 @@ func initBot(cfg *config.Config) {
DB: db,
Dialect: cfg.DB.Dialect,
NoEncryption: cfg.NoEncryption,
AccountDataSecret: cfg.DataSecret,
LPLogger: mxlog,
APILogger: logger.New("api.", cfg.LogLevel),
StoreLogger: logger.New("store.", cfg.LogLevel),

View File

@@ -18,6 +18,7 @@ func New() *Config {
Domain: env.String("domain", defaultConfig.Domain),
Port: env.String("port", defaultConfig.Port),
NoEncryption: env.Bool("noencryption"),
DataSecret: env.String("data.secret", defaultConfig.DataSecret),
MaxSize: env.Int("maxsize", defaultConfig.MaxSize),
StatusMsg: env.String("statusmsg", defaultConfig.StatusMsg),
Admins: env.Slice("admins"),

View File

@@ -14,6 +14,8 @@ type Config struct {
Port string
// RoomID of the admin room
LogLevel string
// DataSecret is account data secret key (password) to encrypt all account data values
DataSecret string
// NoEncryption disabled encryption support
NoEncryption bool
// Prefix for commands

26
go.mod
View File

@@ -2,30 +2,36 @@ module gitlab.com/etke.cc/postmoogle
go 1.18
// replace gitlab.com/etke.cc/linkpearl => ../linkpearl
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.14
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
gitlab.com/etke.cc/go/secgen v1.1.1
gitlab.com/etke.cc/linkpearl v0.0.0-20220831124140-598117f26c77
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b
maunium.net/go/mautrix v0.12.0
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
)
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
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
@@ -38,11 +44,11 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.4.12 // indirect
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 // indirect
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect
github.com/yuin/goldmark v1.4.13 // indirect
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b // indirect
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/maulogger/v2 v2.3.2 // indirect

43
go.sum
View File

@@ -1,5 +1,3 @@
git.sr.ht/~xn/cache/v2 v2.0.0 h1:aYzwGDyVIzjCl2yqcxZjprnu++Q3BmUQeK2agqvcQt8=
git.sr.ht/~xn/cache/v2 v2.0.0/go.mod h1:HIPSMiDudQ483tRDup586e0YZdwMySIZFWXMPwYMuV8=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
@@ -34,6 +32,8 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
@@ -50,8 +50,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@@ -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=
@@ -78,28 +80,35 @@ github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.4.12 h1:6hffw6vALvEDqJ19dOJvJKOoAOKe4NDaTqvd2sktGN0=
github.com/yuin/goldmark v1.4.12/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
gitlab.com/etke.cc/go/env v1.0.0 h1:J98BwzOuELnjsVPFvz5wa79L7IoRV9CmrS41xLYXtSw=
gitlab.com/etke.cc/go/env v1.0.0/go.mod h1:e1l4RM5MA1sc0R1w/RBDAESWRwgo5cOG9gx8BKUn2C4=
gitlab.com/etke.cc/go/logger v1.1.0 h1:Yngp/DDLmJ0jJNLvLXrfan5Gi5QV+r7z6kCczTv8t4U=
gitlab.com/etke.cc/go/logger v1.1.0/go.mod h1:8Vw5HFXlZQ5XeqvUs5zan+GnhrQyYtm/xe+yj8H/0zk=
gitlab.com/etke.cc/go/mxidwc v1.0.0 h1:6EAlJXvs3nU4RaMegYq6iFlyVvLw7JZYnZmNCGMYQP0=
gitlab.com/etke.cc/go/mxidwc v1.0.0/go.mod h1:E/0kh45SAN9+ntTG0cwkAEKdaPxzvxVmnjwivm9nmz8=
gitlab.com/etke.cc/go/secgen v1.1.1 h1:RmKOki725HIhWJHzPtAc9X4YvBneczndchpMgoDkE8w=
gitlab.com/etke.cc/go/secgen v1.1.1/go.mod h1:3pJqRGeWApzx7qXjABqz2o2SMCNpKSZao/gXVdasqE8=
gitlab.com/etke.cc/linkpearl v0.0.0-20220831124140-598117f26c77 h1:O9t4Sw/nu0JDUX+3KYjaqBi887opyNZ0imE+i2sV+q8=
gitlab.com/etke.cc/linkpearl v0.0.0-20220831124140-598117f26c77/go.mod h1:CqwzwxVogKG6gDWTPTen3NyWbTESg42jxoTfXXwDGKQ=
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=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0=
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4=
golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -107,8 +116,8 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY=
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 h1:AzgQNqF+FKwyQ5LbVrVqOcuuFB67N47F9+htZYH0wFM=
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -124,5 +133,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/mautrix v0.12.0 h1:jyT1TkJBIRJ7+OW7NhmMHmnEEBLsQe9ml+FYwSLhlaU=
maunium.net/go/mautrix v0.12.0/go.mod h1:hHvNi5iKVAiI2MAdAeXHtP4g9BvNEX2rsQpSF/x6Kx4=
maunium.net/go/mautrix v0.12.1 h1:MLfkWkpRIgUn7lueDSlPwYOeIuGF+NrAHW0hInIuVAw=
maunium.net/go/mautrix v0.12.1/go.mod h1:/jxQFIipObSsjZPH6o3xyUi8uoULz3Hfr/8p9loqpYE=

View File

@@ -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, incoming bool) *msasession {
return &msasession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
mta: m.mta,
from: from,
incoming: incoming,
log: m.log,
bot: m.bot,
domain: m.domain,
@@ -25,9 +32,17 @@ 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 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
}

View File

@@ -2,48 +2,68 @@ package smtp
import (
"context"
"errors"
"io"
"github.com/emersion/go-smtp"
"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"
)
// 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
incoming bool
to string
from string
}
func (s *msasession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
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)
s.to = to
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))
roomID, ok := s.bot.GetMapping(utils.Mailbox(to))
if !ok {
s.log.Debug("mapping for %s not found", to)
return smtp.ErrAuthRequired
}
s.to = to
validations := s.bot.GetIFOptions(roomID)
if !s.validate(validations) {
return smtp.ErrAuthRequired
}
}
s.log.Debug("mail to %s", to)
return nil
}
@@ -61,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)
@@ -80,7 +115,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() {}

View File

@@ -2,14 +2,11 @@ package smtp
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/smtp"
"strings"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/trysmtp"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
@@ -17,8 +14,10 @@ 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
GetIFOptions(id.RoomID) utils.IncomingFilteringOptions
Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) error
SetMTA(mta utils.MTA)
}
@@ -27,9 +26,6 @@ type mta struct {
log *logger.Logger
}
// SMTPAddrs priority list
var SMTPAddrs = []string{":25", ":587", ":465"}
func NewMTA(loglevel string) utils.MTA {
return &mta{
log: logger.New("smtp/mta.", loglevel),
@@ -38,22 +34,12 @@ func NewMTA(loglevel string) utils.MTA {
func (m *mta) Send(from, to, data string) error {
m.log.Debug("Sending email from %s to %s", from, to)
conn, err := m.connect(from, to)
conn, err := trysmtp.Connect(from, to)
if err != nil {
m.log.Error("cannot connect to SMTP server of %s: %v", to, err)
return err
}
defer conn.Close()
err = conn.Mail(from)
if err != nil {
m.log.Error("cannot call MAIL command: %v", err)
return err
}
err = conn.Rcpt(to)
if err != nil {
m.log.Error("cannot send RCPT command: %v", err)
return err
}
var w io.WriteCloser
w, err = conn.Data()
@@ -72,61 +58,3 @@ func (m *mta) Send(from, to, data string) error {
m.log.Debug("email has been sent")
return nil
}
func (m *mta) tryServer(localname, mxhost, addr string) *smtp.Client {
m.log.Debug("trying SMTP connection to %s%s", mxhost, addr)
conn, err := smtp.Dial(mxhost + addr)
if err != nil {
m.log.Warn("cannot connect to the %s%s: %v", mxhost, addr, err)
return nil
}
err = conn.Hello(localname)
if err != nil {
m.log.Warn("cannot call HELLO command of the %s%s: %v", mxhost, addr, err)
return nil
}
if ok, _ := conn.Extension("STARTTLS"); ok {
m.log.Debug("%s supports STARTTLS", mxhost)
config := &tls.Config{ServerName: mxhost}
err = conn.StartTLS(config)
if err != nil {
m.log.Warn("STARTTLS connection to the %s failed: %v", mxhost, err)
}
}
return conn
}
func (m *mta) connect(from, to string) (*smtp.Client, error) {
localname := strings.SplitN(from, "@", 2)[1]
hostname := strings.SplitN(to, "@", 2)[1]
m.log.Debug("performing MX lookup of %s", hostname)
mxs, err := net.LookupMX(hostname)
if err != nil {
m.log.Error("cannot perform MX lookup: %v", err)
return nil, err
}
for _, mx := range mxs {
for _, addr := range SMTPAddrs {
client := m.tryServer(localname, strings.TrimSuffix(mx.Host, "."), addr)
if client != nil {
return client, nil
}
}
}
// If there are no MX records, according to https://datatracker.ietf.org/doc/html/rfc5321#section-5.1,
// we're supposed to try talking directly to the host.
if len(mxs) == 0 {
for _, addr := range SMTPAddrs {
client := m.tryServer(localname, hostname, addr)
if client != nil {
return client, nil
}
}
}
return nil, fmt.Errorf("target SMTP server not found")
}

View File

@@ -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,7 +51,9 @@ 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" {
s.Debug = os.Stdout
}

View File

@@ -10,12 +10,12 @@ var ErrInvalidArgs = fmt.Errorf("invalid arguments")
// ParseSend parses "!pm send" command, returns to, subject, body, err
func ParseSend(commandSlice []string) (string, string, string, error) {
if len(commandSlice) < 3 {
message := strings.Join(commandSlice, " ")
lines := strings.Split(message, "\n")
if len(lines) < 3 {
return "", "", "", ErrInvalidArgs
}
message := strings.Join(commandSlice, " ")
lines := strings.Split(message, "\n")
commandSlice = strings.Split(lines[0], " ")
to := commandSlice[1]
subject := lines[1]

View File

@@ -4,6 +4,7 @@ import (
"crypto"
"crypto/x509"
"encoding/pem"
"net/mail"
"strings"
"time"
@@ -18,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
@@ -35,6 +45,7 @@ type Email struct {
type ContentOptions struct {
// On/Off
Sender bool
Recipient bool
Subject bool
HTML bool
Threads bool
@@ -46,6 +57,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,13 +88,26 @@ 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
if options.Sender {
text.WriteString("From: ")
text.WriteString(e.From)
text.WriteString("\n\n")
text.WriteString("\n")
}
if options.Recipient {
text.WriteString("To: ")
text.WriteString(e.To)
text.WriteString("\n")
}
if options.Subject {
text.WriteString("# ")
@@ -110,6 +140,13 @@ func (e *Email) Compose(privkey string) string {
var data strings.Builder
domain := strings.SplitN(e.From, "@", 2)[1]
data.WriteString("Content-Type: text/plain; charset=\"UTF-8\"")
data.WriteString("\r\n")
data.WriteString("Content-Transfer-Encoding: 8BIT")
data.WriteString("\r\n")
data.WriteString("From: ")
data.WriteString(e.From)
data.WriteString("\r\n")

View File

@@ -31,8 +31,8 @@ func NewFile(name string, content []byte) *File {
return file
}
func (f *File) Convert() mautrix.ReqUploadMedia {
return mautrix.ReqUploadMedia{
func (f *File) Convert() *mautrix.ReqUploadMedia {
return &mautrix.ReqUploadMedia{
ContentBytes: f.Content,
Content: bytes.NewReader(f.Content),
ContentLength: int64(f.Length),

View File

@@ -1,104 +0,0 @@
package utils
import (
"fmt"
"regexp"
"strings"
)
// WildcardMXIDsToRegexes converts a list of wildcard patterns to a list of regular expressions
func WildcardMXIDsToRegexes(wildCardPatterns []string) ([]*regexp.Regexp, error) {
regexPatterns := make([]*regexp.Regexp, len(wildCardPatterns))
for idx, wildCardPattern := range wildCardPatterns {
regex, err := parseMXIDWildcard(wildCardPattern)
if err != nil {
return nil, fmt.Errorf("failed to parse allowed user rule `%s`: %s", wildCardPattern, err)
}
regexPatterns[idx] = regex
}
return regexPatterns, nil
}
// Match tells if the given user id is allowed to use the bot, according to the given whitelist
func Match(userID string, allowed []*regexp.Regexp) bool {
for _, regex := range allowed {
if regex.MatchString(userID) {
return true
}
}
return false
}
// parseMXIDWildcard parses a user whitelisting wildcard rule and returns a regular expression which corresponds to it
//
// Example conversion: `@bot.*.something:*.example.com` -> `^bot\.([^:@]*)\.something:([^:@]*)\.example.com$`
// Example of recognized wildcard patterns: `@someone:example.com`, `@*:example.com`, `@bot.*:example.com`, `@someone:*`, `@someone:*.example.com`
//
// The `*` wildcard character is normally interpretted as "a number of literal characters or an empty string".
// Our implementation below matches this (yielding `([^:@])*`), which could provide a slightly suboptimal regex in these cases:
// - `@*:example.com` -> `^@([^:@])*:example\.com$`, although `^@([^:@])+:example\.com$` would be preferable
// - `@someone:*` -> `@someone:([^:@])*$`, although `@someone:([^:@])+$` would be preferable
// When it's a bare wildcard (`*`, instead of `*.example.com`) we likely prefer to yield a regex that matches **at least one character**.
// This probably doesn't matter because mxids that we'll match against are all valid and fully complete.
func parseMXIDWildcard(wildCardRule string) (*regexp.Regexp, error) {
if !strings.HasPrefix(wildCardRule, "@") {
return nil, fmt.Errorf("rules need to be fully-qualified, starting with a @")
}
remainingRule := wildCardRule[1:]
if strings.Contains(remainingRule, "@") {
return nil, fmt.Errorf("rules cannot contain more than one @")
}
parts := strings.Split(remainingRule, ":")
if len(parts) != 2 {
return nil, fmt.Errorf("expected exactly 2 parts in the rule, separated by `:`")
}
localPart := parts[0]
localPartPattern, err := getRegexPatternForPart(localPart)
if err != nil {
return nil, fmt.Errorf("failed to convert local part `%s` to regex: %s", localPart, err)
}
domainPart := parts[1]
domainPartPattern, err := getRegexPatternForPart(domainPart)
if err != nil {
return nil, fmt.Errorf("failed to convert domain part `%s` to regex: %s", domainPart, err)
}
finalPattern := fmt.Sprintf("^@%s:%s$", localPartPattern, domainPartPattern)
regex, err := regexp.Compile(finalPattern)
if err != nil {
return nil, fmt.Errorf("failed to compile regex `%s`: %s", finalPattern, err)
}
return regex, nil
}
func getRegexPatternForPart(part string) (string, error) {
if part == "" {
return "", fmt.Errorf("rejecting empty part")
}
var pattern strings.Builder
for _, rune := range part {
if rune == '*' {
// We match everything except for `:` and `@`, because that would be an invalid MXID anyway.
//
// If the whole part is `*` (only) instead of merely containing `*` within it,
// we may also consider replacing it with `([^:@]+)` (+, instead of *).
// See parseMXIDWildcard for notes about this.
pattern.WriteString("([^:@]*)")
continue
}
pattern.WriteString(regexp.QuoteMeta(string(rune)))
}
return pattern.String(), nil
}

View File

@@ -1,221 +0,0 @@
package utils
import "testing"
func TestRuleToRegex(t *testing.T) {
type testDataDefinition struct {
name string
checkedValue string
expectedResult string
expectedError bool
}
tests := []testDataDefinition{
{
name: "simple pattern without wildcards succeeds",
checkedValue: "@someone:example.com",
expectedResult: `^@someone:example\.com$`,
expectedError: false,
},
{
name: "pattern with wildcard as the whole local part succeeds",
checkedValue: "@*:example.com",
expectedResult: `^@([^:@]*):example\.com$`,
expectedError: false,
},
{
name: "pattern with wildcard within the local part succeeds",
checkedValue: "@bot.*.something:example.com",
expectedResult: `^@bot\.([^:@]*)\.something:example\.com$`,
expectedError: false,
},
{
name: "pattern with wildcard as the whole domain part succeeds",
checkedValue: "@someone:*",
expectedResult: `^@someone:([^:@]*)$`,
expectedError: false,
},
{
name: "pattern with wildcard within the domain part succeeds",
checkedValue: "@someone:*.organization.com",
expectedResult: `^@someone:([^:@]*)\.organization\.com$`,
expectedError: false,
},
{
name: "pattern with wildcard in both parts succeeds",
checkedValue: "@*:*",
expectedResult: `^@([^:@]*):([^:@]*)$`,
expectedError: false,
},
{
name: "pattern that does not appear fully-qualified fails",
checkedValue: "someone:example.com",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern that does not appear fully-qualified fails",
checkedValue: "@someone",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern with empty domain part fails",
checkedValue: "@someone:",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern with empty local part fails",
checkedValue: "@:example.com",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern with multiple @ fails",
checkedValue: "@someone@someone:example.com",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern with multiple : fails",
checkedValue: "@someone:someone:example.com",
expectedResult: ``,
expectedError: true,
},
}
for _, testData := range tests {
func(testData testDataDefinition) {
t.Run(testData.name, func(t *testing.T) {
actualResult, err := parseMXIDWildcard(testData.checkedValue)
if testData.expectedError {
if err != nil {
return
}
t.Errorf("expected an error, but did not get one")
}
if err != nil {
t.Errorf("did not expect an error, but got one: %s", err)
}
if actualResult.String() == testData.expectedResult {
return
}
t.Errorf(
"Expected `%s` to yield `%s`, not `%s`",
testData.checkedValue,
testData.expectedResult,
actualResult.String(),
)
})
}(testData)
}
}
func TestMatch(t *testing.T) {
type testDataDefinition struct {
name string
checkedValue string
allowedUsers []string
expectedResult bool
}
tests := []testDataDefinition{
{
name: "Empty allowed users allows no one",
checkedValue: "@someone:example.com",
allowedUsers: []string{},
expectedResult: false,
},
{
name: "Direct full mxid match is allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@someone:example.com"},
expectedResult: true,
},
{
name: "Direct full mxid match later on is allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@another:example.com", "@someone:example.com"},
expectedResult: true,
},
{
name: "No mxid match is not allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@another:example.com"},
expectedResult: false,
},
{
name: "mxid localpart only wildcard match is allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@*:example.com"},
expectedResult: true,
},
{
name: "mxid localpart with wildcard match is allowed",
checkedValue: "@bot.abc:example.com",
allowedUsers: []string{"@bot.*:example.com"},
expectedResult: true,
},
{
name: "mxid localpart with wildcard match is not allowed when it does not match",
checkedValue: "@bot.abc:example.com",
allowedUsers: []string{"@employee.*:example.com"},
expectedResult: false,
},
{
name: "mxid localpart wildcard for another domain is not allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@*:another.com"},
expectedResult: false,
},
{
name: "mxid domainpart with only wildcard match is allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@someone:*"},
expectedResult: true,
},
{
name: "mxid domainpart with wildcard match is allowed",
checkedValue: "@someone:example.organization.com",
allowedUsers: []string{"@someone:*.organization.com"},
expectedResult: true,
},
{
name: "mxid domainpart with wildcard match is not allowed when it does not match",
checkedValue: "@someone:example.another.com",
allowedUsers: []string{"@someone:*.organization.com"},
expectedResult: false,
},
}
for _, testData := range tests {
func(testData testDataDefinition) {
t.Run(testData.name, func(t *testing.T) {
allowedUserRegexes, err := WildcardMXIDsToRegexes(testData.allowedUsers)
if err != nil {
t.Error(err)
}
actualResult := Match(testData.checkedValue, allowedUserRegexes)
if actualResult == testData.expectedResult {
return
}
t.Errorf(
"Expected `%s` compared against `%v` to yield `%v`, not `%v`",
testData.checkedValue,
testData.allowedUsers,
testData.expectedResult,
actualResult,
)
})
}(testData)
}
}

View File

@@ -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, ",")
}