12 Commits

Author SHA1 Message Date
Aine
bebfa6df92 provide proper reply-to fallback by default 2023-09-28 15:17:55 +03:00
Aine
8bdd46fb32 make linter happy 2023-09-28 08:31:42 +03:00
Aine
da41bd31fb add !pm banlist:totals, fix notices on reactions 2023-09-28 08:30:37 +03:00
Aine
7fbb279830 use proper thread IDs on metadata save and error reporting 2023-09-27 15:07:55 +03:00
Aine
816db6f409 always reply to a specific message; moved matrix-related utils to the linkpearl; refactoring 2023-09-27 12:43:55 +03:00
Aine
e2f5f4c731 show banlist by day, in weekly chunks 2023-09-26 15:54:27 +03:00
Aine
6be4891165 subaddressing support, closes #61 2023-09-25 23:20:17 +03:00
Aine
18f1113d33 add pm banlist:auth and pm banlist:auto 2023-09-25 21:59:23 +03:00
Aine
b413e5871a partial complexity refactoring 2023-09-23 17:33:58 +03:00
Aine
8f3a74d46c add !pm autoreply 2023-09-23 17:09:18 +03:00
Aine
480c99cf79 add !pm signature option 2023-09-23 16:35:12 +03:00
Aine
74defa85e4 spam:add using emoji 2023-09-22 22:15:53 +03:00
34 changed files with 1327 additions and 291 deletions

View File

@@ -18,6 +18,7 @@ so you can use it to send emails from your apps and scripts as well.
- [x] Configuration in room's account data - [x] Configuration in room's account data
- [x] Receive emails to matrix rooms - [x] Receive emails to matrix rooms
- [x] Receive attachments - [x] Receive attachments
- [x] Subaddressing support
- [x] Catch-all mailbox - [x] Catch-all mailbox
- [x] Map email threads to matrix threads - [x] Map email threads to matrix threads
- [x] Multi-domain support - [x] Multi-domain support
@@ -35,6 +36,8 @@ so you can use it to send emails from your apps and scripts as well.
- [x] SMTP server (you can use Postmoogle as general purpose SMTP server to send emails from your scripts or apps) - [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, even to multiple email addresses at once - [x] Send a message to matrix room with special format to send a new email, even to multiple email addresses at once
- [x] Reply to matrix thread sends reply into email thread - [x] Reply to matrix thread sends reply into email thread
- [x] Email signatures
- [x] Email autoreply / autoresponder for new email threads
## Configuration ## Configuration
@@ -120,6 +123,8 @@ If you want to change them - check available options in the help message (`!pm h
> The following section is visible to the mailbox owners only > The following section is visible to the mailbox owners only
* **`!pm autoreply`** - Get or set autoreply of the room (markdown supported) that will be sent on any new incoming email thread
* **`!pm signature`** - Get or set signature of the room (markdown supported)
* **`!pm nosend`** - Get or set `nosend` of the room (`true` - disable email sending; `false` - enable email sending) * **`!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 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 nosender`** - Get or set `nosender` of the room (`true` - hide email sender; `false` - show email sender)
@@ -149,7 +154,7 @@ If you want to change them - check available options in the help message (`!pm h
> The following section is visible to the mailbox owners only > 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: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:add`** - Mark an email address (or pattern) as spam (or you can react to the email with emoji: ⛔️,🛑, or 🚫)
* **`!pm spam:remove`** - Unmark an email address (or pattern) as spam * **`!pm spam:remove`** - Unmark an email address (or pattern) as spam
* **`!pm spam:reset`** - Reset spamlist * **`!pm spam:reset`** - Reset spamlist
@@ -176,6 +181,9 @@ If you want to change them - check available options in the help message (`!pm h
* **`!pm greylist`** - Set automatic greylisting duration in minutes (0 - disabled) * **`!pm greylist`** - Set automatic greylisting duration in minutes (0 - disabled)
* **`!pm banlist`** - Enable/disable banlist and show current values * **`!pm banlist`** - Enable/disable banlist and show current values
* **`!pm banlist:auth`** - Enable/disable automatic banning for invalid auth credentials
* **`!pm banlist:auto`** - Enable/disable automatic banning for invalid emails
* **`!pm banlist:totals`** - List banlist totals only
* **`!pm banlist:add`** - Ban an IP * **`!pm banlist:add`** - Ban an IP
* **`!pm banlist:remove`** - Unban an IP * **`!pm banlist:remove`** - Unban an IP
* **`!pm banlist:reset`** - Reset banlist * **`!pm banlist:reset`** - Reset banlist

View File

@@ -7,7 +7,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/getsentry/sentry-go"
"github.com/raja/argon2pw" "github.com/raja/argon2pw"
"gitlab.com/etke.cc/go/mxidwc" "gitlab.com/etke.cc/go/mxidwc"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -43,7 +42,7 @@ func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
} }
cfg, err := b.cfg.GetRoom(targetRoomID) cfg, err := b.cfg.GetRoom(targetRoomID)
if err != nil { if err != nil {
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err) b.Error(context.Background(), "failed to retrieve settings: %v", err)
return false return false
} }
@@ -66,7 +65,7 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
cfg, err := b.cfg.GetRoom(targetRoomID) cfg, err := b.cfg.GetRoom(targetRoomID)
if err != nil { if err != nil {
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err) b.Error(context.Background(), "failed to retrieve settings: %v", err)
return false return false
} }
@@ -80,7 +79,7 @@ func (b *Bot) allowReply(actorID id.UserID, targetRoomID id.RoomID) bool {
cfg, err := b.cfg.GetRoom(targetRoomID) cfg, err := b.cfg.GetRoom(targetRoomID)
if err != nil { if err != nil {
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err) b.Error(context.Background(), "failed to retrieve settings: %v", err)
return false return false
} }
@@ -136,15 +135,59 @@ func (b *Bot) IsTrusted(addr net.Addr) bool {
return false return false
} }
// Ban an address // Ban an address automatically
func (b *Bot) Ban(addr net.Addr) { func (b *Bot) BanAuto(addr net.Addr) {
if !b.cfg.GetBot().BanlistEnabled() {
return
}
if !b.cfg.GetBot().BanlistAuto() {
return
}
if b.IsTrusted(addr) {
return
}
b.log.Debug().Str("addr", addr.String()).Msg("attempting to automatically ban")
banlist := b.cfg.GetBanlist()
banlist.Add(addr)
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update banlist")
}
}
// Ban an address for incorrect auth automatically
func (b *Bot) BanAuth(addr net.Addr) {
if !b.cfg.GetBot().BanlistEnabled() {
return
}
if !b.cfg.GetBot().BanlistAuth() {
return
}
if b.IsTrusted(addr) {
return
}
b.log.Debug().Str("addr", addr.String()).Msg("attempting to automatically ban")
banlist := b.cfg.GetBanlist()
banlist.Add(addr)
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update banlist")
}
}
// Ban an address manually
func (b *Bot) BanManually(addr net.Addr) {
if !b.cfg.GetBot().BanlistEnabled() { if !b.cfg.GetBot().BanlistEnabled() {
return return
} }
if b.IsTrusted(addr) { if b.IsTrusted(addr) {
return return
} }
b.log.Debug().Str("addr", addr.String()).Msg("attempting to ban") b.log.Debug().Str("addr", addr.String()).Msg("attempting to manually ban")
banlist := b.cfg.GetBanlist() banlist := b.cfg.GetBanlist()
banlist.Add(addr) banlist.Add(addr)
err := b.cfg.SetBanlist(banlist) err := b.cfg.SetBanlist(banlist)

View File

@@ -6,11 +6,9 @@ import (
"regexp" "regexp"
"sync" "sync"
"github.com/getsentry/sentry-go"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"gitlab.com/etke.cc/linkpearl" "gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/bot/config" "gitlab.com/etke.cc/postmoogle/bot/config"
@@ -93,28 +91,31 @@ func New(
} }
// Error message to the log and matrix room // Error message to the log and matrix room
func (b *Bot) Error(ctx context.Context, roomID id.RoomID, message string, args ...interface{}) { func (b *Bot) Error(ctx context.Context, message string, args ...interface{}) {
evt := eventFromContext(ctx)
threadID := threadIDFromContext(ctx)
if threadID == "" {
threadID = linkpearl.EventParent(evt.ID, evt.Content.AsMessage())
}
err := fmt.Errorf(message, args...) err := fmt.Errorf(message, args...)
b.log.Error().Err(err).Msg("something is wrong") b.log.Error().Err(err).Msg(err.Error())
if evt == nil {
if roomID != "" { return
b.SendError(ctx, roomID, err.Error())
} }
}
// SendError sends an error message to the matrix room var noThreads bool
func (b *Bot) SendError(ctx context.Context, roomID id.RoomID, message string) { cfg, cerr := b.cfg.GetRoom(evt.RoomID)
b.SendNotice(ctx, roomID, "ERROR: "+message) if cerr == nil {
} noThreads = cfg.NoThreads()
// SendNotice sends a notice message to the matrix room
func (b *Bot) SendNotice(ctx context.Context, roomID id.RoomID, message string) {
parsed := format.RenderMarkdown(message, true, true)
parsed.MsgType = event.MsgNotice
_, err := b.lp.Send(roomID, &event.Content{Parsed: &parsed})
if err != nil {
sentry.GetHubFromContext(ctx).CaptureException(err)
} }
var relatesTo *event.RelatesTo
if threadID != "" {
relatesTo = linkpearl.RelatesTo(threadID, noThreads)
}
b.lp.SendNotice(evt.RoomID, "ERROR: "+err.Error(), relatesTo)
} }
// Start performs matrix /sync // Start performs matrix /sync

View File

@@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
"gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -30,6 +31,9 @@ const (
commandSpamlistReset = "spam:reset" commandSpamlistReset = "spam:reset"
commandDelete = "delete" commandDelete = "delete"
commandBanlist = "banlist" commandBanlist = "banlist"
commandBanlistTotals = "banlist:totals"
commandBanlistAuto = "banlist:auto"
commandBanlistAuth = "banlist:auth"
commandBanlistAdd = "banlist:add" commandBanlistAdd = "banlist:add"
commandBanlistRemove = "banlist:remove" commandBanlistRemove = "banlist:remove"
commandBanlistReset = "banlist:reset" commandBanlistReset = "banlist:reset"
@@ -99,6 +103,18 @@ func (b *Bot) initCommands() commandList {
allowed: b.allowOwner, allowed: b.allowOwner,
}, },
{allowed: b.allowOwner, description: "mailbox options"}, // delimiter {allowed: b.allowOwner, description: "mailbox options"}, // delimiter
{
key: config.RoomAutoreply,
description: "Get or set autoreply of the room (markdown supported) that will be send for any new incoming email thread",
sanitizer: func(s string) string { return s },
allowed: b.allowOwner,
},
{
key: config.RoomSignature,
description: "Get or set signature of the room (markdown supported)",
sanitizer: func(s string) string { return s },
allowed: b.allowOwner,
},
{ {
key: config.RoomNoSend, key: config.RoomNoSend,
description: fmt.Sprintf( description: fmt.Sprintf(
@@ -223,7 +239,7 @@ func (b *Bot) initCommands() commandList {
}, },
{ {
key: commandSpamlistAdd, key: commandSpamlistAdd,
description: "Mark an email address (or pattern) as spam", description: "Mark an email address (or pattern) as spam (or you can react to the email with emoji: ⛔️,🛑, or 🚫)",
allowed: b.allowOwner, allowed: b.allowOwner,
}, },
{ {
@@ -290,6 +306,21 @@ func (b *Bot) initCommands() commandList {
description: "Enable/disable banlist and show current values", description: "Enable/disable banlist and show current values",
allowed: b.allowAdmin, allowed: b.allowAdmin,
}, },
{
key: commandBanlistAuth,
description: "Enable/disable automatic banning of IP addresses when they try to auth with invalid credentials",
allowed: b.allowAdmin,
},
{
key: commandBanlistAuto,
description: "Enable/disable automatic banning of IP addresses when they try to send invalid emails",
allowed: b.allowAdmin,
},
{
key: commandBanlistTotals,
description: "List banlist totals only",
allowed: b.allowAdmin,
},
{ {
key: commandBanlistAdd, key: commandBanlistAdd,
description: "Ban an IP", description: "Ban an IP",
@@ -317,7 +348,7 @@ func (b *Bot) handle(ctx context.Context) {
content := evt.Content.AsMessage() content := evt.Content.AsMessage()
if content == nil { if content == nil {
b.Error(ctx, evt.RoomID, "cannot read message") b.Error(ctx, "cannot read message")
return return
} }
// ignore notices // ignore notices
@@ -327,7 +358,7 @@ func (b *Bot) handle(ctx context.Context) {
message := strings.TrimSpace(content.Body) message := strings.TrimSpace(content.Body)
commandSlice := b.parseCommand(message, true) commandSlice := b.parseCommand(message, true)
if commandSlice == nil { if commandSlice == nil {
if utils.EventParent("", content) != "" { if linkpearl.EventParent("", content) != "" {
b.SendEmailReply(ctx) b.SendEmailReply(ctx)
} }
return return
@@ -344,7 +375,7 @@ func (b *Bot) handle(ctx context.Context) {
defer b.lp.GetClient().UserTyping(evt.RoomID, false, 30*time.Second) //nolint:errcheck defer b.lp.GetClient().UserTyping(evt.RoomID, false, 30*time.Second) //nolint:errcheck
if !cmd.allowed(evt.Sender, evt.RoomID) { if !cmd.allowed(evt.Sender, evt.RoomID) {
b.SendNotice(ctx, evt.RoomID, "not allowed to do that, kupo") b.lp.SendNotice(evt.RoomID, "not allowed to do that, kupo")
return return
} }
@@ -375,6 +406,12 @@ func (b *Bot) handle(ctx context.Context) {
b.runGreylist(ctx, commandSlice) b.runGreylist(ctx, commandSlice)
case commandBanlist: case commandBanlist:
b.runBanlist(ctx, commandSlice) b.runBanlist(ctx, commandSlice)
case commandBanlistAuth:
b.runBanlistAuth(ctx, commandSlice)
case commandBanlistAuto:
b.runBanlistAuto(ctx, commandSlice)
case commandBanlistTotals:
b.runBanlistTotals(ctx)
case commandBanlistAdd: case commandBanlistAdd:
b.runBanlistAdd(ctx, commandSlice) b.runBanlistAdd(ctx, commandSlice)
case commandBanlistRemove: case commandBanlistRemove:
@@ -405,7 +442,7 @@ func (b *Bot) parseCommand(message string, toLower bool) []string {
return strings.Split(strings.TrimSpace(message), " ") return strings.Split(strings.TrimSpace(message), " ")
} }
func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) { func (b *Bot) sendIntroduction(roomID id.RoomID) {
var msg strings.Builder var msg strings.Builder
msg.WriteString("Hello, kupo!\n\n") msg.WriteString("Hello, kupo!\n\n")
@@ -421,7 +458,7 @@ func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
msg.WriteString(utils.EmailsList("SOME_INBOX", "")) msg.WriteString(utils.EmailsList("SOME_INBOX", ""))
msg.WriteString("` and have them appear in this room.") msg.WriteString("` and have them appear in this room.")
b.SendNotice(ctx, roomID, msg.String()) b.lp.SendNotice(roomID, msg.String())
} }
func (b *Bot) getHelpValue(cfg config.Room, cmd command) string { func (b *Bot) getHelpValue(cfg config.Room, cmd command) string {
@@ -481,40 +518,19 @@ func (b *Bot) sendHelp(ctx context.Context) {
msg.WriteString("\n") msg.WriteString("\n")
} }
b.SendNotice(ctx, evt.RoomID, msg.String()) b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
} }
//nolint:gocognit // TODO
func (b *Bot) runSend(ctx context.Context) { func (b *Bot) runSend(ctx context.Context) {
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
if !b.allowSend(evt.Sender, evt.RoomID) { to, subject, body, shouldSend := b.getSendDetails(ctx)
return if !shouldSend {
}
commandSlice := b.parseCommand(evt.Content.AsMessage().Body, false)
to, subject, body, err := utils.ParseSend(commandSlice)
if err == utils.ErrInvalidArgs {
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf(
"Usage:\n"+
"```\n"+
"%s send someone@example.com\n"+
"Subject goes here on a line of its own\n"+
"Email content goes here\n"+
"on as many lines\n"+
"as you want.\n"+
"```",
b.prefix))
return return
} }
cfg, err := b.cfg.GetRoom(evt.RoomID) cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve room settings: %v", err) b.Error(ctx, "failed to retrieve room settings: %v", err)
return
}
mailbox := cfg.Mailbox()
if mailbox == "" {
b.SendNotice(ctx, evt.RoomID, "mailbox is not configured, kupo")
return return
} }
@@ -524,10 +540,60 @@ func (b *Bot) runSend(ctx context.Context) {
} }
tos := strings.Split(to, ",") tos := strings.Split(to, ",")
b.runSendCommand(ctx, cfg, tos, subject, body, htmlBody)
}
func (b *Bot) getSendDetails(ctx context.Context) (string, string, string, bool) {
evt := eventFromContext(ctx)
if !b.allowSend(evt.Sender, evt.RoomID) {
return "", "", "", false
}
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "failed to retrieve room settings: %v", err)
return "", "", "", false
}
commandSlice := b.parseCommand(evt.Content.AsMessage().Body, false)
to, subject, body, err := utils.ParseSend(commandSlice)
if err == utils.ErrInvalidArgs {
b.lp.SendNotice(evt.RoomID, fmt.Sprintf(
"Usage:\n"+
"```\n"+
"%s send someone@example.com\n"+
"Subject goes here on a line of its own\n"+
"Email content goes here\n"+
"on as many lines\n"+
"as you want.\n"+
"```",
b.prefix),
linkpearl.RelatesTo(evt.ID, cfg.NoThreads()),
)
return "", "", "", false
}
mailbox := cfg.Mailbox()
if mailbox == "" {
b.lp.SendNotice(evt.RoomID, "mailbox is not configured, kupo", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return "", "", "", false
}
signature := cfg.Signature()
if signature != "" {
body += "\n\n---\n" + signature
}
return to, subject, body, true
}
func (b *Bot) runSendCommand(ctx context.Context, cfg config.Room, tos []string, subject, body, htmlBody string) {
evt := eventFromContext(ctx)
// validate first // validate first
for _, to := range tos { for _, to := range tos {
if !email.AddressValid(to) { if !email.AddressValid(to) {
b.Error(ctx, evt.RoomID, "email address is not valid") b.Error(ctx, "email address is not valid")
return return
} }
} }
@@ -536,29 +602,29 @@ func (b *Bot) runSend(ctx context.Context) {
defer b.mu.Unlock(evt.RoomID.String()) defer b.mu.Unlock(evt.RoomID.String())
domain := utils.SanitizeDomain(cfg.Domain()) domain := utils.SanitizeDomain(cfg.Domain())
from := mailbox + "@" + domain from := cfg.Mailbox() + "@" + domain
ID := email.MessageID(evt.ID, domain) ID := email.MessageID(evt.ID, domain)
for _, to := range tos { for _, to := range tos {
recipients := []string{to} recipients := []string{to}
eml := email.New(ID, "", " "+ID, subject, from, to, to, "", body, htmlBody, nil, nil) eml := email.New(ID, "", " "+ID, subject, from, to, to, "", body, htmlBody, nil, nil)
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey()) data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
if data == "" { if data == "" {
b.SendError(ctx, evt.RoomID, "email body is empty") b.lp.SendNotice(evt.RoomID, "email body is empty", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return return
} }
queued, err := b.Sendmail(evt.ID, from, to, data) queued, err := b.Sendmail(evt.ID, from, to, data)
if queued { if queued {
b.log.Error().Err(err).Msg("cannot send email") b.log.Warn().Err(err).Msg("email has been queued")
b.saveSentMetadata(ctx, queued, evt.ID, recipients, eml, cfg) b.saveSentMetadata(ctx, queued, evt.ID, recipients, eml, cfg)
continue continue
} }
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err) b.Error(ctx, "cannot send email to %s: %v", to, err)
continue continue
} }
b.saveSentMetadata(ctx, false, evt.ID, recipients, eml, cfg) b.saveSentMetadata(ctx, false, evt.ID, recipients, eml, cfg)
} }
if len(tos) > 1 { if len(tos) > 1 {
b.SendNotice(ctx, evt.RoomID, "All emails were sent.") b.lp.SendNotice(evt.RoomID, "All emails were sent.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
} }
} }

View File

@@ -7,8 +7,10 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"gitlab.com/etke.cc/go/secgen" "gitlab.com/etke.cc/go/secgen"
"gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/bot/config" "gitlab.com/etke.cc/postmoogle/bot/config"
@@ -47,7 +49,7 @@ func (b *Bot) sendMailboxes(ctx context.Context) {
sort.Strings(slice) sort.Strings(slice)
if len(slice) == 0 { if len(slice) == 0 {
b.SendNotice(ctx, evt.RoomID, "No mailboxes are managed by the bot so far, kupo!") b.lp.SendNotice(evt.RoomID, "No mailboxes are managed by the bot so far, kupo!", linkpearl.RelatesTo(evt.ID))
return return
} }
@@ -62,20 +64,20 @@ func (b *Bot) sendMailboxes(ctx context.Context) {
msg.WriteString("\n") msg.WriteString("\n")
} }
b.SendNotice(ctx, evt.RoomID, msg.String()) b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
} }
func (b *Bot) runDelete(ctx context.Context, commandSlice []string) { func (b *Bot) runDelete(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
if len(commandSlice) < 2 { if len(commandSlice) < 2 {
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Usage: `%s delete MAILBOX`", b.prefix)) b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Usage: `%s delete MAILBOX`", b.prefix), linkpearl.RelatesTo(evt.ID))
return return
} }
mailbox := utils.Mailbox(commandSlice[1]) mailbox := utils.Mailbox(commandSlice[1])
v, ok := b.rooms.Load(mailbox) v, ok := b.rooms.Load(mailbox)
if v == nil || !ok { if v == nil || !ok {
b.SendError(ctx, evt.RoomID, "mailbox does not exists, kupo") b.lp.SendNotice(evt.RoomID, "mailbox does not exists, kupo", linkpearl.RelatesTo(evt.ID))
return return
} }
roomID := v.(id.RoomID) roomID := v.(id.RoomID)
@@ -83,11 +85,11 @@ func (b *Bot) runDelete(ctx context.Context, commandSlice []string) {
b.rooms.Delete(mailbox) b.rooms.Delete(mailbox)
err := b.cfg.SetRoom(roomID, config.Room{}) err := b.cfg.SetRoom(roomID, config.Room{})
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err) b.Error(ctx, "cannot update settings: %v", err)
return return
} }
b.SendNotice(ctx, evt.RoomID, "mailbox has been deleted") b.lp.SendNotice(evt.RoomID, "mailbox has been deleted", linkpearl.RelatesTo(evt.ID))
} }
func (b *Bot) runUsers(ctx context.Context, commandSlice []string) { func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
@@ -107,19 +109,19 @@ func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
msg.WriteString("where each pattern is like `@someone:example.com`, ") msg.WriteString("where each pattern is like `@someone:example.com`, ")
msg.WriteString("`@bot.*:example.com`, `@*:another.com`, or `@*:*`\n") msg.WriteString("`@bot.*:example.com`, `@*:another.com`, or `@*:*`\n")
b.SendNotice(ctx, evt.RoomID, msg.String()) b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return return
} }
_, homeserver, err := b.lp.GetClient().UserID.Parse() _, homeserver, err := b.lp.GetClient().UserID.Parse()
if err != nil { if err != nil {
b.SendError(ctx, evt.RoomID, fmt.Sprintf("invalid userID: %v", err)) b.lp.SendNotice(evt.RoomID, fmt.Sprintf("invalid userID: %v", err), linkpearl.RelatesTo(evt.ID))
} }
patterns := commandSlice[1:] patterns := commandSlice[1:]
allowedUsers, err := parseMXIDpatterns(patterns, "@*:"+homeserver) allowedUsers, err := parseMXIDpatterns(patterns, "@*:"+homeserver)
if err != nil { if err != nil {
b.SendError(ctx, evt.RoomID, fmt.Sprintf("invalid patterns: %v", err)) b.lp.SendNotice(evt.RoomID, fmt.Sprintf("invalid patterns: %v", err), linkpearl.RelatesTo(evt.ID))
return return
} }
@@ -127,10 +129,10 @@ func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
err = b.cfg.SetBot(cfg) err = b.cfg.SetBot(cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err) b.Error(ctx, "cannot set bot config: %v", err)
} }
b.allowedUsers = allowedUsers b.allowedUsers = allowedUsers
b.SendNotice(ctx, evt.RoomID, "allowed users updated") b.lp.SendNotice(evt.RoomID, "allowed users updated", linkpearl.RelatesTo(evt.ID))
} }
func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) { func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
@@ -147,25 +149,27 @@ func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
var derr error var derr error
signature, private, derr = secgen.DKIM() signature, private, derr = secgen.DKIM()
if derr != nil { if derr != nil {
b.Error(ctx, evt.RoomID, "cannot generate DKIM signature: %v", derr) b.Error(ctx, "cannot generate DKIM signature: %v", derr)
return return
} }
cfg.Set(config.BotDKIMSignature, signature) cfg.Set(config.BotDKIMSignature, signature)
cfg.Set(config.BotDKIMPrivateKey, private) cfg.Set(config.BotDKIMPrivateKey, private)
err := b.cfg.SetBot(cfg) err := b.cfg.SetBot(cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err) b.Error(ctx, "cannot save bot options: %v", err)
return return
} }
} }
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf( b.lp.SendNotice(evt.RoomID, fmt.Sprintf(
"DKIM signature is: `%s`.\n"+ "DKIM signature is: `%s`.\n"+
"You need to add it to DNS records of all domains added to postmoogle (if not already):\n"+ "You need to add it to DNS records of all domains added to postmoogle (if not already):\n"+
"Add new DNS record with type = `TXT`, key (subdomain/from): `postmoogle._domainkey` and value (to):\n ```\n%s\n```\n"+ "Add new DNS record with type = `TXT`, key (subdomain/from): `postmoogle._domainkey` and value (to):\n ```\n%s\n```\n"+
"Without that record other email servers may reject your emails as spam, kupo.\n"+ "Without that record other email servers may reject your emails as spam, kupo.\n"+
"To reset the signature, send `%s dkim reset`", "To reset the signature, send `%s dkim reset`",
signature, signature, b.prefix)) signature, signature, b.prefix),
linkpearl.RelatesTo(evt.ID),
)
} }
func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) { func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
@@ -188,25 +192,25 @@ func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
msg.WriteString(" catch-all MAILBOX`") msg.WriteString(" catch-all MAILBOX`")
msg.WriteString("where mailbox is valid and existing mailbox name\n") msg.WriteString("where mailbox is valid and existing mailbox name\n")
b.SendNotice(ctx, evt.RoomID, msg.String()) b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return return
} }
mailbox := utils.Mailbox(commandSlice[1]) mailbox := utils.Mailbox(commandSlice[1])
_, ok := b.GetMapping(mailbox) _, ok := b.GetMapping(mailbox)
if !ok { if !ok {
b.SendError(ctx, evt.RoomID, "mailbox does not exist, kupo.") b.lp.SendNotice(evt.RoomID, "mailbox does not exist, kupo.", linkpearl.RelatesTo(evt.ID))
return return
} }
cfg.Set(config.BotCatchAll, mailbox) cfg.Set(config.BotCatchAll, mailbox)
err := b.cfg.SetBot(cfg) err := b.cfg.SetBot(cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err) b.Error(ctx, "cannot save bot options: %v", err)
return return
} }
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s` (%s).", mailbox, utils.EmailsList(mailbox, ""))) b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s` (%s).", mailbox, utils.EmailsList(mailbox, "")), linkpearl.RelatesTo(evt.ID))
} }
func (b *Bot) runAdminRoom(ctx context.Context, commandSlice []string) { func (b *Bot) runAdminRoom(ctx context.Context, commandSlice []string) {
@@ -226,7 +230,7 @@ func (b *Bot) runAdminRoom(ctx context.Context, commandSlice []string) {
msg.WriteString(" adminroom ROOM_ID`") msg.WriteString(" adminroom ROOM_ID`")
msg.WriteString("where ROOM_ID is valid and existing matrix room id\n") msg.WriteString("where ROOM_ID is valid and existing matrix room id\n")
b.SendNotice(ctx, evt.RoomID, msg.String()) b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return return
} }
@@ -234,13 +238,13 @@ func (b *Bot) runAdminRoom(ctx context.Context, commandSlice []string) {
cfg.Set(config.BotAdminRoom, roomID) cfg.Set(config.BotAdminRoom, roomID)
err := b.cfg.SetBot(cfg) err := b.cfg.SetBot(cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err) b.Error(ctx, "cannot save bot options: %v", err)
return return
} }
b.adminRooms = append([]id.RoomID{id.RoomID(roomID)}, b.adminRooms...) // make it the first room in list on the fly 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)) b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Admin Room is set to: `%s`.", roomID), linkpearl.RelatesTo(evt.ID))
} }
func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) { func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) {
@@ -271,7 +275,7 @@ func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) {
msg.WriteString("where `MIN` is duration in minutes for automatic greylisting\n") msg.WriteString("where `MIN` is duration in minutes for automatic greylisting\n")
} }
b.SendNotice(ctx, roomID, msg.String()) b.lp.SendNotice(roomID, msg.String(), linkpearl.RelatesTo(eventFromContext(ctx).ID))
} }
func (b *Bot) runGreylist(ctx context.Context, commandSlice []string) { func (b *Bot) runGreylist(ctx context.Context, commandSlice []string) {
@@ -285,9 +289,9 @@ func (b *Bot) runGreylist(ctx context.Context, commandSlice []string) {
cfg.Set(config.BotGreylist, value) cfg.Set(config.BotGreylist, value)
err := b.cfg.SetBot(cfg) err := b.cfg.SetBot(cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err) b.Error(ctx, "cannot set bot config: %v", err)
} }
b.SendNotice(ctx, evt.RoomID, "greylist duration has been updated") b.lp.SendNotice(evt.RoomID, "greylist duration has been updated", linkpearl.RelatesTo(evt.ID))
} }
func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) { func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) {
@@ -302,9 +306,7 @@ func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) {
msg.WriteString(cfg.Get(config.BotBanlistEnabled)) msg.WriteString(cfg.Get(config.BotBanlistEnabled))
msg.WriteString("`, total: ") msg.WriteString("`, total: ")
msg.WriteString(strconv.Itoa(size)) msg.WriteString(strconv.Itoa(size))
msg.WriteString(" hosts (`") msg.WriteString("\n\n")
msg.WriteString(strings.Join(banlist.Slice(), "`, `"))
msg.WriteString("`)\n\n")
} }
if !cfg.BanlistEnabled() { if !cfg.BanlistEnabled() {
msg.WriteString("To enable banlist, send `") msg.WriteString("To enable banlist, send `")
@@ -314,18 +316,92 @@ func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) {
msg.WriteString("To ban somebody: `") msg.WriteString("To ban somebody: `")
msg.WriteString(b.prefix) msg.WriteString(b.prefix)
msg.WriteString(" banlist:add IP1 IP2 IP3...`") msg.WriteString(" banlist:add IP1 IP2 IP3...`")
msg.WriteString("where each ip is IPv4 or IPv6\n") msg.WriteString("where each ip is IPv4 or IPv6\n\n")
msg.WriteString("You can find current banlist values below:\n")
b.SendNotice(ctx, evt.RoomID, msg.String()) b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
b.addBanlistTimeline(ctx, false)
return return
} }
value := utils.SanitizeBoolString(commandSlice[1]) value := utils.SanitizeBoolString(commandSlice[1])
cfg.Set(config.BotBanlistEnabled, value) cfg.Set(config.BotBanlistEnabled, value)
err := b.cfg.SetBot(cfg) err := b.cfg.SetBot(cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err) b.Error(ctx, "cannot set bot config: %v", err)
} }
b.SendNotice(ctx, evt.RoomID, "banlist has been updated") b.lp.SendNotice(evt.RoomID, "banlist has been updated", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runBanlistTotals(ctx context.Context) {
evt := eventFromContext(ctx)
banlist := b.cfg.GetBanlist()
var msg strings.Builder
size := len(banlist)
if size == 0 {
b.lp.SendNotice(evt.RoomID, "banlist is empty, kupo.", linkpearl.RelatesTo(evt.ID))
return
}
msg.WriteString("Total: ")
msg.WriteString(strconv.Itoa(size))
msg.WriteString(" hosts banned\n\n")
msg.WriteString("You can find daily totals below:\n")
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
b.addBanlistTimeline(ctx, true)
}
func (b *Bot) runBanlistAuth(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
msg.WriteString(cfg.Get(config.BotBanlistAuth))
msg.WriteString("`\n\n")
if !cfg.BanlistAuth() {
msg.WriteString("To enable automatic banning for invalid credentials, send `")
msg.WriteString(b.prefix)
msg.WriteString(" banlist:auth true` (banlist itself must be enabled!)\n\n")
}
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return
}
value := utils.SanitizeBoolString(commandSlice[1])
cfg.Set(config.BotBanlistAuth, value)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, "cannot set bot config: %v", err)
}
b.lp.SendNotice(evt.RoomID, "auth banning has been updated", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runBanlistAuto(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
msg.WriteString(cfg.Get(config.BotBanlistAuto))
msg.WriteString("`\n\n")
if !cfg.BanlistAuto() {
msg.WriteString("To enable automatic banning for invalid emails, send `")
msg.WriteString(b.prefix)
msg.WriteString(" banlist:auto true` (banlist itself must be enabled!)\n\n")
}
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return
}
value := utils.SanitizeBoolString(commandSlice[1])
cfg.Set(config.BotBanlistAuto, value)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, "cannot set bot config: %v", err)
}
b.lp.SendNotice(evt.RoomID, "auto banning has been updated", linkpearl.RelatesTo(evt.ID))
} }
func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) { func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) {
@@ -335,7 +411,7 @@ func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) {
return return
} }
if !b.cfg.GetBot().BanlistEnabled() { if !b.cfg.GetBot().BanlistEnabled() {
b.SendNotice(ctx, evt.RoomID, "banlist is disabled, you have to enable it first, kupo") b.lp.SendNotice(evt.RoomID, "banlist is disabled, you have to enable it first, kupo", linkpearl.RelatesTo(evt.ID))
return return
} }
banlist := b.cfg.GetBanlist() banlist := b.cfg.GetBanlist()
@@ -344,7 +420,7 @@ func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) {
for _, ip := range ips { for _, ip := range ips {
addr, err := net.ResolveIPAddr("ip", ip) addr, err := net.ResolveIPAddr("ip", ip)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot add %s to banlist: %v", ip, err) b.Error(ctx, "cannot add %s to banlist: %v", ip, err)
return return
} }
banlist.Add(addr) banlist.Add(addr)
@@ -352,11 +428,11 @@ func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) {
err := b.cfg.SetBanlist(banlist) err := b.cfg.SetBanlist(banlist)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err) b.Error(ctx, "cannot set banlist: %v", err)
return return
} }
b.SendNotice(ctx, evt.RoomID, "banlist has been updated, kupo") b.lp.SendNotice(evt.RoomID, "banlist has been updated, kupo", linkpearl.RelatesTo(evt.ID))
} }
func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) { func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
@@ -366,7 +442,7 @@ func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
return return
} }
if !b.cfg.GetBot().BanlistEnabled() { if !b.cfg.GetBot().BanlistEnabled() {
b.SendNotice(ctx, evt.RoomID, "banlist is disabled, you have to enable it first, kupo") b.lp.SendNotice(evt.RoomID, "banlist is disabled, you have to enable it first, kupo", linkpearl.RelatesTo(evt.ID))
return return
} }
banlist := b.cfg.GetBanlist() banlist := b.cfg.GetBanlist()
@@ -375,7 +451,7 @@ func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
for _, ip := range ips { for _, ip := range ips {
addr, err := net.ResolveIPAddr("ip", ip) addr, err := net.ResolveIPAddr("ip", ip)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot remove %s from banlist: %v", ip, err) b.Error(ctx, "cannot remove %s from banlist: %v", ip, err)
return return
} }
banlist.Remove(addr) banlist.Remove(addr)
@@ -383,25 +459,63 @@ func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
err := b.cfg.SetBanlist(banlist) err := b.cfg.SetBanlist(banlist)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err) b.Error(ctx, "cannot set banlist: %v", err)
return return
} }
b.SendNotice(ctx, evt.RoomID, "banlist has been updated, kupo") b.lp.SendNotice(evt.RoomID, "banlist has been updated, kupo", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) addBanlistTimeline(ctx context.Context, onlyTotals bool) {
evt := eventFromContext(ctx)
banlist := b.cfg.GetBanlist()
timeline := map[string][]string{}
for ip, ts := range banlist {
key := "???"
date, _ := time.ParseInLocation(time.RFC1123Z, ts, time.UTC) //nolint:errcheck // stored in that format
if !date.IsZero() {
key = date.Truncate(24 * time.Hour).Format(time.DateOnly)
}
if _, ok := timeline[key]; !ok {
timeline[key] = []string{}
}
timeline[key] = append(timeline[key], ip)
}
keys := utils.MapKeys(timeline)
for _, chunk := range utils.Chunks(keys, 7) {
var txt strings.Builder
for _, day := range chunk {
data := timeline[day]
sort.Strings(data)
txt.WriteString("* `")
txt.WriteString(day)
if onlyTotals {
txt.WriteString("` ")
txt.WriteString(strconv.Itoa(len(data)))
txt.WriteString(" hosts banned\n")
continue
}
txt.WriteString("` `")
txt.WriteString(strings.Join(data, "`, `"))
txt.WriteString("`\n")
}
b.lp.SendNotice(evt.RoomID, txt.String(), linkpearl.RelatesTo(evt.ID))
}
} }
func (b *Bot) runBanlistReset(ctx context.Context) { func (b *Bot) runBanlistReset(ctx context.Context) {
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
if !b.cfg.GetBot().BanlistEnabled() { if !b.cfg.GetBot().BanlistEnabled() {
b.SendNotice(ctx, evt.RoomID, "banlist is disabled, you have to enable it first, kupo") b.lp.SendNotice(evt.RoomID, "banlist is disabled, you have to enable it first, kupo", linkpearl.RelatesTo(evt.ID))
return return
} }
err := b.cfg.SetBanlist(config.List{}) err := b.cfg.SetBanlist(config.List{})
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err) b.Error(ctx, "cannot set banlist: %v", err)
return return
} }
b.SendNotice(ctx, evt.RoomID, "banlist has been reset, kupo") b.lp.SendNotice(evt.RoomID, "banlist has been reset, kupo", linkpearl.RelatesTo(evt.ID))
} }

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/raja/argon2pw" "github.com/raja/argon2pw"
"gitlab.com/etke.cc/linkpearl"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
"gitlab.com/etke.cc/postmoogle/bot/config" "gitlab.com/etke.cc/postmoogle/bot/config"
@@ -17,13 +18,13 @@ func (b *Bot) runStop(ctx context.Context) {
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
cfg, err := b.cfg.GetRoom(evt.RoomID) cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err) b.Error(ctx, "failed to retrieve settings: %v", err)
return return
} }
mailbox := cfg.Get(config.RoomMailbox) mailbox := cfg.Get(config.RoomMailbox)
if mailbox == "" { if mailbox == "" {
b.SendNotice(ctx, evt.RoomID, "that room is not configured yet") b.lp.SendNotice(evt.RoomID, "that room is not configured yet", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return return
} }
@@ -31,11 +32,11 @@ func (b *Bot) runStop(ctx context.Context) {
err = b.cfg.SetRoom(evt.RoomID, config.Room{}) err = b.cfg.SetRoom(evt.RoomID, config.Room{})
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err) b.Error(ctx, "cannot update settings: %v", err)
return return
} }
b.SendNotice(ctx, evt.RoomID, "mailbox has been disabled") b.lp.SendNotice(evt.RoomID, "mailbox has been disabled", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
} }
func (b *Bot) handleOption(ctx context.Context, cmd []string) { func (b *Bot) handleOption(ctx context.Context, cmd []string) {
@@ -43,14 +44,23 @@ func (b *Bot) handleOption(ctx context.Context, cmd []string) {
b.getOption(ctx, cmd[0]) b.getOption(ctx, cmd[0])
return return
} }
b.setOption(ctx, cmd[0], cmd[1]) switch cmd[0] {
case config.RoomActive:
return
case config.RoomMailbox:
b.setMailbox(ctx, cmd[1])
case config.RoomPassword:
b.setPassword(ctx)
default:
b.setOption(ctx, cmd[0], cmd[1])
}
} }
func (b *Bot) getOption(ctx context.Context, name string) { func (b *Bot) getOption(ctx context.Context, name string) {
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
cfg, err := b.cfg.GetRoom(evt.RoomID) cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err) b.Error(ctx, "failed to retrieve settings: %v", err)
return return
} }
@@ -63,7 +73,7 @@ func (b *Bot) getOption(ctx context.Context, name string) {
msg := fmt.Sprintf("`%s` is not set, kupo.\n"+ msg := fmt.Sprintf("`%s` is not set, kupo.\n"+
"To set it, send a `%s %s VALUE` command.", "To set it, send a `%s %s VALUE` command.",
name, b.prefix, name) name, b.prefix, name)
b.SendNotice(ctx, evt.RoomID, msg) b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return return
} }
@@ -81,10 +91,67 @@ func (b *Bot) getOption(ctx context.Context, name string) {
"or just set a new one with `%s %s NEW_PASSWORD`.", "or just set a new one with `%s %s NEW_PASSWORD`.",
b.prefix, name) b.prefix, name)
} }
b.SendNotice(ctx, evt.RoomID, msg) b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
func (b *Bot) setMailbox(ctx context.Context, value string) {
evt := eventFromContext(ctx)
existingID, ok := b.getMapping(value)
if (ok && existingID != "" && existingID != evt.RoomID) || b.isReserved(value) {
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Mailbox `%s` (%s) already taken, kupo", value, utils.EmailsList(value, "")))
return
}
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "failed to retrieve settings: %v", err)
return
}
old := cfg.Get(config.RoomMailbox)
cfg.Set(config.RoomMailbox, value)
cfg.Set(config.RoomOwner, evt.Sender.String())
if old != "" {
b.rooms.Delete(old)
}
active := b.ActivateMailbox(evt.Sender, evt.RoomID, value)
cfg.Set(config.RoomActive, strconv.FormatBool(active))
value = fmt.Sprintf("%s@%s", value, utils.SanitizeDomain(cfg.Domain()))
err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil {
b.Error(ctx, "cannot update settings: %v", err)
return
}
msg := fmt.Sprintf("mailbox of this room set to `%s`", value)
b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
func (b *Bot) setPassword(ctx context.Context) {
evt := eventFromContext(ctx)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "failed to retrieve settings: %v", err)
return
}
value := b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case
value, err = argon2pw.GenerateSaltedHash(value)
if err != nil {
b.Error(ctx, "failed to hash password: %v", err)
return
}
cfg.Set(config.RoomPassword, value)
err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil {
b.Error(ctx, "cannot update settings: %v", err)
return
}
b.lp.SendNotice(evt.RoomID, "SMTP password has been set", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
} }
//nolint:gocognit
func (b *Bot) setOption(ctx context.Context, name, value string) { func (b *Bot) setOption(ctx context.Context, name, value string) {
cmd := b.commands.get(name) cmd := b.commands.get(name)
if cmd != nil && cmd.sanitizer != nil { if cmd != nil && cmd.sanitizer != nil {
@@ -92,57 +159,36 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
} }
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
// ignore request
if name == config.RoomActive {
return
}
if name == config.RoomMailbox {
existingID, ok := b.getMapping(value)
if (ok && existingID != "" && existingID != evt.RoomID) || b.isReserved(value) {
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Mailbox `%s` (%s) already taken, kupo", value, utils.EmailsList(value, "")))
return
}
}
cfg, err := b.cfg.GetRoom(evt.RoomID) cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err) b.Error(ctx, "failed to retrieve settings: %v", err)
return return
} }
if name == config.RoomPassword { if name == config.RoomAutoreply ||
value = b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case name == config.RoomSignature {
value, err = argon2pw.GenerateSaltedHash(value) value = strings.Join(b.parseCommand(evt.Content.AsMessage().Body, false)[1:], " ")
if err != nil { }
b.Error(ctx, evt.RoomID, "failed to hash password: %v", err)
return if value == "reset" {
} value = ""
} }
old := cfg.Get(name) old := cfg.Get(name)
cfg.Set(name, value) if old == value {
b.lp.SendNotice(evt.RoomID, "nothing changed, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
if name == config.RoomMailbox { return
cfg.Set(config.RoomOwner, evt.Sender.String())
if old != "" {
b.rooms.Delete(old)
}
active := b.ActivateMailbox(evt.Sender, evt.RoomID, value)
cfg.Set(config.RoomActive, strconv.FormatBool(active))
value = fmt.Sprintf("%s@%s", value, utils.SanitizeDomain(cfg.Domain()))
} }
cfg.Set(name, value)
err = b.cfg.SetRoom(evt.RoomID, cfg) err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err) b.Error(ctx, "cannot update settings: %v", err)
return return
} }
msg := 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 == config.RoomPassword { b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
msg = "SMTP password has been set"
}
b.SendNotice(ctx, evt.RoomID, msg)
} }
func (b *Bot) runSpamlistAdd(ctx context.Context, commandSlice []string) { func (b *Bot) runSpamlistAdd(ctx context.Context, commandSlice []string) {
@@ -151,12 +197,12 @@ func (b *Bot) runSpamlistAdd(ctx context.Context, commandSlice []string) {
b.getOption(ctx, config.RoomSpamlist) b.getOption(ctx, config.RoomSpamlist)
return return
} }
roomCfg, err := b.cfg.GetRoom(evt.RoomID) cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot get room settings: %v", err) b.Error(ctx, "cannot get room settings: %v", err)
return return
} }
spamlist := utils.StringSlice(roomCfg[config.RoomSpamlist]) spamlist := utils.StringSlice(cfg[config.RoomSpamlist])
for _, newItem := range commandSlice[1:] { for _, newItem := range commandSlice[1:] {
newItem = strings.TrimSpace(newItem) newItem = strings.TrimSpace(newItem)
if slices.Contains(spamlist, newItem) { if slices.Contains(spamlist, newItem) {
@@ -165,14 +211,19 @@ func (b *Bot) runSpamlistAdd(ctx context.Context, commandSlice []string) {
spamlist = append(spamlist, newItem) spamlist = append(spamlist, newItem)
} }
roomCfg.Set(config.RoomSpamlist, utils.SliceString(spamlist)) cfg.Set(config.RoomSpamlist, utils.SliceString(spamlist))
err = b.cfg.SetRoom(evt.RoomID, roomCfg) err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot store room settings: %v", err) b.Error(ctx, "cannot store room settings: %v", err)
return return
} }
b.SendNotice(ctx, evt.RoomID, "spamlist has been updated, kupo") threadID := threadIDFromContext(ctx)
if threadID == "" {
threadID = evt.ID
}
b.lp.SendNotice(evt.RoomID, "spamlist has been updated, kupo", linkpearl.RelatesTo(threadID, cfg.NoThreads()))
} }
func (b *Bot) runSpamlistRemove(ctx context.Context, commandSlice []string) { func (b *Bot) runSpamlistRemove(ctx context.Context, commandSlice []string) {
@@ -181,13 +232,13 @@ func (b *Bot) runSpamlistRemove(ctx context.Context, commandSlice []string) {
b.getOption(ctx, config.RoomSpamlist) b.getOption(ctx, config.RoomSpamlist)
return return
} }
roomCfg, err := b.cfg.GetRoom(evt.RoomID) cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot get room settings: %v", err) b.Error(ctx, "cannot get room settings: %v", err)
return return
} }
toRemove := map[int]struct{}{} toRemove := map[int]struct{}{}
spamlist := utils.StringSlice(roomCfg[config.RoomSpamlist]) spamlist := utils.StringSlice(cfg[config.RoomSpamlist])
for _, item := range commandSlice[1:] { for _, item := range commandSlice[1:] {
item = strings.TrimSpace(item) item = strings.TrimSpace(item)
idx := slices.Index(spamlist, item) idx := slices.Index(spamlist, item)
@@ -197,7 +248,7 @@ func (b *Bot) runSpamlistRemove(ctx context.Context, commandSlice []string) {
toRemove[idx] = struct{}{} toRemove[idx] = struct{}{}
} }
if len(toRemove) == 0 { if len(toRemove) == 0 {
b.SendNotice(ctx, evt.RoomID, "nothing new, kupo.") b.lp.SendNotice(evt.RoomID, "nothing new, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return return
} }
@@ -209,35 +260,35 @@ func (b *Bot) runSpamlistRemove(ctx context.Context, commandSlice []string) {
updatedSpamlist = append(updatedSpamlist, item) updatedSpamlist = append(updatedSpamlist, item)
} }
roomCfg.Set(config.RoomSpamlist, utils.SliceString(updatedSpamlist)) cfg.Set(config.RoomSpamlist, utils.SliceString(updatedSpamlist))
err = b.cfg.SetRoom(evt.RoomID, roomCfg) err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot store room settings: %v", err) b.Error(ctx, "cannot store room settings: %v", err)
return return
} }
b.SendNotice(ctx, evt.RoomID, "spamlist has been updated, kupo") b.lp.SendNotice(evt.RoomID, "spamlist has been updated, kupo", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
} }
func (b *Bot) runSpamlistReset(ctx context.Context) { func (b *Bot) runSpamlistReset(ctx context.Context) {
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
roomCfg, err := b.cfg.GetRoom(evt.RoomID) cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot get room settings: %v", err) b.Error(ctx, "cannot get room settings: %v", err)
return return
} }
spamlist := utils.StringSlice(roomCfg[config.RoomSpamlist]) spamlist := utils.StringSlice(cfg[config.RoomSpamlist])
if len(spamlist) == 0 { if len(spamlist) == 0 {
b.SendNotice(ctx, evt.RoomID, "spamlist is empty, kupo.") b.lp.SendNotice(evt.RoomID, "spamlist is empty, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return return
} }
roomCfg.Set(config.RoomSpamlist, "") cfg.Set(config.RoomSpamlist, "")
err = b.cfg.SetRoom(evt.RoomID, roomCfg) err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot store room settings: %v", err) b.Error(ctx, "cannot store room settings: %v", err)
return return
} }
b.SendNotice(ctx, evt.RoomID, "spamlist has been reset, kupo.") b.lp.SendNotice(evt.RoomID, "spamlist has been reset, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
} }

View File

@@ -21,6 +21,8 @@ const (
BotQueueBatch = "queue:batch" BotQueueBatch = "queue:batch"
BotQueueRetries = "queue:retries" BotQueueRetries = "queue:retries"
BotBanlistEnabled = "banlist:enabled" BotBanlistEnabled = "banlist:enabled"
BotBanlistAuto = "banlist:auto"
BotBanlistAuth = "banlist:auth"
BotGreylist = "greylist" BotGreylist = "greylist"
BotMautrix015Migration = "mautrix015migration" BotMautrix015Migration = "mautrix015migration"
) )
@@ -72,6 +74,16 @@ func (s Bot) BanlistEnabled() bool {
return utils.Bool(s.Get(BotBanlistEnabled)) return utils.Bool(s.Get(BotBanlistEnabled))
} }
// BanlistAuto option
func (s Bot) BanlistAuto() bool {
return utils.Bool(s.Get(BotBanlistAuto))
}
// BanlistAuth option
func (s Bot) BanlistAuth() bool {
return utils.Bool(s.Get(BotBanlistAuth))
}
// Greylist option (duration in minutes) // Greylist option (duration in minutes)
func (s Bot) Greylist() int { func (s Bot) Greylist() int {
return utils.Int(s.Get(BotGreylist)) return utils.Int(s.Get(BotGreylist))

View File

@@ -32,7 +32,7 @@ func (m *Manager) GetBot() Bot {
var config Bot var config Bot
config, err = m.lp.GetAccountData(acBotKey) config, err = m.lp.GetAccountData(acBotKey)
if err != nil { if err != nil {
m.log.Error().Err(utils.UnwrapError(err)).Msg("cannot get bot settings") m.log.Error().Err(err).Msg("cannot get bot settings")
} }
if config == nil { if config == nil {
config = make(Bot, 0) config = make(Bot, 0)
@@ -44,7 +44,7 @@ func (m *Manager) GetBot() Bot {
// SetBot config // SetBot config
func (m *Manager) SetBot(cfg Bot) error { func (m *Manager) SetBot(cfg Bot) error {
return utils.UnwrapError(m.lp.SetAccountData(acBotKey, cfg)) return m.lp.SetAccountData(acBotKey, cfg)
} }
// GetRoom config // GetRoom config
@@ -54,12 +54,12 @@ func (m *Manager) GetRoom(roomID id.RoomID) (Room, error) {
config = make(Room, 0) config = make(Room, 0)
} }
return config, utils.UnwrapError(err) return config, err
} }
// SetRoom config // SetRoom config
func (m *Manager) SetRoom(roomID id.RoomID, cfg Room) error { func (m *Manager) SetRoom(roomID id.RoomID, cfg Room) error {
return utils.UnwrapError(m.lp.SetRoomAccountData(roomID, acRoomKey, cfg)) return m.lp.SetRoomAccountData(roomID, acRoomKey, cfg)
} }
// GetBanlist config // GetBanlist config
@@ -72,7 +72,7 @@ func (m *Manager) GetBanlist() List {
defer m.mu.Unlock("banlist") defer m.mu.Unlock("banlist")
config, err := m.lp.GetAccountData(acBanlistKey) config, err := m.lp.GetAccountData(acBanlistKey)
if err != nil { if err != nil {
m.log.Error().Err(utils.UnwrapError(err)).Msg("cannot get banlist") m.log.Error().Err(err).Msg("cannot get banlist")
} }
if config == nil { if config == nil {
config = make(List, 0) config = make(List, 0)
@@ -93,14 +93,14 @@ func (m *Manager) SetBanlist(cfg List) error {
cfg = make(List, 0) cfg = make(List, 0)
} }
return utils.UnwrapError(m.lp.SetAccountData(acBanlistKey, cfg)) return m.lp.SetAccountData(acBanlistKey, cfg)
} }
// GetGreylist config // GetGreylist config
func (m *Manager) GetGreylist() List { func (m *Manager) GetGreylist() List {
config, err := m.lp.GetAccountData(acGreylistKey) config, err := m.lp.GetAccountData(acGreylistKey)
if err != nil { if err != nil {
m.log.Error().Err(utils.UnwrapError(err)).Msg("cannot get banlist") m.log.Error().Err(err).Msg("cannot get banlist")
} }
if config == nil { if config == nil {
config = make(List, 0) config = make(List, 0)
@@ -112,5 +112,5 @@ func (m *Manager) GetGreylist() List {
// SetGreylist config // SetGreylist config
func (m *Manager) SetGreylist(cfg List) error { func (m *Manager) SetGreylist(cfg List) error {
return utils.UnwrapError(m.lp.SetAccountData(acGreylistKey, cfg)) return m.lp.SetAccountData(acGreylistKey, cfg)
} }

View File

@@ -14,26 +14,31 @@ type Room map[string]string
// option keys // option keys
const ( const (
RoomActive = ".active" RoomActive = ".active"
RoomOwner = "owner" RoomOwner = "owner"
RoomMailbox = "mailbox" RoomMailbox = "mailbox"
RoomDomain = "domain" RoomDomain = "domain"
RoomNoSend = "nosend" RoomPassword = "password"
RoomNoReplies = "noreplies" RoomSignature = "signature"
RoomNoCC = "nocc" RoomAutoreply = "autoreply"
RoomNoSender = "nosender"
RoomNoRecipient = "norecipient" RoomNoCC = "nocc"
RoomNoSubject = "nosubject" RoomNoFiles = "nofiles"
RoomNoHTML = "nohtml" RoomNoHTML = "nohtml"
RoomNoThreads = "nothreads" RoomNoInlines = "noinlines"
RoomNoFiles = "nofiles" RoomNoRecipient = "norecipient"
RoomNoInlines = "noinlines" RoomNoReplies = "noreplies"
RoomPassword = "password" RoomNoSend = "nosend"
RoomNoSender = "nosender"
RoomNoSubject = "nosubject"
RoomNoThreads = "nothreads"
RoomSpamcheckDKIM = "spamcheck:dkim" RoomSpamcheckDKIM = "spamcheck:dkim"
RoomSpamcheckMX = "spamcheck:mx"
RoomSpamcheckSMTP = "spamcheck:smtp" RoomSpamcheckSMTP = "spamcheck:smtp"
RoomSpamcheckSPF = "spamcheck:spf" RoomSpamcheckSPF = "spamcheck:spf"
RoomSpamcheckMX = "spamcheck:mx"
RoomSpamlist = "spamlist" RoomSpamlist = "spamlist"
) )
// Get option // Get option
@@ -66,6 +71,14 @@ func (s Room) Password() string {
return s.Get(RoomPassword) return s.Get(RoomPassword)
} }
func (s Room) Signature() string {
return s.Get(RoomSignature)
}
func (s Room) Autoreply() string {
return s.Get(RoomAutoreply)
}
func (s Room) NoSend() bool { func (s Room) NoSend() bool {
return utils.Bool(s.Get(RoomNoSend)) return utils.Bool(s.Get(RoomNoSend))
} }

View File

@@ -5,12 +5,14 @@ import (
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
) )
type ctxkey int type ctxkey int
const ( const (
ctxEvent ctxkey = iota ctxEvent ctxkey = iota
ctxThreadID ctxkey = iota
) )
func newContext(evt *event.Event) context.Context { func newContext(evt *event.Event) context.Context {
@@ -49,3 +51,21 @@ func eventToContext(ctx context.Context, evt *event.Event) context.Context {
return ctx return ctx
} }
func threadIDToContext(ctx context.Context, threadID id.EventID) context.Context {
return context.WithValue(ctx, ctxThreadID, threadID)
}
func threadIDFromContext(ctx context.Context) id.EventID {
v := ctx.Value(ctxThreadID)
if v == nil {
return ""
}
threadID, ok := v.(id.EventID)
if !ok {
return ""
}
return threadID
}

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"strings" "strings"
"gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -111,6 +112,8 @@ func (b *Bot) GetIFOptions(roomID id.RoomID) email.IncomingFilteringOptions {
} }
// IncomingEmail sends incoming email to matrix room // IncomingEmail sends incoming email to matrix room
//
//nolint:gocognit // TODO
func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error { func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error {
roomID, ok := b.GetMapping(email.Mailbox(true)) roomID, ok := b.GetMapping(email.Mailbox(true))
if !ok { if !ok {
@@ -118,16 +121,19 @@ func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error {
} }
cfg, err := b.cfg.GetRoom(roomID) cfg, err := b.cfg.GetRoom(roomID)
if err != nil { if err != nil {
b.Error(ctx, roomID, "cannot get settings: %v", err) b.Error(ctx, "cannot get settings: %v", err)
} }
b.mu.Lock(roomID.String()) b.mu.Lock(roomID.String())
defer b.mu.Unlock(roomID.String()) defer b.mu.Unlock(roomID.String())
var threadID id.EventID var threadID id.EventID
newThread := true
if email.InReplyTo != "" || email.References != "" { if email.InReplyTo != "" || email.References != "" {
threadID = b.getThreadID(roomID, email.InReplyTo, email.References) threadID = b.getThreadID(roomID, email.InReplyTo, email.References)
if threadID != "" { if threadID != "" {
newThread = false
ctx = threadIDToContext(ctx, threadID)
b.setThreadID(roomID, email.MessageID, threadID) b.setThreadID(roomID, email.MessageID, threadID)
} }
} }
@@ -135,12 +141,14 @@ func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error {
eventID, serr := b.lp.Send(roomID, content) eventID, serr := b.lp.Send(roomID, content)
if serr != nil { if serr != nil {
if !strings.Contains(serr.Error(), "M_UNKNOWN") { // if it's not an unknown event event error if !strings.Contains(serr.Error(), "M_UNKNOWN") { // if it's not an unknown event event error
return utils.UnwrapError(serr) return serr
} }
threadID = "" // unknown event edge case - remove existing thread ID to avoid complications threadID = "" // unknown event edge case - remove existing thread ID to avoid complications
newThread = true
} }
if threadID == "" { if threadID == "" {
threadID = eventID threadID = eventID
ctx = threadIDToContext(ctx, threadID)
} }
b.setThreadID(roomID, email.MessageID, threadID) b.setThreadID(roomID, email.MessageID, threadID)
@@ -154,26 +162,119 @@ func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error {
b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID) b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID)
} }
if newThread && cfg.Autoreply() != "" {
b.sendAutoreply(roomID, threadID)
}
return nil return nil
} }
// SendEmailReply sends replies from matrix thread to email thread //nolint:gocognit // TODO
func (b *Bot) SendEmailReply(ctx context.Context) { func (b *Bot) sendAutoreply(roomID id.RoomID, threadID id.EventID) {
evt := eventFromContext(ctx) cfg, err := b.cfg.GetRoom(roomID)
if !b.allowSend(evt.Sender, evt.RoomID) { if err != nil {
return return
} }
if !b.allowReply(evt.Sender, evt.RoomID) {
text := cfg.Autoreply()
if text == "" {
return
}
threadEvt, err := b.lp.GetClient().GetEvent(roomID, threadID)
if err != nil {
b.log.Error().Err(err).Msg("cannot get thread event for autoreply")
return
}
evt := &event.Event{
ID: threadID + "-autoreply",
RoomID: roomID,
Content: event.Content{
Parsed: &event.MessageEventContent{
RelatesTo: &event.RelatesTo{
Type: event.RelThread,
EventID: threadID,
},
},
},
}
meta := b.getParentEmail(evt, cfg.Mailbox())
if meta.To == "" {
return
}
if meta.ThreadID == "" {
meta.ThreadID = threadID
}
if meta.Subject == "" {
meta.Subject = "Automatic response"
}
content := format.RenderMarkdown(text, true, true)
signature := format.RenderMarkdown(cfg.Signature(), true, true)
body := content.Body
if signature.Body != "" {
body += "\n\n---\n" + signature.Body
}
var htmlBody string
if !cfg.NoHTML() {
htmlBody = content.FormattedBody
if htmlBody != "" && signature.FormattedBody != "" {
htmlBody += "<br><hr><br>" + signature.FormattedBody
}
}
meta.MessageID = email.MessageID(evt.ID, meta.FromDomain)
meta.References = meta.References + " " + meta.MessageID
b.log.Info().Any("meta", meta).Msg("sending automatic reply")
eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil, nil)
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
if data == "" {
return
}
var queued bool
ctx := newContext(threadEvt)
recipients := meta.Recipients
for _, to := range recipients {
queued, err = b.Sendmail(evt.ID, meta.From, to, data)
if queued {
b.log.Info().Err(err).Str("from", meta.From).Str("to", to).Msg("email has been queued")
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg, "Autoreply has been sent (queued)")
continue
}
if err != nil {
b.Error(ctx, "cannot send email: %v", err)
continue
}
}
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg, "Autoreply has been sent")
}
func (b *Bot) canReply(sender id.UserID, roomID id.RoomID) bool {
return b.allowSend(sender, roomID) && b.allowReply(sender, roomID)
}
// SendEmailReply sends replies from matrix thread to email thread
//
//nolint:gocognit // TODO
func (b *Bot) SendEmailReply(ctx context.Context) {
evt := eventFromContext(ctx)
if !b.canReply(evt.Sender, evt.RoomID) {
return return
} }
cfg, err := b.cfg.GetRoom(evt.RoomID) cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot retrieve room settings: %v", err) b.Error(ctx, "cannot retrieve room settings: %v", err)
return return
} }
mailbox := cfg.Mailbox() mailbox := cfg.Mailbox()
if mailbox == "" { if mailbox == "" {
b.Error(ctx, evt.RoomID, "mailbox is not configured, kupo") b.Error(ctx, "mailbox is not configured, kupo")
return return
} }
@@ -183,21 +284,29 @@ func (b *Bot) SendEmailReply(ctx context.Context) {
meta := b.getParentEmail(evt, mailbox) meta := b.getParentEmail(evt, mailbox)
if meta.To == "" { if meta.To == "" {
b.Error(ctx, evt.RoomID, "cannot find parent email and continue the thread. Please, start a new email thread") b.Error(ctx, "cannot find parent email and continue the thread. Please, start a new email thread")
return return
} }
if meta.ThreadID == "" { if meta.ThreadID == "" {
meta.ThreadID = b.getThreadID(evt.RoomID, meta.InReplyTo, meta.References) meta.ThreadID = b.getThreadID(evt.RoomID, meta.InReplyTo, meta.References)
ctx = threadIDToContext(ctx, meta.ThreadID)
} }
content := evt.Content.AsMessage() content := evt.Content.AsMessage()
if meta.Subject == "" { if meta.Subject == "" {
meta.Subject = strings.SplitN(content.Body, "\n", 1)[0] meta.Subject = strings.SplitN(content.Body, "\n", 1)[0]
} }
signature := format.RenderMarkdown(cfg.Signature(), true, true)
body := content.Body body := content.Body
if signature.Body != "" {
body += "\n\n---\n" + signature.Body
}
var htmlBody string var htmlBody string
if !cfg.NoHTML() { if !cfg.NoHTML() {
htmlBody = content.FormattedBody htmlBody = content.FormattedBody
if htmlBody != "" && signature.FormattedBody != "" {
htmlBody += "<br><hr><br>" + signature.FormattedBody
}
} }
meta.MessageID = email.MessageID(evt.ID, meta.FromDomain) meta.MessageID = email.MessageID(evt.ID, meta.FromDomain)
@@ -206,7 +315,7 @@ func (b *Bot) SendEmailReply(ctx context.Context) {
eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil, nil) eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil, nil)
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey()) data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
if data == "" { if data == "" {
b.SendError(ctx, evt.RoomID, "email body is empty") b.lp.SendNotice(evt.RoomID, "email body is empty", linkpearl.RelatesTo(meta.ThreadID, cfg.NoThreads()))
return return
} }
@@ -221,7 +330,7 @@ func (b *Bot) SendEmailReply(ctx context.Context) {
} }
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot send email: %v", err) b.Error(ctx, "cannot send email: %v", err)
continue continue
} }
} }
@@ -320,7 +429,7 @@ func (e *parentEmail) calculateRecipients(from string, forwardedFrom []string) {
func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) { func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) {
content := evt.Content.AsMessage() content := evt.Content.AsMessage()
threadID := utils.EventParent(evt.ID, content) threadID := linkpearl.EventParent(evt.ID, content)
b.log.Debug().Str("eventID", evt.ID.String()).Str("threadID", threadID.String()).Msg("looking up for the parent event within thread") b.log.Debug().Str("eventID", evt.ID.String()).Str("threadID", threadID.String()).Msg("looking up for the parent event within thread")
if threadID == evt.ID { if threadID == evt.ID {
b.log.Debug().Str("eventID", evt.ID.String()).Msg("event is the thread itself") b.log.Debug().Str("eventID", evt.ID.String()).Msg("event is the thread itself")
@@ -336,7 +445,7 @@ func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) {
b.log.Error().Err(err).Msg("cannot get parent event") b.log.Error().Err(err).Msg("cannot get parent event")
return threadID, nil return threadID, nil
} }
utils.ParseContent(parentEvt, parentEvt.Type) linkpearl.ParseContent(parentEvt, parentEvt.Type, b.log)
if !b.lp.GetMachine().StateStore.IsEncrypted(evt.RoomID) { if !b.lp.GetMachine().StateStore.IsEncrypted(evt.RoomID) {
return threadID, parentEvt return threadID, parentEvt
@@ -362,12 +471,12 @@ func (b *Bot) getParentEmail(evt *event.Event, newFromMailbox string) *parentEma
return parent return parent
} }
parent.From = utils.EventField[string](&parentEvt.Content, eventFromKey) parent.From = linkpearl.EventField[string](&parentEvt.Content, eventFromKey)
parent.To = utils.EventField[string](&parentEvt.Content, eventToKey) parent.To = linkpearl.EventField[string](&parentEvt.Content, eventToKey)
parent.CC = utils.EventField[string](&parentEvt.Content, eventCcKey) parent.CC = linkpearl.EventField[string](&parentEvt.Content, eventCcKey)
parent.RcptTo = utils.EventField[string](&parentEvt.Content, eventRcptToKey) parent.RcptTo = linkpearl.EventField[string](&parentEvt.Content, eventRcptToKey)
parent.InReplyTo = utils.EventField[string](&parentEvt.Content, eventMessageIDkey) parent.InReplyTo = linkpearl.EventField[string](&parentEvt.Content, eventMessageIDkey)
parent.References = utils.EventField[string](&parentEvt.Content, eventReferencesKey) parent.References = linkpearl.EventField[string](&parentEvt.Content, eventReferencesKey)
senderEmail := parent.fixtofrom(newFromMailbox, b.domains) senderEmail := parent.fixtofrom(newFromMailbox, b.domains)
parent.calculateRecipients(senderEmail, b.mbxc.Forwarded) parent.calculateRecipients(senderEmail, b.mbxc.Forwarded)
parent.MessageID = email.MessageID(parentEvt.ID, parent.FromDomain) parent.MessageID = email.MessageID(parentEvt.ID, parent.FromDomain)
@@ -378,7 +487,7 @@ func (b *Bot) getParentEmail(evt *event.Event, newFromMailbox string) *parentEma
parent.References = " " + parent.MessageID parent.References = " " + parent.MessageID
} }
parent.Subject = utils.EventField[string](&parentEvt.Content, eventSubjectKey) parent.Subject = linkpearl.EventField[string](&parentEvt.Content, eventSubjectKey)
if parent.Subject != "" { if parent.Subject != "" {
parent.Subject = "Re: " + parent.Subject parent.Subject = "Re: " + parent.Subject
} else { } else {
@@ -390,28 +499,32 @@ func (b *Bot) getParentEmail(evt *event.Event, newFromMailbox string) *parentEma
// saveSentMetadata used to save metadata from !pm sent and thread reply events to a separate notice message // saveSentMetadata used to save metadata from !pm sent and thread reply events to a separate notice message
// because that metadata is needed to determine email thread relations // because that metadata is needed to determine email thread relations
func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, recipients []string, eml *email.Email, cfg config.Room) { func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, recipients []string, eml *email.Email, cfg config.Room, textOverride ...string) {
addrs := strings.Join(recipients, ", ") addrs := strings.Join(recipients, ", ")
text := "Email has been sent to " + addrs text := "Email has been sent to " + addrs
if queued { if queued {
text = "Email to " + addrs + " has been queued" text = "Email to " + addrs + " has been queued"
} }
if len(textOverride) > 0 {
text = textOverride[0]
}
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
content := eml.Content(threadID, cfg.ContentOptions()) content := eml.Content(threadID, cfg.ContentOptions())
notice := format.RenderMarkdown(text, true, true) notice := format.RenderMarkdown(text, true, true)
msgContent, ok := content.Parsed.(*event.MessageEventContent) msgContent, ok := content.Parsed.(*event.MessageEventContent)
if !ok { if !ok {
b.Error(ctx, evt.RoomID, "cannot parse message") b.Error(ctx, "cannot parse message")
return return
} }
msgContent.MsgType = event.MsgNotice msgContent.MsgType = event.MsgNotice
msgContent.Body = notice.Body msgContent.Body = notice.Body
msgContent.FormattedBody = notice.FormattedBody msgContent.FormattedBody = notice.FormattedBody
msgContent.RelatesTo = linkpearl.RelatesTo(threadID, cfg.NoThreads())
content.Parsed = msgContent content.Parsed = msgContent
msgID, err := b.lp.Send(evt.RoomID, content) msgID, err := b.lp.Send(evt.RoomID, content)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot send notice: %v", err) b.Error(ctx, "cannot send notice: %v", err)
return return
} }
domain := utils.SanitizeDomain(cfg.Domain()) domain := utils.SanitizeDomain(cfg.Domain())
@@ -423,9 +536,9 @@ func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.Eve
func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) { func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) {
for _, file := range files { for _, file := range files {
req := file.Convert() req := file.Convert()
err := b.lp.SendFile(roomID, req, file.MsgType, utils.RelatesTo(!noThreads, parentID)) err := b.lp.SendFile(roomID, req, file.MsgType, linkpearl.RelatesTo(parentID, noThreads))
if err != nil { if err != nil {
b.Error(ctx, roomID, "cannot upload file %s: %v", req.FileName, err) b.Error(ctx, "cannot upload file %s: %v", req.FileName, err)
} }
} }
} }

44
bot/reaction.go Normal file
View File

@@ -0,0 +1,44 @@
package bot
import (
"context"
"gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/event"
)
var supportedReactions = map[string]string{
"⛔️": commandSpamlistAdd,
"🛑": commandSpamlistAdd,
"🚫": commandSpamlistAdd,
"spam": commandSpamlistAdd,
}
func (b *Bot) handleReaction(ctx context.Context) {
evt := eventFromContext(ctx)
content := evt.Content.AsReaction()
action, ok := supportedReactions[content.GetRelatesTo().Key]
if !ok { // cannot do anything with it
return
}
srcID := content.GetRelatesTo().EventID
srcEvt, err := b.lp.GetClient().GetEvent(evt.RoomID, srcID)
if err != nil {
b.Error(ctx, "cannot find event %s: %v", srcID, err)
return
}
threadID := linkpearl.EventParent(srcID, srcEvt.Content.AsMessage())
ctx = threadIDToContext(ctx, threadID)
linkpearl.ParseContent(evt, event.EventMessage, b.log)
switch action {
case commandSpamlistAdd:
sender := linkpearl.EventField[string](&srcEvt.Content, eventFromKey)
if sender == "" {
b.Error(ctx, "cannot get sender of the email")
return
}
b.runSpamlistAdd(ctx, []string{commandSpamlistAdd, linkpearl.EventField[string](&srcEvt.Content, eventFromKey)})
}
}

View File

@@ -21,7 +21,14 @@ func (b *Bot) initSync() {
event.EventMessage, event.EventMessage,
func(_ mautrix.EventSource, evt *event.Event) { func(_ mautrix.EventSource, evt *event.Event) {
go b.onMessage(evt) go b.onMessage(evt)
}) },
)
b.lp.OnEventType(
event.EventReaction,
func(_ mautrix.EventSource, evt *event.Event) {
go b.onReaction(evt)
},
)
} }
// joinPermit is called by linkpearl when processing "invite" events and deciding if rooms should be auto-joined or not // joinPermit is called by linkpearl when processing "invite" events and deciding if rooms should be auto-joined or not
@@ -69,6 +76,20 @@ func (b *Bot) onMessage(evt *event.Event) {
b.handle(ctx) b.handle(ctx)
} }
func (b *Bot) onReaction(evt *event.Event) {
// ignore own messages
if evt.Sender == b.lp.GetClient().UserID {
return
}
// mautrix 0.15.x migration
if b.ignoreBefore >= evt.Timestamp {
return
}
ctx := newContext(evt)
b.handleReaction(ctx)
}
// onBotJoin handles the "bot joined the room" event // onBotJoin handles the "bot joined the room" event
func (b *Bot) onBotJoin(ctx context.Context) { func (b *Bot) onBotJoin(ctx context.Context) {
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
@@ -80,7 +101,7 @@ func (b *Bot) onBotJoin(ctx context.Context) {
return return
} }
b.sendIntroduction(ctx, evt.RoomID) b.sendIntroduction(evt.RoomID)
b.sendHelp(ctx) b.sendHelp(ctx)
} }
@@ -103,7 +124,7 @@ func (b *Bot) onLeave(ctx context.Context) {
b.runStop(ctx) b.runStop(ctx)
_, err := b.lp.GetClient().LeaveRoom(evt.RoomID) _, err := b.lp.GetClient().LeaveRoom(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot leave empty room: %v", err) b.Error(ctx, "cannot leave empty room: %v", err)
} }
} }
} }

View File

@@ -41,7 +41,6 @@ func main() {
cfg := config.New() cfg := config.New()
initLog(cfg) initLog(cfg)
utils.SetLogger(&log)
utils.SetDomains(cfg.Domains) utils.SetDomains(cfg.Domains)
log.Info().Msg("#############################") log.Info().Msg("#############################")

View File

@@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
ssmtp -v test@localhost < $1 ssmtp -v test+sub@localhost < $1

View File

@@ -3,7 +3,7 @@ Content-Type: multipart/alternative; boundary="Apple-Mail=_E091454E-BCFA-43B4-99
Subject: MIME test 1 Subject: MIME test 1
Date: Sat, 13 Oct 2012 15:33:07 -0700 Date: Sat, 13 Oct 2012 15:33:07 -0700
Message-Id: <4E2E5A48-1A2C-4450-8663-D41B451DA93A@makita.skynet> Message-Id: <4E2E5A48-1A2C-4450-8663-D41B451DA93A@makita.skynet>
To: test@localhost To: test+sub@localhost
Mime-Version: 1.0 (Apple Message framework v1283) Mime-Version: 1.0 (Apple Message framework v1283)
X-Mailer: Apple Mail (2.1283) X-Mailer: Apple Mail (2.1283)

View File

@@ -8,6 +8,7 @@ import (
"github.com/emersion/go-msgauth/dkim" "github.com/emersion/go-msgauth/dkim"
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
"gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -107,15 +108,21 @@ func (e *Email) Mailbox(incoming bool) string {
return utils.Mailbox(e.From) return utils.Mailbox(e.From)
} }
// Content converts the email object to a Matrix event content func (e *Email) contentHeader(threadID id.EventID, text *strings.Builder, options *ContentOptions) {
func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Content {
var text strings.Builder
if options.Sender { if options.Sender {
text.WriteString(e.From) text.WriteString(e.From)
} }
if options.Recipient { if options.Recipient {
mailbox, sub, host := utils.EmailParts(e.To)
text.WriteString(" ➡️ ") text.WriteString(" ➡️ ")
text.WriteString(e.To) text.WriteString(mailbox)
text.WriteString("@")
text.WriteString(host)
if sub != "" {
text.WriteString(" (")
text.WriteString(sub)
text.WriteString(")")
}
} }
if options.CC && len(e.CC) > 0 { if options.CC && len(e.CC) > 0 {
text.WriteString("\ncc: ") text.WriteString("\ncc: ")
@@ -129,6 +136,14 @@ func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Con
text.WriteString(e.Subject) text.WriteString(e.Subject)
text.WriteString("\n\n") text.WriteString("\n\n")
} }
}
// 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
e.contentHeader(threadID, &text, options)
if e.HTML != "" && options.HTML { if e.HTML != "" && options.HTML {
text.WriteString(format.HTMLToMarkdown(e.HTML)) text.WriteString(format.HTMLToMarkdown(e.HTML))
} else { } else {
@@ -136,7 +151,7 @@ func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Con
} }
parsed := format.RenderMarkdown(text.String(), true, true) parsed := format.RenderMarkdown(text.String(), true, true)
parsed.RelatesTo = utils.RelatesTo(options.Threads, threadID) parsed.RelatesTo = linkpearl.RelatesTo(threadID, !options.Threads)
var cc string var cc string
if len(e.CC) > 0 { if len(e.CC) > 0 {

3
go.mod
View File

@@ -15,6 +15,7 @@ require (
github.com/jhillyerd/enmime v0.10.0 github.com/jhillyerd/enmime v0.10.0
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.17 github.com/mattn/go-sqlite3 v1.14.17
github.com/mcnijman/go-emailaddress v1.1.0
github.com/mileusna/crontab v1.2.0 github.com/mileusna/crontab v1.2.0
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39 github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39
github.com/rs/zerolog v1.30.0 github.com/rs/zerolog v1.30.0
@@ -25,7 +26,7 @@ require (
gitlab.com/etke.cc/go/secgen v1.1.1 gitlab.com/etke.cc/go/secgen v1.1.1
gitlab.com/etke.cc/go/trysmtp v1.1.3 gitlab.com/etke.cc/go/trysmtp v1.1.3
gitlab.com/etke.cc/go/validator v1.0.6 gitlab.com/etke.cc/go/validator v1.0.6
gitlab.com/etke.cc/linkpearl v0.0.0-20230920071429-25fe33ba08d0 gitlab.com/etke.cc/linkpearl v0.0.0-20230928120707-1e99315dc616
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 golang.org/x/exp v0.0.0-20230905200255-921286631fa9
maunium.net/go/mautrix v0.16.1 maunium.net/go/mautrix v0.16.1
) )

7
go.sum
View File

@@ -58,6 +58,8 @@ github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxm
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mcnijman/go-emailaddress v1.1.0 h1:7/Uxgn9pXwXmvXsFSgORo6XoRTrttj7AGmmB2yFArAg=
github.com/mcnijman/go-emailaddress v1.1.0/go.mod h1:m+aauxGmv31sB5zZ1I8ICcMoa9ZHOA9RiurCijfvkhI=
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= 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/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ=
github.com/mileusna/crontab v1.2.0 h1:x9ZmE2A4p6CDqMEGQ+GbqsNtnmbdmWMQYShdQu8LvrU= github.com/mileusna/crontab v1.2.0 h1:x9ZmE2A4p6CDqMEGQ+GbqsNtnmbdmWMQYShdQu8LvrU=
@@ -109,8 +111,8 @@ gitlab.com/etke.cc/go/trysmtp v1.1.3 h1:e2EHond77onMaecqCg6mWumffTSEf+ycgj88nbee
gitlab.com/etke.cc/go/trysmtp v1.1.3/go.mod h1:lOO7tTdAE0a3ETV3wN3GJ7I1Tqewu7YTpPWaOmTteV0= gitlab.com/etke.cc/go/trysmtp v1.1.3/go.mod h1:lOO7tTdAE0a3ETV3wN3GJ7I1Tqewu7YTpPWaOmTteV0=
gitlab.com/etke.cc/go/validator v1.0.6 h1:w0Muxf9Pqw7xvF7NaaswE6d7r9U3nB2t2l5PnFMrecQ= gitlab.com/etke.cc/go/validator v1.0.6 h1:w0Muxf9Pqw7xvF7NaaswE6d7r9U3nB2t2l5PnFMrecQ=
gitlab.com/etke.cc/go/validator v1.0.6/go.mod h1:Id0SxRj0J3IPhiKlj0w1plxVLZfHlkwipn7HfRZsDts= gitlab.com/etke.cc/go/validator v1.0.6/go.mod h1:Id0SxRj0J3IPhiKlj0w1plxVLZfHlkwipn7HfRZsDts=
gitlab.com/etke.cc/linkpearl v0.0.0-20230920071429-25fe33ba08d0 h1:7fx8afCUluCzJISPUr6j8przpwdcCCXqqPHWvPRmzhA= gitlab.com/etke.cc/linkpearl v0.0.0-20230928120707-1e99315dc616 h1:Gvhmq84VmAJN1xRzRBK79XJVObAvVcx9Q3s6K+Zo644=
gitlab.com/etke.cc/linkpearl v0.0.0-20230920071429-25fe33ba08d0/go.mod h1:IZ0TE+ZnIdJLb538owDMxhtpWH7blfW+oR7e5XRXxNY= gitlab.com/etke.cc/linkpearl v0.0.0-20230928120707-1e99315dc616/go.mod h1:IZ0TE+ZnIdJLb538owDMxhtpWH7blfW+oR7e5XRXxNY=
go.mau.fi/util v0.1.0 h1:BwIFWIOEeO7lsiI2eWKFkWTfc5yQmoe+0FYyOFVyaoE= go.mau.fi/util v0.1.0 h1:BwIFWIOEeO7lsiI2eWKFkWTfc5yQmoe+0FYyOFVyaoE=
go.mau.fi/util v0.1.0/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84= go.mau.fi/util v0.1.0/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84=
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
@@ -118,6 +120,7 @@ golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 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-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-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=

View File

@@ -64,7 +64,8 @@ type matrixbot interface {
IsGreylisted(net.Addr) bool IsGreylisted(net.Addr) bool
IsBanned(net.Addr) bool IsBanned(net.Addr) bool
IsTrusted(net.Addr) bool IsTrusted(net.Addr) bool
Ban(net.Addr) BanAuto(net.Addr)
BanAuth(net.Addr)
GetMapping(string) (id.RoomID, bool) GetMapping(string) (id.RoomID, bool)
GetIFOptions(id.RoomID) email.IncomingFilteringOptions GetIFOptions(id.RoomID) email.IncomingFilteringOptions
IncomingEmail(context.Context, *email.Email) error IncomingEmail(context.Context, *email.Email) error

View File

@@ -41,14 +41,14 @@ func (m *mailServer) Login(state *smtp.ConnectionState, username, password strin
if !email.AddressValid(username) { if !email.AddressValid(username) {
m.log.Debug().Str("address", username).Msg("address is invalid") m.log.Debug().Str("address", username).Msg("address is invalid")
m.bot.Ban(state.RemoteAddr) m.bot.BanAuth(state.RemoteAddr)
return nil, ErrBanned return nil, ErrBanned
} }
roomID, allow := m.bot.AllowAuth(username, password) roomID, allow := m.bot.AllowAuth(username, password)
if !allow { if !allow {
m.log.Debug().Str("username", username).Msg("username or password is invalid") m.log.Debug().Str("username", username).Msg("username or password is invalid")
m.bot.Ban(state.RemoteAddr) m.bot.BanAuth(state.RemoteAddr)
return nil, ErrBanned return nil, ErrBanned
} }
@@ -77,7 +77,7 @@ func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session,
getRoomID: m.bot.GetMapping, getRoomID: m.bot.GetMapping,
getFilters: m.bot.GetIFOptions, getFilters: m.bot.GetIFOptions,
receiveEmail: m.ReceiveEmail, receiveEmail: m.ReceiveEmail,
ban: m.bot.Ban, ban: m.bot.BanAuto,
greylisted: m.bot.IsGreylisted, greylisted: m.bot.IsGreylisted,
trusted: m.bot.IsTrusted, trusted: m.bot.IsTrusted,
log: m.log, log: m.log,

View File

@@ -1,14 +1,56 @@
package utils package utils
import "strings" import (
"strings"
"github.com/mcnijman/go-emailaddress"
)
// Mailbox returns mailbox part from email address // Mailbox returns mailbox part from email address
func Mailbox(email string) string { func Mailbox(email string) string {
index := strings.LastIndex(email, "@") mailbox, _, _ := EmailParts(email)
if index == -1 { return mailbox
return email }
// Subaddress returns sub address part form email address
func Subaddress(email string) string {
_, sub, _ := EmailParts(email)
return sub
}
// Hostname returns hostname part from email address
func Hostname(email string) string {
_, _, hostname := EmailParts(email)
return hostname
}
// EmailParts parses email address into mailbox, subaddress, and hostname
func EmailParts(email string) (string, string, string) {
var mailbox, hostname string
address, err := emailaddress.Parse(email)
if err == nil {
mailbox = address.LocalPart
hostname = address.Domain
} else {
mailbox = email
hostname = email
mIdx := strings.Index(email, "@")
hIdx := strings.LastIndex(email, "@")
if mIdx != -1 {
mailbox = email[:mIdx]
}
if hIdx != -1 {
hostname = email[hIdx+1:]
}
} }
return email[:index]
var sub string
idx := strings.Index(mailbox, "+")
if idx != -1 {
sub = strings.ReplaceAll(mailbox[idx:], "+", "")
mailbox = strings.ReplaceAll(mailbox[:idx], "+", "")
}
return mailbox, sub, hostname
} }
// EmailsList returns human-readable list of mailbox's emails for all available domains // EmailsList returns human-readable list of mailbox's emails for all available domains
@@ -34,8 +76,3 @@ func EmailsList(mailbox string, domain string) string {
return msg.String() return msg.String()
} }
// Hostname returns hostname part from email address
func Hostname(email string) string {
return email[strings.LastIndex(email, "@")+1:]
}

70
utils/mail_test.go Normal file
View File

@@ -0,0 +1,70 @@
package utils
import "testing"
func TestMailbox(t *testing.T) {
tests := map[string]string{
"mailbox@example.com": "mailbox",
"mail-box@example.com": "mail-box",
"mailbox": "mailbox",
"mail@box@example.com": "mail",
"mailbox+@example.com": "mailbox",
"mailbox+sub@example.com": "mailbox",
"mailbox+++sub@example.com": "mailbox",
}
for in, expected := range tests {
t.Run(in, func(t *testing.T) {
output := Mailbox(in)
if output != expected {
t.Error(expected, "!=", output)
}
})
}
}
func TestSubaddress(t *testing.T) {
tests := map[string]string{
"mailbox@example@example.com": "",
"mail-box@example.com": "",
"mailbox+": "",
"mailbox+sub@example.com": "sub",
"mailbox+++sub@example.com": "sub",
}
for in, expected := range tests {
t.Run(in, func(t *testing.T) {
output := Subaddress(in)
if output != expected {
t.Error(expected, "!=", output)
}
})
}
}
func TestHostname(t *testing.T) {
tests := map[string]string{
"mailbox@example.com": "example.com",
"mailbox": "mailbox",
"mail@box@example.com": "example.com",
}
for in, expected := range tests {
t.Run(in, func(t *testing.T) {
output := Hostname(in)
if output != expected {
t.Error(expected, "!=", output)
}
})
}
}
func TestEmailList(t *testing.T) {
domains = []string{"example.com", "example.org"}
expected := "test@example.org, test@example.com"
actual := EmailsList("test", "example.org")
if actual != expected {
t.Error(expected, "!=", actual)
}
}

View File

@@ -5,19 +5,9 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"github.com/rs/zerolog"
) )
var ( var domains []string
log *zerolog.Logger
domains []string
)
// SetLogger for utils
func SetLogger(loggerInstance *zerolog.Logger) {
log = loggerInstance
}
// SetDomains for later use // SetDomains for later use
func SetDomains(slice []string) { func SetDomains(slice []string) {
@@ -134,3 +124,23 @@ func StringSlice(str string) []string {
func SanitizeStringSlice(str string) string { func SanitizeStringSlice(str string) string {
return SliceString(StringSlice(str)) return SliceString(StringSlice(str))
} }
// MapKeys returns sorted keys of the map
func MapKeys[V any](data map[string]V) []string {
keys := make([]string, 0, len(data))
for k := range data {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// Chunks divides slice by chunks with specified size
func Chunks[T any](slice []T, chunkSize int) [][]T {
chunks := make([][]T, 0, (len(slice)+chunkSize-1)/chunkSize)
for chunkSize < len(slice) {
slice, chunks = slice[chunkSize:], append(chunks, slice[0:chunkSize:chunkSize])
}
return append(chunks, slice)
}

View File

@@ -0,0 +1,2 @@
emailaddress
coverage.*

25
vendor/github.com/mcnijman/go-emailaddress/.travis.yml generated vendored Normal file
View File

@@ -0,0 +1,25 @@
sudo: false
language: go
go:
- "1.11.x"
- "1.10.x"
- "1.9.x"
- tip
env:
global:
- GO111MODULE=on
matrix:
allow_failures:
- go: tip
fast_finish: true
install:
- go get golang.org/x/tools/cmd/cover
- go get github.com/mattn/goveralls
- GO111MODULE=off go get -u github.com/securego/gosec/cmd/gosec/...
script:
- go get -t -v ./...
- diff -u <(echo -n) <(gofmt -d -s .)
- go tool vet .
- $GOPATH/bin/gosec ./...
- go test -race -covermode=atomic -coverprofile=coverage.txt ./...
- $GOPATH/bin/goveralls -coverprofile=coverage.txt -service=travis-ci

21
vendor/github.com/mcnijman/go-emailaddress/LICENCE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Thijs Nijman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

102
vendor/github.com/mcnijman/go-emailaddress/README.md generated vendored Normal file
View File

@@ -0,0 +1,102 @@
# go-emailaddress #
[![GoDoc](https://godoc.org/github.com/mcnijman/go-emailaddress?status.svg)](https://godoc.org/github.com/mcnijman/go-emailaddress) [![Build Status](https://travis-ci.org/mcnijman/go-emailaddress.svg?branch=master)](https://travis-ci.org/mcnijman/go-emailaddress) [![Test Coverage](https://coveralls.io/repos/github/mcnijman/go-emailaddress/badge.svg?branch=master)](https://coveralls.io/github/mcnijman/go-emailaddress?branch=master) [![go report](https://goreportcard.com/badge/github.com/mcnijman/go-emailaddress)](https://goreportcard.com/report/github.com/mcnijman/go-emailaddress)
go-emailaddress is a tiny Go library for finding, parsing and validating email addresses. This
library is tested for Go v1.9 and above.
Note that there is no such thing as perfect email address validation other than sending an actual
email (ie. with a confirmation token). This library however checks if the email format conforms to
the spec and if the host (domain) is actually able to receive emails. You can also use this library
to find emails in a byte array. This package was created as similar packages don't seem to be
maintained anymore (ie contain bugs with pull requests still open), and/or use wrong local
validation.
## Usage ##
```bash
go get -u github.com/mcnijman/go-emailaddress
```
### Parsing and local validation ###
Parse and validate the email locally using RFC 5322 regex, note that when `err == nil` it doesn't
necessarily mean the email address actually exists.
```go
import "github.com/mcnijman/go-emailaddress"
email, err := emailaddress.Parse("foo@bar.com")
if err != nil {
fmt.Println("invalid email")
}
fmt.Println(email.LocalPart) // foo
fmt.Println(email.Domain) // bar.com
fmt.Println(email) // foo@bar.com
fmt.Println(email.String()) // foo@bar.com
```
### Validating the host ###
Host validation will first attempt to resolve the domain and then verify if we can start a mail
transaction with the host. This is relatively slow as it will contact the host several times.
Note that when `err == nil` it doesn't necessarily mean the email address actually exists.
```go
import "github.com/mcnijman/go-emailaddress"
email, err := emailaddress.Parse("foo@bar.com")
if err != nil {
fmt.Println("invalid email")
}
err := email.ValidateHost()
if err != nil {
fmt.Println("invalid host")
}
```
### Finding emails ###
This will look for emails in a byte array (ie text or an html response).
```go
import "github.com/mcnijman/go-emailaddress"
text := []byte(`Send me an email at foo@bar.com.`)
validateHost := false
emails := emailaddress.Find(text, validateHost)
for _, e := range emails {
fmt.Println(e)
}
// foo@bar.com
```
As RFC 5322 is really broad this method will likely match images and urls that contain
the '@' character (ie. !--logo@2x.png). For more reliable results, you can use the following method.
```go
import "github.com/mcnijman/go-emailaddress"
text := []byte(`Send me an email at foo@bar.com or fake@domain.foobar.`)
validateHost := false
emails := emailaddress.FindWithIcannSuffix(text, validateHost)
for _, e := range emails {
fmt.Println(e)
}
// foo@bar.com
```
## Versioning ##
This library uses [semantic versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
## License ##
This library is distributed under the MIT license found in the [LICENSE](./LICENSE)
file.

View File

@@ -0,0 +1,224 @@
// Copyright 2018 The go-emailaddress AUTHORS. All rights reserved.
//
// Use of this source code is governed by a MIT
// license that can be found in the LICENSE file.
/*
Package emailaddress provides a tiny library for finding, parsing and validation of email
addresses. This library is tested for Go v1.9 and above.
go get -u github.com/mcnijman/go-emailaddress
Local validation
Parse and validate the email locally using RFC 5322 regex, note that when err == nil it doesn't
necessarily mean the email address actually exists.
import "github.com/mcnijman/go-emailaddress"
email, err := emailaddress.Parse("foo@bar.com")
if err != nil {
fmt.Println("invalid email")
}
fmt.Println(email.LocalPart) // foo
fmt.Println(email.Domain) // bar.com
fmt.Println(email) // foo@bar.com
fmt.Println(email.String()) // foo@bar.com
Host validation
Host validation will first attempt to resolve the domain and then verify if we can start a mail
transaction with the host. This is relatively slow as it will contact the host several times.
Note that when err == nil it doesn't necessarily mean the email address actually exists.
import "github.com/mcnijman/go-emailaddress"
email, err := emailaddress.Parse("foo@bar.com")
if err != nil {
fmt.Println("invalid email")
}
err := email.ValidateHost()
if err != nil {
fmt.Println("invalid host")
}
Finding emails
This will look for emails in a byte array (ie text or an html response).
import "github.com/mcnijman/go-emailaddress"
text := []byte(`Send me an email at foo@bar.com.`)
validateHost := false
emails := emailaddress.Find(text, validateHost)
for _, e := range emails {
fmt.Println(e)
}
// foo@bar.com
As RFC 5322 is really broad this method will likely match images and urls that contain
the '@' character (ie. !--logo@2x.png). For more reliable results, you can use the following method.
import "github.com/mcnijman/go-emailaddress"
text := []byte(`Send me an email at foo@bar.com or fake@domain.foobar.`)
validateHost := false
emails := emailaddress.FindWithIcannSuffix(text, validateHost)
for _, e := range emails {
fmt.Println(e)
}
// foo@bar.com
*/
package emailaddress
import (
"fmt"
"net"
"net/smtp"
"regexp"
"strings"
"golang.org/x/net/publicsuffix"
)
var (
// rfc5322 is a RFC 5322 regex, as per: https://stackoverflow.com/a/201378/5405453.
// Note that this can't verify that the address is an actual working email address.
// Use ValidateHost as a starter and/or send them one :-).
rfc5322 = "(?i)(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])"
validEmailRegexp = regexp.MustCompile(fmt.Sprintf("^%s*$", rfc5322))
findEmailRegexp = regexp.MustCompile(rfc5322)
)
// EmailAddress is a structure that stores the address local-part@domain parts.
type EmailAddress struct {
// LocalPart usually the username of an email address.
LocalPart string
// Domain is the part of the email address after the last @.
// This should be DNS resolvable to an email server.
Domain string
}
func (e EmailAddress) String() string {
if e.LocalPart == "" || e.Domain == "" {
return ""
}
return fmt.Sprintf("%s@%s", e.LocalPart, e.Domain)
}
// ValidateHost will test if the email address is actually reachable. It will first try to resolve
// the host and then start a mail transaction.
func (e EmailAddress) ValidateHost() error {
host, err := lookupHost(e.Domain)
if err != nil {
return err
}
return tryHost(host, e)
}
// ValidateIcanSuffix will test if the public suffix of the domain is managed by ICANN using
// the golang.org/x/net/publicsuffix package. If not it will return an error. Note that if this
// method returns an error it does not necessarily mean that the email address is invalid. Also the
// suffix list in the standard package is embedded and thereby not up to date.
func (e EmailAddress) ValidateIcanSuffix() error {
d := strings.ToLower(e.Domain)
if s, icann := publicsuffix.PublicSuffix(d); !icann {
return fmt.Errorf("public suffix is not managed by ICANN, got %s", s)
}
return nil
}
// Find uses the RFC 5322 regex to match, parse and validate any email addresses found in a string.
// If the validateHost boolean is true it will call the validate host for every email address
// encounterd. As RFC 5322 is really broad this method will likely match images and urls that
// contain the '@' character.
func Find(haystack []byte, validateHost bool) (emails []*EmailAddress) {
results := findEmailRegexp.FindAll(haystack, -1)
for _, r := range results {
if e, err := Parse(string(r)); err == nil {
if validateHost {
if err := e.ValidateHost(); err != nil {
continue
}
}
emails = append(emails, e)
}
}
return emails
}
// FindWithIcannSuffix uses the RFC 5322 regex to match, parse and validate any email addresses
// found in a string. It will return emails if its eTLD is managed by the ICANN organization.
// If the validateHost boolean is true it will call the validate host for every email address
// encounterd. As RFC 5322 is really broad this method will likely match images and urls that
// contain the '@' character.
func FindWithIcannSuffix(haystack []byte, validateHost bool) (emails []*EmailAddress) {
results := Find(haystack, false)
for _, e := range results {
if err := e.ValidateIcanSuffix(); err == nil {
if validateHost {
if err := e.ValidateHost(); err != nil {
continue
}
}
emails = append(emails, e)
}
}
return emails
}
// Parse will parse the input and validate the email locally. If you want to validate the host of
// this email address remotely call the ValidateHost method.
func Parse(email string) (*EmailAddress, error) {
if !validEmailRegexp.MatchString(email) {
return nil, fmt.Errorf("format is incorrect for %s", email)
}
i := strings.LastIndexByte(email, '@')
e := &EmailAddress{
LocalPart: email[:i],
Domain: email[i+1:],
}
return e, nil
}
// lookupHost first checks if any MX records are available and if not, it will check
// if A records are available as they can resolve email server hosts. An error indicates
// that non of the A or MX records are available.
func lookupHost(domain string) (string, error) {
if mx, err := net.LookupMX(domain); err == nil {
return mx[0].Host, nil
}
if ips, err := net.LookupIP(domain); err == nil {
return ips[0].String(), nil // randomly returns IPv4 or IPv6 (when available)
}
return "", fmt.Errorf("failed finding MX and A records for domain %s", domain)
}
// tryHost will verify if we can start a mail transaction with the host.
func tryHost(host string, e EmailAddress) error {
client, err := smtp.Dial(fmt.Sprintf("%s:%d", host, 25))
if err != nil {
return err
}
defer client.Close()
if err = client.Hello(e.Domain); err == nil {
if err = client.Mail(fmt.Sprintf("hello@%s", e.Domain)); err == nil {
if err = client.Rcpt(e.String()); err == nil {
client.Reset() // #nosec
client.Quit() // #nosec
return nil
}
}
}
return err
}

View File

@@ -24,7 +24,7 @@ func (l *Linkpearl) GetAccountData(name string) (map[string]string, error) {
l.acc.Add(name, data) l.acc.Add(name, data)
return data, nil return data, nil
} }
return data, err return data, UnwrapError(err)
} }
data = l.decryptAccountData(data) data = l.decryptAccountData(data)
@@ -37,7 +37,7 @@ func (l *Linkpearl) SetAccountData(name string, data map[string]string) error {
l.acc.Add(name, data) l.acc.Add(name, data)
data = l.encryptAccountData(data) data = l.encryptAccountData(data)
return l.GetClient().SetAccountData(name, data) return UnwrapError(l.GetClient().SetAccountData(name, data))
} }
// GetRoomAccountData of the room (from cache and API, with encryption support) // GetRoomAccountData of the room (from cache and API, with encryption support)
@@ -59,7 +59,7 @@ func (l *Linkpearl) GetRoomAccountData(roomID id.RoomID, name string) (map[strin
l.acc.Add(key, data) l.acc.Add(key, data)
return data, nil return data, nil
} }
return data, err return data, UnwrapError(err)
} }
data = l.decryptAccountData(data) data = l.decryptAccountData(data)
@@ -73,7 +73,7 @@ func (l *Linkpearl) SetRoomAccountData(roomID id.RoomID, name string, data map[s
l.acc.Add(key, data) l.acc.Add(key, data)
data = l.encryptAccountData(data) data = l.encryptAccountData(data)
return l.GetClient().SetRoomAccountData(roomID, name, data) return UnwrapError(l.GetClient().SetRoomAccountData(roomID, name, data))
} }
func (l *Linkpearl) encryptAccountData(data map[string]string) map[string]string { func (l *Linkpearl) encryptAccountData(data map[string]string) map[string]string {

View File

@@ -21,6 +21,6 @@ vuln:
# run unit tests # run unit tests
test: test:
@go test ${BUILDFLAGS} -coverprofile=cover.out ./... @go test -coverprofile=cover.out ./...
@go tool cover -func=cover.out @go tool cover -func=cover.out
-@rm -f cover.out -@rm -f cover.out

View File

@@ -1,8 +1,6 @@
package linkpearl package linkpearl
import ( import (
"fmt"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
@@ -16,24 +14,31 @@ func (l *Linkpearl) Send(roomID id.RoomID, content interface{}) (id.EventID, err
l.log.Debug().Str("roomID", roomID.String()).Any("content", content).Msg("sending event") l.log.Debug().Str("roomID", roomID.String()).Any("content", content).Msg("sending event")
resp, err := l.api.SendMessageEvent(roomID, event.EventMessage, content) resp, err := l.api.SendMessageEvent(roomID, event.EventMessage, content)
if err != nil { if err != nil {
return "", err return "", UnwrapError(err)
} }
return resp.EventID, nil return resp.EventID, nil
} }
// SendNotice to a room with optional thread relation // SendNotice to a room with optional relations, markdown supported
func (l *Linkpearl) SendNotice(roomID id.RoomID, threadID id.EventID, message string, args ...interface{}) { func (l *Linkpearl) SendNotice(roomID id.RoomID, message string, relates ...*event.RelatesTo) {
content := format.RenderMarkdown(fmt.Sprintf(message, args...), true, true) var withRelatesTo bool
if threadID != "" { content := format.RenderMarkdown(message, true, true)
content.RelatesTo = &event.RelatesTo{ content.MsgType = event.MsgNotice
Type: event.RelThread, if len(relates) > 0 {
EventID: threadID, withRelatesTo = true
} content.RelatesTo = relates[0]
} }
_, err := l.Send(roomID, &content) _, err := l.Send(roomID, &content)
if err != nil { if err != nil {
l.log.Error().Err(err).Str("roomID", roomID.String()).Msg("cannot send a notice int the room") l.log.Error().Err(UnwrapError(err)).Str("roomID", roomID.String()).Str("retries", "1/2").Msg("cannot send a notice into the room")
if withRelatesTo {
content.RelatesTo = nil
_, err = l.Send(roomID, &content)
if err != nil {
l.log.Error().Err(UnwrapError(err)).Str("roomID", roomID.String()).Str("retries", "2/2").Msg("cannot send a notice into the room even without relations")
}
}
} }
} }
@@ -41,6 +46,7 @@ func (l *Linkpearl) SendNotice(roomID id.RoomID, threadID id.EventID, message st
func (l *Linkpearl) SendFile(roomID id.RoomID, req *mautrix.ReqUploadMedia, msgtype event.MessageType, relation *event.RelatesTo) error { func (l *Linkpearl) SendFile(roomID id.RoomID, req *mautrix.ReqUploadMedia, msgtype event.MessageType, relation *event.RelatesTo) error {
resp, err := l.GetClient().UploadMedia(*req) resp, err := l.GetClient().UploadMedia(*req)
if err != nil { if err != nil {
err = UnwrapError(err)
l.log.Error().Err(err).Str("file", req.FileName).Msg("cannot upload file") l.log.Error().Err(err).Str("file", req.FileName).Msg("cannot upload file")
return err return err
} }
@@ -52,6 +58,7 @@ func (l *Linkpearl) SendFile(roomID id.RoomID, req *mautrix.ReqUploadMedia, msgt
RelatesTo: relation, RelatesTo: relation,
}, },
}) })
err = UnwrapError(err)
if err != nil { if err != nil {
l.log.Error().Err(err).Str("file", req.FileName).Msg("cannot send uploaded file") l.log.Error().Err(err).Str("file", req.FileName).Msg("cannot send uploaded file")
} }

View File

@@ -1,28 +1,38 @@
package utils package linkpearl
import ( import (
"github.com/rs/zerolog"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
// RelatesTo returns relation object of a matrix event (either threads or reply-to) // RelatesTo returns relation object of a matrix event (either threads with reply-to fallback or plain reply-to)
func RelatesTo(threads bool, parentID id.EventID) *event.RelatesTo { func RelatesTo(parentID id.EventID, noThreads ...bool) *event.RelatesTo {
if parentID == "" { if parentID == "" {
return nil return nil
} }
if threads { var nothreads bool
if len(noThreads) > 0 {
nothreads = noThreads[0]
}
if nothreads {
return &event.RelatesTo{ return &event.RelatesTo{
Type: event.RelThread, InReplyTo: &event.InReplyTo{
EventID: parentID, EventID: parentID,
},
} }
} }
return &event.RelatesTo{ return &event.RelatesTo{
Type: event.RelThread,
EventID: parentID,
InReplyTo: &event.InReplyTo{ InReplyTo: &event.InReplyTo{
EventID: parentID, EventID: parentID,
}, },
IsFallingBack: true,
} }
} }
@@ -66,7 +76,7 @@ func EventField[T any](content *event.Content, field string) T {
return v return v
} }
func ParseContent(evt *event.Event, eventType event.Type) { func ParseContent(evt *event.Event, eventType event.Type, log *zerolog.Logger) {
if evt.Content.Parsed != nil { if evt.Content.Parsed != nil {
return return
} }

5
vendor/modules.txt vendored
View File

@@ -71,6 +71,9 @@ github.com/mattn/go-runewidth
# github.com/mattn/go-sqlite3 v1.14.17 # github.com/mattn/go-sqlite3 v1.14.17
## explicit; go 1.16 ## explicit; go 1.16
github.com/mattn/go-sqlite3 github.com/mattn/go-sqlite3
# github.com/mcnijman/go-emailaddress v1.1.0
## explicit
github.com/mcnijman/go-emailaddress
# github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a # github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a
## explicit ## explicit
github.com/mikesmitty/edkey github.com/mikesmitty/edkey
@@ -141,7 +144,7 @@ gitlab.com/etke.cc/go/trysmtp
# gitlab.com/etke.cc/go/validator v1.0.6 # gitlab.com/etke.cc/go/validator v1.0.6
## explicit; go 1.18 ## explicit; go 1.18
gitlab.com/etke.cc/go/validator gitlab.com/etke.cc/go/validator
# gitlab.com/etke.cc/linkpearl v0.0.0-20230920071429-25fe33ba08d0 # gitlab.com/etke.cc/linkpearl v0.0.0-20230928120707-1e99315dc616
## explicit; go 1.18 ## explicit; go 1.18
gitlab.com/etke.cc/linkpearl gitlab.com/etke.cc/linkpearl
# go.mau.fi/util v0.1.0 # go.mau.fi/util v0.1.0