24 Commits

Author SHA1 Message Date
Aine
fcd6110790 add trusted proxies 2022-11-27 00:30:50 +02:00
Aine
8d6c4aeafe big refactoring 2022-11-25 23:33:38 +02:00
Aine
14bad9f479 update readme 2022-11-25 16:48:49 +02:00
Aine
4a76a3269d healthchecks.io integration 2022-11-25 16:23:26 +02:00
Aine
351f0fca77 speed up email checks execution 2022-11-24 21:41:45 +02:00
Aine
363ba313e0 update readme 2022-11-23 21:33:29 +02:00
Aine
3115373118 SPF and DKIM checks 2022-11-23 21:30:13 +02:00
Aine
0701f8c9c3 reject wrong email in SMTP MAIL(), reject impersonation attempts 2022-11-23 11:51:12 +02:00
Aine
b4d6d992ac do not react on edits and redactions, add section titles in help message 2022-11-21 23:57:49 +02:00
Aine
21772d7360 mailbox activation, closes #52 2022-11-21 15:37:44 +02:00
Aine
a5edaaea78 respect nosend in thread replies, respect nohtml in !pm send and thread replies (on sending) 2022-11-21 10:50:06 +02:00
Aine
6ddb894577 allow reserved mailboxes, closes #43 2022-11-20 20:55:41 +02:00
Aine
117736dcf3 use correct list of recipients on thread reply and in 'email has been sent' messages 2022-11-20 00:58:51 +02:00
Aine
bb7cf4aa7a cleanup From, To and Cc. Send replies to all recipients (To+Cc) 2022-11-20 00:31:59 +02:00
Aine
8007f77535 Merge branch 'addmeto.cc' into 'main'
correctly handle TCP connections without forging them for banned hosts

See merge request etke.cc/postmoogle!41
2022-11-19 16:21:23 +00:00
Aine
ced98e818e correctly handle TCP connections without forging them for banned hosts 2022-11-19 18:20:57 +02:00
Aine
9d25b9455f Merge branch 'addmeto.cc' into 'main'
CC/BCC support

See merge request etke.cc/postmoogle!40
2022-11-19 16:06:29 +00:00
Aine
1bcf9bb050 set correct Message-Id, From, To, Cc, based on previous emails (and used domain) in the thread 2022-11-19 18:05:26 +02:00
Aine
128d2b595a use the same sender's domain on thread reply as in parent email 2022-11-19 17:41:38 +02:00
Aine
8aac16aca8 make thread replies CC-aware and multi-domain aware 2022-11-19 17:38:13 +02:00
Aine
5fe8603506 add nocc option 2022-11-19 17:09:24 +02:00
Aine
052fd5bb25 refactoring, created email package 2022-11-19 17:00:57 +02:00
Aine
9e532a6007 initial cc support 2022-11-19 16:41:53 +02:00
Aine
ad83eab930 force <style></style> removal in html part of incoming emails 2022-11-19 00:48:48 +02:00
81 changed files with 5230 additions and 1133 deletions

View File

@@ -20,16 +20,13 @@ so you can use it to send emails from your apps and scripts as well.
- [x] Catch-all mailbox
- [x] Map email threads to matrix threads
- [x] Multi-domain support
- [x] automatic banlist
- [x] automatic greylisting
#### deep dive
> features in that section considered as "nice to have", but not a priority
- [ ] DKIM verification
- [ ] SPF verification
- [ ] DMARC verification
- [x] SMTP verification
- [x] DKIM verification
- [x] SPF verification
- [x] MX verification
- [x] Spamlist of emails (wildcards supported)
- [x] Spamlist of hosts (per server only)
- [x] Greylisting (per server only)
### Send
@@ -53,6 +50,7 @@ env vars
<summary>other optional config parameters</summary>
* **POSTMOOGLE_PORT** - SMTP port to listen for new emails
* **POSTMOOGLE_PROXIES** - space separated list of IP addresses considered as trusted proxies, thus never banned
* **POSTMOOGLE_TLS_PORT** - secure SMTP port to listen for new emails. Requires valid cert and key as well
* **POSTMOOGLE_TLS_CERT** - space separated list of paths to the SSL certificates (chain) of your domains, note that position in the cert list must match the position of the cert's key in the key list
* **POSTMOOGLE_TLS_KEY** - space separated list of paths to the SSL certificates' private keys of your domains, note that position on the key list must match the position of cert in the cert list
@@ -60,10 +58,15 @@ env vars
* **POSTMOOGLE_DATA_SECRET** - secure key (password) to encrypt account data, must be 16, 24, or 32 bytes long
* **POSTMOOGLE_NOENCRYPTION** - disable matrix encryption (libolm) support
* **POSTMOOGLE_STATUSMSG** - presence status message
* **POSTMOOGLE_SENTRY_DSN** - sentry DSN
* **POSTMOOGLE_MONITORING_SENTRY_DSN** - sentry DSN
* **POSTMOOGLE_MONITORING_SENTRY_RATE** - sentry sample rate, from 0 to 100 (default: 20)
* **POSTMOOGLE_MONITORING_HEALTHCHECKS_UUID** - healthchecks.io UUID
* **POSTMOOGLE_MONITORING_HEALTHCHECKS_DURATION** - heathchecks.io duration between pings in secods (default: 5)
* **POSTMOOGLE_LOGLEVEL** - log level
* **POSTMOOGLE_DB_DSN** - database connection string
* **POSTMOOGLE_DB_DIALECT** - database dialect (postgres, sqlite3)
* **POSTMOOGLE_MAILBOXES_RESERVED** - space separated list of reserved mailboxes, [docs/mailboxes.md](docs/mailboxes.md)
* **POSTMOOGLE_MAILBOXES_ACTIVATION** - activation flow for new mailboxes, [docs/mailboxes.md](docs/mailboxes.md)
* **POSTMOOGLE_MAXSIZE** - max email size (including attachments) in megabytes
* **POSTMOOGLE_ADMINS** - a space-separated list of admin users. See `POSTMOOGLE_USERS` for syntax examples
@@ -102,6 +105,7 @@ If you want to change them - check available options in the help message (`!pm h
* **!pm nosender** - Get or set `nosender` of the room (`true` - hide email sender; `false` - show email sender)
* **!pm norecipient** - Get or set `norecipient` of the room (`true` - hide recipient; `false` - show recipient)
* **!pm nocc** - Get or set `nocc` of the room (`true` - hide CC; `false` - show CC)
* **!pm nosubject** - Get or set `nosubject` of the room (`true` - hide email subject; `false` - show email subject)
* **!pm nohtml** - Get or set `nohtml` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)
* **!pm nothreads** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)
@@ -110,11 +114,14 @@ If you want to change them - check available options in the help message (`!pm h
---
* **!pm spamcheck:mx** - only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)
* **!pm spamcheck:spf** - only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable)
* **!pm spamcheck:dkim** - only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`true` - enable, `false` - disable)
* **!pm spamcheck:smtp** - only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)
* **!pm spamlist** - Get or set `spamlist` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,noreply@*`
---
* **!pm adminroom** - Get or set admin room
* **!pm dkim** - Get DKIM signature
* **!pm catch-all** - Configure catch-all mailbox
* **!pm queue:batch** - max amount of emails to process on each queue check

View File

@@ -41,7 +41,7 @@ func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
if !b.allowUsers(actorID) {
return false
}
cfg, err := b.getRoomSettings(targetRoomID)
cfg, err := b.cfg.GetRoom(targetRoomID)
if err != nil {
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
return false
@@ -64,7 +64,7 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
return false
}
cfg, err := b.getRoomSettings(targetRoomID)
cfg, err := b.cfg.GetRoom(targetRoomID)
if err != nil {
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
return false
@@ -73,50 +73,72 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
return !cfg.NoSend()
}
func (b *Bot) isReserved(mailbox string) bool {
for _, reserved := range b.mbxc.Reserved {
if mailbox == reserved {
return true
}
}
return false
}
// IsGreylisted checks if host is in greylist
func (b *Bot) IsGreylisted(addr net.Addr) bool {
if b.getBotSettings().Greylist() == 0 {
if b.cfg.GetBot().Greylist() == 0 {
return false
}
greylist := b.getGreylist()
greylist := b.cfg.GetGreylist()
greylistedAt, ok := greylist.Get(addr)
if !ok {
b.log.Debug("greylisting %s", addr.String())
greylist.Add(addr)
err := b.setGreylist(greylist)
err := b.cfg.SetGreylist(greylist)
if err != nil {
b.log.Error("cannot update greylist with %s: %v", addr.String(), err)
}
return true
}
duration := time.Duration(b.getBotSettings().Greylist()) * time.Minute
duration := time.Duration(b.cfg.GetBot().Greylist()) * time.Minute
return greylistedAt.Add(duration).After(time.Now().UTC())
}
// IsBanned checks if address is banned
func (b *Bot) IsBanned(addr net.Addr) bool {
return b.banlist.Has(addr)
return b.cfg.GetBanlist().Has(addr)
}
// IsTrusted checks if address is a trusted (proxy)
func (b *Bot) IsTrusted(addr net.Addr) bool {
ip := utils.AddrIP(addr)
for _, proxy := range b.proxies {
if ip == proxy {
b.log.Debug("address %s is trusted", ip)
return true
}
}
b.log.Debug("address %s is NOT trusted", ip)
return false
}
// Ban an address
func (b *Bot) Ban(addr net.Addr) {
if !b.getBotSettings().BanlistEnabled() {
if b.IsTrusted(addr) {
return
}
b.log.Debug("banning %s", addr.String())
banlist := b.getBanlist()
b.log.Debug("attempting to ban %s", addr.String())
banlist := b.cfg.GetBanlist()
banlist.Add(addr)
err := b.setBanlist(banlist)
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.log.Error("cannot update banlist with %s: %v", addr.String(), err)
}
}
// AllowAuth check if SMTP login (email) and password are valid
func (b *Bot) AllowAuth(email, password string) bool {
func (b *Bot) AllowAuth(email, password string) (id.RoomID, bool) {
var suffix bool
for _, domain := range b.domains {
if strings.HasSuffix(email, "@"+domain) {
@@ -125,22 +147,27 @@ func (b *Bot) AllowAuth(email, password string) bool {
}
}
if !suffix {
return false
return "", false
}
roomID, ok := b.getMapping(utils.Mailbox(email))
if !ok {
return false
return "", false
}
cfg, err := b.getRoomSettings(roomID)
cfg, err := b.cfg.GetRoom(roomID)
if err != nil {
b.log.Error("failed to retrieve settings: %v", err)
return false
return "", false
}
if cfg.NoSend() {
b.log.Warn("trying to send email from %q (%q), but it's receive-only", email, roomID)
return "", false
}
allow, err := argon2pw.CompareHashWithPassword(cfg.Password(), password)
if err != nil {
b.log.Warn("Password for %s is not valid: %v", email, err)
}
return allow
return roomID, allow
}

54
bot/activation.go Normal file
View File

@@ -0,0 +1,54 @@
package bot
import (
"fmt"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
type activationFlow func(id.UserID, id.RoomID, string) bool
func (b *Bot) getActivationFlow() activationFlow {
switch b.mbxc.Activation {
case "none":
return b.activateNone
case "notify":
return b.activateNotify
default:
return b.activateNone
}
}
// ActivateMailbox using the configured flow
func (b *Bot) ActivateMailbox(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
flow := b.getActivationFlow()
return flow(ownerID, roomID, mailbox)
}
func (b *Bot) activateNone(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
b.log.Debug("activating mailbox %q (%q) of %q through flow 'none'", mailbox, roomID, ownerID)
b.rooms.Store(mailbox, roomID)
return true
}
func (b *Bot) activateNotify(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
b.log.Debug("activating mailbox %q (%q) of %q through flow 'notify'", mailbox, roomID, ownerID)
b.rooms.Store(mailbox, roomID)
if len(b.adminRooms) == 0 {
return true
}
msg := fmt.Sprintf("Mailbox %q has been registered by %q for the room %q", mailbox, ownerID, roomID)
for _, adminRoom := range b.adminRooms {
content := format.RenderMarkdown(msg, true, true)
_, err := b.lp.Send(adminRoom, &content)
if err != nil {
b.log.Info("cannot send mailbox activation notification to the admin room %q", adminRoom)
continue
}
break
}
return true
}

View File

@@ -12,39 +12,62 @@ import (
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/bot/queue"
"gitlab.com/etke.cc/postmoogle/utils"
)
// Mailboxes config
type MBXConfig struct {
Reserved []string
Activation string
}
// Bot represents matrix bot
type Bot struct {
prefix string
mbxc MBXConfig
domains []string
allowedUsers []*regexp.Regexp
allowedAdmins []*regexp.Regexp
adminRooms []id.RoomID
commands commandList
banlist bglist
rooms sync.Map
proxies []string
sendmail func(string, string, string) error
cfg *config.Manager
log *logger.Logger
lp *linkpearl.Linkpearl
mu map[string]*sync.Mutex
mu utils.Mutex
q *queue.Queue
handledMembershipEvents sync.Map
}
// New creates a new matrix bot
func New(
q *queue.Queue,
lp *linkpearl.Linkpearl,
log *logger.Logger,
cfg *config.Manager,
proxies []string,
prefix string,
domains []string,
admins []string,
mbxc MBXConfig,
) (*Bot, error) {
b := &Bot{
prefix: prefix,
domains: domains,
rooms: sync.Map{},
log: log,
lp: lp,
mu: map[string]*sync.Mutex{},
domains: domains,
prefix: prefix,
rooms: sync.Map{},
adminRooms: []id.RoomID{},
proxies: proxies,
mbxc: mbxc,
cfg: cfg,
log: log,
lp: lp,
mu: utils.NewMutex(),
q: q,
}
users, err := b.initBotUsers()
if err != nil {
@@ -103,7 +126,6 @@ func (b *Bot) Start(statusMsg string) error {
if err := b.syncRooms(); err != nil {
return err
}
b.syncBanlist()
b.initSync()
b.log.Info("Postmoogle has been started")

View File

@@ -6,10 +6,11 @@ import (
"strings"
"time"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils"
)
@@ -18,10 +19,10 @@ const (
commandStop = "stop"
commandSend = "send"
commandDKIM = "dkim"
commandCatchAll = botOptionCatchAll
commandUsers = botOptionUsers
commandQueueBatch = botOptionQueueBatch
commandQueueRetries = botOptionQueueRetries
commandCatchAll = config.BotCatchAll
commandUsers = config.BotUsers
commandQueueBatch = config.BotQueueBatch
commandQueueRetries = config.BotQueueRetries
commandDelete = "delete"
commandBanlist = "banlist"
commandBanlistAdd = "banlist:add"
@@ -67,120 +68,146 @@ func (b *Bot) initCommands() commandList {
description: "Send email",
allowed: b.allowSend,
},
{allowed: b.allowOwner}, // delimiter
{allowed: b.allowOwner, description: "mailbox ownership"}, // delimiter
// options commands
{
key: roomOptionMailbox,
key: config.RoomMailbox,
description: "Get or set mailbox of the room",
sanitizer: utils.Mailbox,
allowed: b.allowOwner,
},
{
key: roomOptionDomain,
key: config.RoomDomain,
description: "Get or set default domain of the room",
sanitizer: utils.SanitizeDomain,
allowed: b.allowOwner,
},
{
key: roomOptionOwner,
key: config.RoomOwner,
description: "Get or set owner of the room",
sanitizer: func(s string) string { return s },
allowed: b.allowOwner,
},
{
key: roomOptionPassword,
key: config.RoomPassword,
description: "Get or set SMTP password of the room's mailbox",
allowed: b.allowOwner,
},
{allowed: b.allowOwner}, // delimiter
{allowed: b.allowOwner, description: "mailbox options"}, // delimiter
{
key: roomOptionNoSend,
key: config.RoomNoSend,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - disable email sending; `false` - enable email sending)",
roomOptionNoSend,
config.RoomNoSend,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoSender,
key: config.RoomNoSender,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide email sender; `false` - show email sender)",
roomOptionNoSender,
config.RoomNoSender,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoRecipient,
key: config.RoomNoRecipient,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide recipient; `false` - show recipient)",
roomOptionNoRecipient,
config.RoomNoRecipient,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoSubject,
key: config.RoomNoCC,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide CC; `false` - show CC)",
config.RoomNoCC,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: config.RoomNoSubject,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide email subject; `false` - show email subject)",
roomOptionNoSubject,
config.RoomNoSubject,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoHTML,
key: config.RoomNoHTML,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)",
roomOptionNoHTML,
config.RoomNoHTML,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoThreads,
key: config.RoomNoThreads,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)",
roomOptionNoThreads,
config.RoomNoThreads,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoFiles,
key: config.RoomNoFiles,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - ignore email attachments; `false` - upload email attachments)",
roomOptionNoFiles,
config.RoomNoFiles,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{allowed: b.allowOwner}, // delimiter
{allowed: b.allowOwner, description: "mailbox antispam"}, // delimiter
{
key: roomOptionSpamcheckMX,
key: config.RoomSpamcheckMX,
description: "only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)",
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionSpamcheckSMTP,
key: config.RoomSpamcheckSPF,
description: "only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable)",
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: config.RoomSpamcheckDKIM,
description: "only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`true` - enable, `false` - disable)",
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: config.RoomSpamcheckSMTP,
description: "only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)",
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionSpamlist,
key: config.RoomSpamlist,
description: fmt.Sprintf(
"Get or set `%s` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,spam@*`",
roomOptionSpamlist,
config.RoomSpamlist,
),
sanitizer: utils.SanitizeStringSlice,
allowed: b.allowOwner,
},
{allowed: b.allowAdmin}, // delimiter
{allowed: b.allowAdmin, description: "server options"}, // delimiter
{
key: botOptionUsers,
key: config.BotAdminRoom,
description: "Get or set admin room",
allowed: b.allowAdmin,
},
{
key: config.BotUsers,
description: "Get or set allowed users",
allowed: b.allowAdmin,
},
@@ -216,9 +243,9 @@ func (b *Bot) initCommands() commandList {
description: "Delete specific mailbox",
allowed: b.allowAdmin,
},
{allowed: b.allowAdmin}, // delimiter
{allowed: b.allowAdmin, description: "server antispam"}, // delimiter
{
key: botOptionGreylist,
key: config.BotGreylist,
description: "Set automatic greylisting duration in minutes (0 - disabled)",
allowed: b.allowAdmin,
},
@@ -245,12 +272,32 @@ func (b *Bot) initCommands() commandList {
}
}
func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice []string) {
func (b *Bot) handle(ctx context.Context) {
evt := eventFromContext(ctx)
err := b.lp.GetClient().MarkRead(evt.RoomID, evt.ID)
if err != nil {
b.log.Error("cannot send read receipt: %v", err)
}
content := evt.Content.AsMessage()
if content == nil {
b.Error(ctx, evt.RoomID, "cannot read message")
return
}
message := strings.TrimSpace(content.Body)
commandSlice := b.parseCommand(message, true)
if commandSlice == nil {
if utils.EventParent("", content) != "" {
b.SendEmailReply(ctx)
}
return
}
cmd := b.commands.get(commandSlice[0])
if cmd == nil {
return
}
_, err := b.lp.GetClient().UserTyping(evt.RoomID, true, 30*time.Second)
_, err = b.lp.GetClient().UserTyping(evt.RoomID, true, 30*time.Second)
if err != nil {
b.log.Error("cannot send typing notification: %v", err)
}
@@ -270,13 +317,15 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
b.runSend(ctx)
case commandDKIM:
b.runDKIM(ctx, commandSlice)
case config.BotAdminRoom:
b.runAdminRoom(ctx, commandSlice)
case commandUsers:
b.runUsers(ctx, commandSlice)
case commandCatchAll:
b.runCatchAll(ctx, commandSlice)
case commandDelete:
b.runDelete(ctx, commandSlice)
case botOptionGreylist:
case config.BotGreylist:
b.runGreylist(ctx, commandSlice)
case commandBanlist:
b.runBanlist(ctx, commandSlice)
@@ -319,7 +368,7 @@ func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
msg.WriteString("To get started, assign an email address to this room by sending a `")
msg.WriteString(b.prefix)
msg.WriteString(" ")
msg.WriteString(roomOptionMailbox)
msg.WriteString(config.RoomMailbox)
msg.WriteString(" SOME_INBOX` command.\n")
msg.WriteString("You will then be able to send emails to ")
@@ -332,7 +381,7 @@ func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
func (b *Bot) sendHelp(ctx context.Context) {
evt := eventFromContext(ctx)
cfg, serr := b.getRoomSettings(evt.RoomID)
cfg, serr := b.cfg.GetRoom(evt.RoomID)
if serr != nil {
b.log.Error("cannot retrieve settings: %v", serr)
}
@@ -344,7 +393,10 @@ func (b *Bot) sendHelp(ctx context.Context) {
continue
}
if cmd.key == "" {
msg.WriteString("\n---\n")
msg.WriteString("\n---\n\n")
msg.WriteString("#### ")
msg.WriteString(cmd.description)
msg.WriteString("\n")
continue
}
msg.WriteString("* **`")
@@ -360,7 +412,7 @@ func (b *Bot) sendHelp(ctx context.Context) {
case true:
msg.WriteString("(currently `")
msg.WriteString(value)
if cmd.key == roomOptionMailbox {
if cmd.key == config.RoomMailbox {
msg.WriteString(" (")
msg.WriteString(utils.EmailsList(value, cfg.Domain()))
msg.WriteString(")")
@@ -378,6 +430,7 @@ func (b *Bot) sendHelp(ctx context.Context) {
b.SendNotice(ctx, evt.RoomID, msg.String())
}
//nolint:gocognit // TODO
func (b *Bot) runSend(ctx context.Context) {
evt := eventFromContext(ctx)
if !b.allowSend(evt.Sender, evt.RoomID) {
@@ -398,9 +451,8 @@ func (b *Bot) runSend(ctx context.Context) {
b.prefix))
return
}
htmlBody := format.RenderMarkdown(body, true, true).FormattedBody
cfg, err := b.getRoomSettings(evt.RoomID)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve room settings: %v", err)
return
@@ -412,24 +464,30 @@ func (b *Bot) runSend(ctx context.Context) {
return
}
var htmlBody string
if !cfg.NoHTML() {
htmlBody = format.RenderMarkdown(body, true, true).FormattedBody
}
tos := strings.Split(to, ",")
// validate first
for _, to := range tos {
if !utils.AddressValid(to) {
if !email.AddressValid(to) {
b.Error(ctx, evt.RoomID, "email address is not valid")
return
}
}
b.lock(evt.RoomID.String())
defer b.unlock(evt.RoomID.String())
b.mu.Lock(evt.RoomID.String())
defer b.mu.Unlock(evt.RoomID.String())
domain := utils.SanitizeDomain(cfg.Domain())
from := mailbox + "@" + domain
ID := utils.MessageID(evt.ID, domain)
ID := email.MessageID(evt.ID, domain)
for _, to := range tos {
email := utils.NewEmail(ID, "", " "+ID, subject, from, to, body, htmlBody, nil)
data := email.Compose(b.getBotSettings().DKIMPrivateKey())
recipients := []string{to}
eml := email.New(ID, "", " "+ID, subject, from, to, to, "", body, htmlBody, nil)
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
if data == "" {
b.SendError(ctx, evt.RoomID, "email body is empty")
return
@@ -437,14 +495,14 @@ func (b *Bot) runSend(ctx context.Context) {
queued, err := b.Sendmail(evt.ID, from, to, data)
if queued {
b.log.Error("cannot send email: %v", err)
b.saveSentMetadata(ctx, queued, evt.ID, email, &cfg)
b.saveSentMetadata(ctx, queued, evt.ID, recipients, eml, cfg)
continue
}
if err != nil {
b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err)
continue
}
b.saveSentMetadata(ctx, false, evt.ID, email, &cfg)
b.saveSentMetadata(ctx, false, evt.ID, recipients, eml, cfg)
}
if len(tos) > 1 {
b.SendNotice(ctx, evt.RoomID, "All emails were sent.")

View File

@@ -11,12 +11,13 @@ import (
"gitlab.com/etke.cc/go/secgen"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/utils"
)
func (b *Bot) sendMailboxes(ctx context.Context) {
evt := eventFromContext(ctx)
mailboxes := map[string]roomSettings{}
mailboxes := map[string]config.Room{}
slice := []string{}
b.rooms.Range(func(key any, value any) bool {
if key == nil {
@@ -34,7 +35,7 @@ func (b *Bot) sendMailboxes(ctx context.Context) {
if !ok {
return true
}
config, err := b.getRoomSettings(roomID)
config, err := b.cfg.GetRoom(roomID)
if err != nil {
b.log.Error("cannot retrieve settings: %v", err)
}
@@ -80,7 +81,7 @@ func (b *Bot) runDelete(ctx context.Context, commandSlice []string) {
roomID := v.(id.RoomID)
b.rooms.Delete(mailbox)
err := b.setRoomSettings(roomID, roomSettings{})
err := b.cfg.SetRoom(roomID, config.Room{})
if err != nil {
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
return
@@ -91,7 +92,7 @@ func (b *Bot) runDelete(ctx context.Context, commandSlice []string) {
func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
users := cfg.Users()
@@ -122,9 +123,9 @@ func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
return
}
cfg.Set(botOptionUsers, strings.Join(patterns, " "))
cfg.Set(config.BotUsers, strings.Join(patterns, " "))
err = b.setBotSettings(cfg)
err = b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err)
}
@@ -134,10 +135,10 @@ func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
cfg := b.cfg.GetBot()
if len(commandSlice) > 1 && commandSlice[1] == "reset" {
cfg.Set(botOptionDKIMPrivateKey, "")
cfg.Set(botOptionDKIMSignature, "")
cfg.Set(config.BotDKIMPrivateKey, "")
cfg.Set(config.BotDKIMSignature, "")
}
signature := cfg.DKIMSignature()
@@ -149,9 +150,9 @@ func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
b.Error(ctx, evt.RoomID, "cannot generate DKIM signature: %v", derr)
return
}
cfg.Set(botOptionDKIMSignature, signature)
cfg.Set(botOptionDKIMPrivateKey, private)
err := b.setBotSettings(cfg)
cfg.Set(config.BotDKIMSignature, signature)
cfg.Set(config.BotDKIMPrivateKey, private)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
return
@@ -169,7 +170,7 @@ func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
@@ -198,8 +199,8 @@ func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
return
}
cfg.Set(botOptionCatchAll, mailbox)
err := b.setBotSettings(cfg)
cfg.Set(config.BotCatchAll, mailbox)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
return
@@ -208,9 +209,43 @@ func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s` (%s).", mailbox, utils.EmailsList(mailbox, "")))
}
func (b *Bot) runAdminRoom(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
if cfg.AdminRoom() != "" {
msg.WriteString(cfg.AdminRoom().String())
} else {
msg.WriteString("not set")
}
msg.WriteString("`\n\n")
msg.WriteString("Usage: `")
msg.WriteString(b.prefix)
msg.WriteString(" adminroom ROOM_ID`")
msg.WriteString("where ROOM_ID is valid and existing matrix room id\n")
b.SendNotice(ctx, evt.RoomID, msg.String())
return
}
roomID := b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case
cfg.Set(config.BotAdminRoom, roomID)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
return
}
b.adminRooms = append([]id.RoomID{id.RoomID(roomID)}, b.adminRooms...) // make it the first room in list on the fly
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Admin Room is set to: `%s`.", roomID))
}
func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) {
cfg := b.getBotSettings()
greylist := b.getGreylist()
cfg := b.cfg.GetBot()
greylist := b.cfg.GetGreylist()
var msg strings.Builder
size := len(greylist)
duration := cfg.Greylist()
@@ -218,7 +253,7 @@ func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) {
if duration == 0 {
msg.WriteString("disabled")
} else {
msg.WriteString(cfg.Get(botOptionGreylist))
msg.WriteString(cfg.Get(config.BotGreylist))
msg.WriteString("min")
}
msg.WriteString("`")
@@ -245,10 +280,10 @@ func (b *Bot) runGreylist(ctx context.Context, commandSlice []string) {
b.printGreylist(ctx, evt.RoomID)
return
}
cfg := b.getBotSettings()
cfg := b.cfg.GetBot()
value := utils.SanitizeIntString(commandSlice[1])
cfg.Set(botOptionGreylist, value)
err := b.setBotSettings(cfg)
cfg.Set(config.BotGreylist, value)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err)
}
@@ -257,14 +292,14 @@ func (b *Bot) runGreylist(ctx context.Context, commandSlice []string) {
func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
banlist := b.getBanlist()
banlist := b.cfg.GetBanlist()
var msg strings.Builder
size := len(banlist)
if size > 0 {
msg.WriteString("Currently: `")
msg.WriteString(cfg.Get(botOptionBanlistEnabled))
msg.WriteString(cfg.Get(config.BotBanlistEnabled))
msg.WriteString("`, total: ")
msg.WriteString(strconv.Itoa(size))
msg.WriteString(" hosts (`")
@@ -285,12 +320,11 @@ func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) {
return
}
value := utils.SanitizeBoolString(commandSlice[1])
cfg.Set(botOptionBanlistEnabled, value)
err := b.setBotSettings(cfg)
cfg.Set(config.BotBanlistEnabled, value)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err)
}
b.syncBanlist()
b.SendNotice(ctx, evt.RoomID, "banlist has been updated")
}
@@ -300,7 +334,7 @@ func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) {
b.runBanlist(ctx, commandSlice)
return
}
banlist := b.getBanlist()
banlist := b.cfg.GetBanlist()
ips := commandSlice[1:]
for _, ip := range ips {
@@ -312,7 +346,7 @@ func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) {
banlist.Add(addr)
}
err := b.setBanlist(banlist)
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err)
return
@@ -327,7 +361,7 @@ func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
b.runBanlist(ctx, commandSlice)
return
}
banlist := b.getBanlist()
banlist := b.cfg.GetBanlist()
ips := commandSlice[1:]
for _, ip := range ips {
@@ -339,7 +373,7 @@ func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
banlist.Remove(addr)
}
err := b.setBanlist(banlist)
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err)
return
@@ -351,7 +385,7 @@ func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
func (b *Bot) runBanlistReset(ctx context.Context) {
evt := eventFromContext(ctx)
err := b.setBanlist(bglist{})
err := b.cfg.SetBanlist(config.List{})
if err != nil {
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err)
return

View File

@@ -3,21 +3,23 @@ package bot
import (
"context"
"fmt"
"strconv"
"github.com/raja/argon2pw"
"gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/utils"
)
func (b *Bot) runStop(ctx context.Context) {
evt := eventFromContext(ctx)
cfg, err := b.getRoomSettings(evt.RoomID)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err)
return
}
mailbox := cfg.Get(roomOptionMailbox)
mailbox := cfg.Get(config.RoomMailbox)
if mailbox == "" {
b.SendNotice(ctx, evt.RoomID, "that room is not configured yet")
return
@@ -25,7 +27,7 @@ func (b *Bot) runStop(ctx context.Context) {
b.rooms.Delete(mailbox)
err = b.setRoomSettings(evt.RoomID, roomSettings{})
err = b.cfg.SetRoom(evt.RoomID, config.Room{})
if err != nil {
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
return
@@ -44,7 +46,7 @@ func (b *Bot) handleOption(ctx context.Context, cmd []string) {
func (b *Bot) getOption(ctx context.Context, name string) {
evt := eventFromContext(ctx)
cfg, err := b.getRoomSettings(evt.RoomID)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err)
return
@@ -59,14 +61,14 @@ func (b *Bot) getOption(ctx context.Context, name string) {
return
}
if name == roomOptionMailbox {
if name == config.RoomMailbox {
value = utils.EmailsList(value, cfg.Domain())
}
msg := fmt.Sprintf("`%s` of this room is `%s`\n"+
"To set it to a new value, send a `%s %s VALUE` command.",
name, value, b.prefix, name)
if name == roomOptionPassword {
if name == config.RoomPassword {
msg = fmt.Sprintf("There is an SMTP password already set for this room/mailbox. "+
"It's stored in a secure hashed manner, so we can't tell you what the original raw password was. "+
"To find the raw password, try to find your old message which had originally set it, "+
@@ -84,21 +86,25 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
}
evt := eventFromContext(ctx)
if name == roomOptionMailbox {
// ignore request
if name == config.RoomActive {
return
}
if name == config.RoomMailbox {
existingID, ok := b.getMapping(value)
if ok && existingID != "" && existingID != evt.RoomID {
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.getRoomSettings(evt.RoomID)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err)
return
}
if name == roomOptionPassword {
if name == config.RoomPassword {
value = b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case
value, err = argon2pw.GenerateSaltedHash(value)
if err != nil {
@@ -110,23 +116,24 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
old := cfg.Get(name)
cfg.Set(name, value)
if name == roomOptionMailbox {
cfg.Set(roomOptionOwner, evt.Sender.String())
if name == config.RoomMailbox {
cfg.Set(config.RoomOwner, evt.Sender.String())
if old != "" {
b.rooms.Delete(old)
}
b.rooms.Store(value, evt.RoomID)
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.setRoomSettings(evt.RoomID, cfg)
err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
return
}
msg := fmt.Sprintf("`%s` of this room set to `%s`", name, value)
if name == roomOptionPassword {
if name == config.RoomPassword {
msg = "SMTP password has been set"
}
b.SendNotice(ctx, evt.RoomID, msg)

92
bot/config/bot.go Normal file
View File

@@ -0,0 +1,92 @@
package config
import (
"strings"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data key
const acBotKey = "cc.etke.postmoogle.config"
// bot options keys
const (
BotAdminRoom = "adminroom"
BotUsers = "users"
BotCatchAll = "catch-all"
BotDKIMSignature = "dkim.pub"
BotDKIMPrivateKey = "dkim.pem"
BotQueueBatch = "queue:batch"
BotQueueRetries = "queue:retries"
BotBanlistEnabled = "banlist:enabled"
BotGreylist = "greylist"
)
// Bot map
type Bot map[string]string
// Get option
func (s Bot) Get(key string) string {
return s[strings.ToLower(strings.TrimSpace(key))]
}
// Set option
func (s Bot) Set(key, value string) {
s[strings.ToLower(strings.TrimSpace(key))] = value
}
// Users option
func (s Bot) Users() []string {
value := s.Get(BotUsers)
if value == "" {
return []string{}
}
if strings.Contains(value, " ") {
return strings.Split(value, " ")
}
return []string{value}
}
// CatchAll option
func (s Bot) CatchAll() string {
return s.Get(BotCatchAll)
}
// AdminRoom option
func (s Bot) AdminRoom() id.RoomID {
return id.RoomID(s.Get(BotAdminRoom))
}
// BanlistEnabled option
func (s Bot) BanlistEnabled() bool {
return utils.Bool(s.Get(BotBanlistEnabled))
}
// Greylist option (duration in minutes)
func (s Bot) Greylist() int {
return utils.Int(s.Get(BotGreylist))
}
// DKIMSignature (DNS TXT record)
func (s Bot) DKIMSignature() string {
return s.Get(BotDKIMSignature)
}
// DKIMPrivateKey keep it secret
func (s Bot) DKIMPrivateKey() string {
return s.Get(BotDKIMPrivateKey)
}
// QueueBatch option
func (s Bot) QueueBatch() int {
return utils.Int(s.Get(BotQueueBatch))
}
// QueueRetries option
func (s Bot) QueueRetries() int {
return utils.Int(s.Get(BotQueueRetries))
}

69
bot/config/lists.go Normal file
View File

@@ -0,0 +1,69 @@
package config
import (
"net"
"sort"
"time"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data keys
const (
acBanlistKey = "cc.etke.postmoogle.banlist"
acGreylistKey = "cc.etke.postmoogle.greylist"
)
// List config
type List map[string]string
// Slice returns slice of ban- or greylist items
func (l List) Slice() []string {
slice := make([]string, 0, len(l))
for item := range l {
slice = append(slice, item)
}
sort.Strings(slice)
return slice
}
// Has addr in ban- or greylist
func (l List) Has(addr net.Addr) bool {
_, ok := l[utils.AddrIP(addr)]
return ok
}
// Get when addr was added in ban- or greylist
func (l List) Get(addr net.Addr) (time.Time, bool) {
from := l[utils.AddrIP(addr)]
if from == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC1123Z, from)
if err != nil {
return time.Time{}, false
}
return t, true
}
// Add an addr to ban- or greylist
func (l List) Add(addr net.Addr) {
key := utils.AddrIP(addr)
if _, ok := l[key]; ok {
return
}
l[key] = time.Now().UTC().Format(time.RFC1123Z)
}
// Remove an addr from ban- or greylist
func (l List) Remove(addr net.Addr) {
key := utils.AddrIP(addr)
if _, ok := l[key]; !ok {
return
}
delete(l, key)
}

120
bot/config/manager.go Normal file
View File

@@ -0,0 +1,120 @@
package config
import (
"fmt"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// Manager of configs
type Manager struct {
bl List
ble bool
mu utils.Mutex
log *logger.Logger
lp *linkpearl.Linkpearl
}
// New config manager
func New(lp *linkpearl.Linkpearl, log *logger.Logger) *Manager {
m := &Manager{
mu: utils.NewMutex(),
bl: make(List, 0),
lp: lp,
log: log,
}
m.ble = m.GetBot().BanlistEnabled()
return m
}
// GetBot config
func (m *Manager) GetBot() Bot {
config, err := m.lp.GetAccountData(acBotKey)
if err != nil {
m.log.Error("cannot get bot settings: %v", utils.UnwrapError(err))
}
if config == nil {
config = make(Bot, 0)
}
return config
}
// SetBot config
func (m *Manager) SetBot(cfg Bot) error {
m.ble = cfg.BanlistEnabled()
return utils.UnwrapError(m.lp.SetAccountData(acBotKey, cfg))
}
// GetRoom config
func (m *Manager) GetRoom(roomID id.RoomID) (Room, error) {
config, err := m.lp.GetRoomAccountData(roomID, acRoomKey)
if config == nil {
config = make(Room, 0)
}
return config, utils.UnwrapError(err)
}
// SetRoom config
func (m *Manager) SetRoom(roomID id.RoomID, cfg Room) error {
return utils.UnwrapError(m.lp.SetRoomAccountData(roomID, acRoomKey, cfg))
}
// GetBanlist config
func (m *Manager) GetBanlist() List {
if len(m.bl) > 0 || !m.ble {
return m.bl
}
m.mu.Lock("banlist")
defer m.mu.Unlock("banlist")
config, err := m.lp.GetAccountData(acBanlistKey)
if err != nil {
m.log.Error("cannot get banlist: %v", utils.UnwrapError(err))
}
if config == nil {
config = make(List, 0)
}
m.bl = config
return config
}
// SetBanlist config
func (m *Manager) SetBanlist(cfg List) error {
if !m.ble {
return fmt.Errorf("banlist is disabled, kupo")
}
m.mu.Lock("banlist")
if cfg == nil {
cfg = make(List, 0)
}
m.bl = cfg
defer m.mu.Unlock("banlist")
return utils.UnwrapError(m.lp.SetAccountData(acBanlistKey, cfg))
}
// GetGreylist config
func (m *Manager) GetGreylist() List {
config, err := m.lp.GetAccountData(acGreylistKey)
if err != nil {
m.log.Error("cannot get banlist: %v", utils.UnwrapError(err))
}
if config == nil {
config = make(List, 0)
}
return config
}
// SetGreylist config
func (m *Manager) SetGreylist(cfg List) error {
return utils.UnwrapError(m.lp.SetAccountData(acGreylistKey, cfg))
}

183
bot/config/room.go Normal file
View File

@@ -0,0 +1,183 @@
package config
import (
"strings"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data key
const acRoomKey = "cc.etke.postmoogle.settings"
type Room map[string]string
// option keys
const (
RoomActive = ".active"
RoomOwner = "owner"
RoomMailbox = "mailbox"
RoomDomain = "domain"
RoomNoSend = "nosend"
RoomNoCC = "nocc"
RoomNoSender = "nosender"
RoomNoRecipient = "norecipient"
RoomNoSubject = "nosubject"
RoomNoHTML = "nohtml"
RoomNoThreads = "nothreads"
RoomNoFiles = "nofiles"
RoomPassword = "password"
RoomSpamcheckDKIM = "spamcheck:dkim"
RoomSpamcheckSMTP = "spamcheck:smtp"
RoomSpamcheckSPF = "spamcheck:spf"
RoomSpamcheckMX = "spamcheck:mx"
RoomSpamlist = "spamlist"
)
// Get option
func (s Room) Get(key string) string {
return s[strings.ToLower(strings.TrimSpace(key))]
}
// Set option
func (s Room) Set(key, value string) {
s[strings.ToLower(strings.TrimSpace(key))] = value
}
func (s Room) Mailbox() string {
return s.Get(RoomMailbox)
}
func (s Room) Domain() string {
return s.Get(RoomDomain)
}
func (s Room) Owner() string {
return s.Get(RoomOwner)
}
func (s Room) Active() bool {
return utils.Bool(s.Get(RoomActive))
}
func (s Room) Password() string {
return s.Get(RoomPassword)
}
func (s Room) NoSend() bool {
return utils.Bool(s.Get(RoomNoSend))
}
func (s Room) NoCC() bool {
return utils.Bool(s.Get(RoomNoCC))
}
func (s Room) NoSender() bool {
return utils.Bool(s.Get(RoomNoSender))
}
func (s Room) NoRecipient() bool {
return utils.Bool(s.Get(RoomNoRecipient))
}
func (s Room) NoSubject() bool {
return utils.Bool(s.Get(RoomNoSubject))
}
func (s Room) NoHTML() bool {
return utils.Bool(s.Get(RoomNoHTML))
}
func (s Room) NoThreads() bool {
return utils.Bool(s.Get(RoomNoThreads))
}
func (s Room) NoFiles() bool {
return utils.Bool(s.Get(RoomNoFiles))
}
func (s Room) SpamcheckDKIM() bool {
return utils.Bool(s.Get(RoomSpamcheckDKIM))
}
func (s Room) SpamcheckSMTP() bool {
return utils.Bool(s.Get(RoomSpamcheckSMTP))
}
func (s Room) SpamcheckSPF() bool {
return utils.Bool(s.Get(RoomSpamcheckSPF))
}
func (s Room) SpamcheckMX() bool {
return utils.Bool(s.Get(RoomSpamcheckMX))
}
func (s Room) Spamlist() []string {
return utils.StringSlice(s.Get(RoomSpamlist))
}
func (s Room) MigrateSpamlistSettings() {
uniq := map[string]struct{}{}
emails := utils.StringSlice(s.Get("spamlist:emails"))
localparts := utils.StringSlice(s.Get("spamlist:localparts"))
hosts := utils.StringSlice(s.Get("spamlist:hosts"))
list := utils.StringSlice(s.Get(RoomSpamlist))
delete(s, "spamlist:emails")
delete(s, "spamlist:localparts")
delete(s, "spamlist:hosts")
for _, email := range emails {
if email == "" {
continue
}
uniq[email] = struct{}{}
}
for _, localpart := range localparts {
if localpart == "" {
continue
}
uniq[localpart+"@*"] = struct{}{}
}
for _, host := range hosts {
if host == "" {
continue
}
uniq["*@"+host] = struct{}{}
}
for _, item := range list {
if item == "" {
continue
}
uniq[item] = struct{}{}
}
spamlist := make([]string, 0, len(uniq))
for item := range uniq {
spamlist = append(spamlist, item)
}
s.Set(RoomSpamlist, strings.Join(spamlist, ","))
}
// ContentOptions converts room display settings to content options
func (s Room) ContentOptions() *email.ContentOptions {
return &email.ContentOptions{
CC: !s.NoCC(),
HTML: !s.NoHTML(),
Sender: !s.NoSender(),
Recipient: !s.NoRecipient(),
Subject: !s.NoSubject(),
Threads: !s.NoThreads(),
ToKey: "cc.etke.postmoogle.to",
CcKey: "cc.etke.postmoogle.cc",
FromKey: "cc.etke.postmoogle.from",
RcptToKey: "cc.etke.postmoogle.rcptTo",
SubjectKey: "cc.etke.postmoogle.subject",
InReplyToKey: "cc.etke.postmoogle.inReplyTo",
MessageIDKey: "cc.etke.postmoogle.messageID",
ReferencesKey: "cc.etke.postmoogle.references",
}
}

View File

@@ -1,5 +1,11 @@
package bot
import (
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/bot/config"
)
var migrations = []string{}
func (b *Bot) migrate() error {
@@ -32,32 +38,66 @@ func (b *Bot) migrate() error {
}
func (b *Bot) syncRooms() error {
adminRoom := b.cfg.GetBot().AdminRoom()
if adminRoom != "" {
b.adminRooms = append(b.adminRooms, adminRoom)
}
resp, err := b.lp.GetClient().JoinedRooms()
if err != nil {
return err
}
for _, roomID := range resp.JoinedRooms {
cfg, serr := b.getRoomSettings(roomID)
b.migrateRoomSettings(roomID)
cfg, serr := b.cfg.GetRoom(roomID)
if serr != nil {
continue
}
b.migrateRoomSettings(roomID)
mailbox := cfg.Mailbox()
if mailbox != "" {
active := cfg.Active()
if mailbox != "" && active {
b.rooms.Store(mailbox, roomID)
}
if cfg.Owner() != "" && b.allowAdmin(id.UserID(cfg.Owner()), "") {
b.adminRooms = append(b.adminRooms, roomID)
}
}
return nil
}
func (b *Bot) syncBanlist() {
b.lock("banlist")
defer b.unlock("banlist")
if !b.getBotSettings().BanlistEnabled() {
b.banlist = make(bglist, 0)
func (b *Bot) migrateRoomSettings(roomID id.RoomID) {
cfg, err := b.cfg.GetRoom(roomID)
if err != nil {
b.log.Error("cannot retrieve room settings: %v", err)
return
}
b.banlist = b.getBanlist()
if _, ok := cfg[config.RoomActive]; !ok {
cfg.Set(config.RoomActive, "true")
}
if cfg["spamlist:emails"] == "" && cfg["spamlist:localparts"] == "" && cfg["spamlist:hosts"] == "" {
return
}
cfg.MigrateSpamlistSettings()
err = b.cfg.SetRoom(roomID, cfg)
if err != nil {
b.log.Error("cannot migrate room settings: %v", err)
}
}
func (b *Bot) initBotUsers() ([]string, error) {
cfg := b.cfg.GetBot()
cfgUsers := cfg.Users()
if len(cfgUsers) > 0 {
return cfgUsers, nil
}
_, homeserver, err := b.lp.GetClient().UserID.Parse()
if err != nil {
return nil, err
}
cfg.Set(config.BotUsers, "@*:"+homeserver)
return cfg.Users(), b.cfg.SetBot(cfg)
}

View File

@@ -9,12 +9,13 @@ import (
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data keys
const (
acQueueKey = "cc.etke.postmoogle.mailqueue"
acMessagePrefix = "cc.etke.postmoogle.message"
acLastEventPrefix = "cc.etke.postmoogle.last"
)
@@ -25,13 +26,16 @@ const (
eventReferencesKey = "cc.etke.postmoogle.references"
eventInReplyToKey = "cc.etke.postmoogle.inReplyTo"
eventSubjectKey = "cc.etke.postmoogle.subject"
eventRcptToKey = "cc.etke.postmoogle.rcptTo"
eventFromKey = "cc.etke.postmoogle.from"
eventToKey = "cc.etke.postmoogle.to"
eventCcKey = "cc.etke.postmoogle.cc"
)
// SetSendmail sets mail sending func to the bot
func (b *Bot) SetSendmail(sendmail func(string, string, string) error) {
b.sendmail = sendmail
b.q.SetSendmail(sendmail)
}
// Sendmail tries to send email immediately, but if it gets 4xx error (greylisting),
@@ -41,7 +45,7 @@ func (b *Bot) Sendmail(eventID id.EventID, from, to, data string) (bool, error)
if err != nil {
if strings.HasPrefix(err.Error(), "4") {
b.log.Debug("email %s (from=%s to=%s) was added to the queue: %v", eventID, from, to, err)
return true, b.enqueueEmail(eventID.String(), from, to, data)
return true, b.q.Add(eventID.String(), from, to, data)
}
return false, err
}
@@ -51,7 +55,7 @@ func (b *Bot) Sendmail(eventID id.EventID, from, to, data string) (bool, error)
// GetDKIMprivkey returns DKIM private key
func (b *Bot) GetDKIMprivkey() string {
return b.getBotSettings().DKIMPrivateKey()
return b.cfg.GetBot().DKIMPrivateKey()
}
func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
@@ -72,7 +76,7 @@ func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
roomID, ok := b.getMapping(mailbox)
if !ok {
catchAll := b.getBotSettings().CatchAll()
catchAll := b.cfg.GetBot().CatchAll()
if catchAll == "" {
return roomID, ok
}
@@ -83,29 +87,28 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
}
// GetIFOptions returns incoming email filtering options (room settings)
func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions {
cfg, err := b.getRoomSettings(roomID)
func (b *Bot) GetIFOptions(roomID id.RoomID) email.IncomingFilteringOptions {
cfg, err := b.cfg.GetRoom(roomID)
if err != nil {
b.log.Error("cannot retrieve room settings: %v", err)
return roomSettings{}
}
return cfg
}
// IncomingEmail sends incoming email to matrix room
func (b *Bot) IncomingEmail(ctx context.Context, email *utils.Email) error {
func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error {
roomID, ok := b.GetMapping(email.Mailbox(true))
if !ok {
return errors.New("room not found")
}
cfg, err := b.getRoomSettings(roomID)
cfg, err := b.cfg.GetRoom(roomID)
if err != nil {
b.Error(ctx, roomID, "cannot get settings: %v", err)
}
b.lock(roomID.String())
defer b.unlock(roomID.String())
b.mu.Lock(roomID.String())
defer b.mu.Unlock(roomID.String())
var threadID id.EventID
if email.InReplyTo != "" || email.References != "" {
@@ -137,7 +140,10 @@ func (b *Bot) IncomingEmail(ctx context.Context, email *utils.Email) error {
// SendEmailReply sends replies from matrix thread to email thread
func (b *Bot) SendEmailReply(ctx context.Context) {
evt := eventFromContext(ctx)
cfg, err := b.getRoomSettings(evt.RoomID)
if !b.allowSend(evt.Sender, evt.RoomID) {
return
}
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot retrieve room settings: %v", err)
return
@@ -147,18 +153,11 @@ func (b *Bot) SendEmailReply(ctx context.Context) {
b.Error(ctx, evt.RoomID, "mailbox is not configured, kupo")
return
}
domain := utils.SanitizeDomain(cfg.Domain())
b.lock(evt.RoomID.String())
defer b.unlock(evt.RoomID.String())
b.mu.Lock(evt.RoomID.String())
defer b.mu.Unlock(evt.RoomID.String())
fromMailbox := mailbox + "@" + domain
meta := b.getParentEmail(evt, domain)
// when email was sent from matrix and reply was sent from matrix again
if fromMailbox != meta.From {
meta.To = meta.From
}
meta.From = fromMailbox
meta := b.getParentEmail(evt, mailbox)
if meta.To == "" {
b.Error(ctx, evt.RoomID, "cannot find parent email and continue the thread. Please, start a new email thread")
@@ -173,43 +172,112 @@ func (b *Bot) SendEmailReply(ctx context.Context) {
meta.Subject = strings.SplitN(content.Body, "\n", 1)[0]
}
body := content.Body
htmlBody := content.FormattedBody
var htmlBody string
if !cfg.NoHTML() {
htmlBody = content.FormattedBody
}
meta.MessageID = utils.MessageID(evt.ID, domain)
meta.MessageID = email.MessageID(evt.ID, meta.FromDomain)
meta.References = meta.References + " " + meta.MessageID
b.log.Debug("send email reply: %+v", meta)
email := utils.NewEmail(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, body, htmlBody, nil)
data := email.Compose(b.getBotSettings().DKIMPrivateKey())
eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil)
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
if data == "" {
b.SendError(ctx, evt.RoomID, "email body is empty")
return
}
queued, err := b.Sendmail(evt.ID, meta.From, meta.To, data)
if queued {
b.log.Error("cannot send email: %v", err)
b.saveSentMetadata(ctx, queued, meta.ThreadID, email, &cfg)
return
var queued bool
var hasErr bool
recipients := meta.Recipients()
for _, to := range recipients {
queued, err = b.Sendmail(evt.ID, meta.From, to, data)
if queued {
b.log.Error("cannot send email: %v", err)
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg)
hasErr = true
continue
}
if err != nil {
b.Error(ctx, evt.RoomID, "cannot send email: %v", err)
hasErr = true
continue
}
}
if err != nil {
b.Error(ctx, evt.RoomID, "cannot send email: %v", err)
return
if !hasErr {
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg)
}
b.saveSentMetadata(ctx, queued, meta.ThreadID, email, &cfg)
}
type parentEmail struct {
MessageID string
ThreadID id.EventID
From string
FromDomain string
To string
RcptTo string
CC string
InReplyTo string
References string
Subject string
}
// fixtofrom attempts to "fix" or rather reverse the To, From and CC headers
// of parent email by using parent email as metadata source for a new email
// that will be sent from postmoogle.
// To do so, we need to reverse From and To headers, but Cc should be adjusted as well,
// thus that hacky workaround below:
func (e *parentEmail) fixtofrom(newSenderMailbox string, domains []string) {
newSenders := make(map[string]string, len(domains))
for _, domain := range domains {
sender := newSenderMailbox + "@" + domain
newSenders[sender] = sender
}
// try to determine previous email of the room mailbox
// by matching RCPT TO, To and From fields
// why? Because of possible multi-domain setup and we won't leak information
var previousSender string
rcptToSender, ok := newSenders[e.RcptTo]
if ok {
previousSender = rcptToSender
}
toSender, ok := newSenders[e.To]
if ok {
previousSender = toSender
}
fromSender, ok := newSenders[e.From]
if ok {
previousSender = fromSender
}
// Message-Id should not leak information either
e.FromDomain = utils.SanitizeDomain(utils.Hostname(previousSender))
originalFrom := e.From
// reverse From if needed
if fromSender == "" {
e.From = previousSender
}
// reverse To if needed
if toSender != "" {
e.To = originalFrom
}
// replace previous recipient of the email which is sender now with the original From
for newSender := range newSenders {
if strings.Contains(e.CC, newSender) {
e.CC = strings.ReplaceAll(e.CC, newSender, originalFrom)
}
}
}
// Recipients returns list of recipients (to, cc)
func (e parentEmail) Recipients() []string {
return append(email.AddressList(e.CC), e.To)
}
func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) {
content := evt.Content.AsMessage()
threadID := utils.EventParent(evt.ID, content)
@@ -246,8 +314,8 @@ func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) {
return threadID, decrypted
}
func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail {
var parent parentEmail
func (b *Bot) getParentEmail(evt *event.Event, newFromMailbox string) *parentEmail {
parent := &parentEmail{}
threadID, parentEvt := b.getParentEvent(evt)
parent.ThreadID = threadID
if parentEvt == nil {
@@ -257,11 +325,14 @@ func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail {
return parent
}
parent.MessageID = utils.MessageID(parentEvt.ID, domain)
parent.From = utils.EventField[string](&parentEvt.Content, eventFromKey)
parent.To = utils.EventField[string](&parentEvt.Content, eventToKey)
parent.CC = utils.EventField[string](&parentEvt.Content, eventCcKey)
parent.RcptTo = utils.EventField[string](&parentEvt.Content, eventRcptToKey)
parent.InReplyTo = utils.EventField[string](&parentEvt.Content, eventMessageIDkey)
parent.References = utils.EventField[string](&parentEvt.Content, eventReferencesKey)
parent.fixtofrom(newFromMailbox, b.domains)
parent.MessageID = email.MessageID(parentEvt.ID, parent.FromDomain)
if parent.InReplyTo == "" {
parent.InReplyTo = parent.MessageID
}
@@ -281,14 +352,15 @@ func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail {
// 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
func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, email *utils.Email, cfg *roomSettings) {
text := "Email has been sent to " + email.To
func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, recipients []string, eml *email.Email, cfg config.Room) {
addrs := strings.Join(recipients, ", ")
text := "Email has been sent to " + addrs
if queued {
text = "Email to " + email.To + " has been queued"
text = "Email to " + addrs + " has been queued"
}
evt := eventFromContext(ctx)
content := email.Content(threadID, cfg.ContentOptions())
content := eml.Content(threadID, cfg.ContentOptions())
notice := format.RenderMarkdown(text, true, true)
msgContent, ok := content.Parsed.(*event.MessageEventContent)
if !ok {
@@ -305,8 +377,8 @@ func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.Eve
return
}
domain := utils.SanitizeDomain(cfg.Domain())
b.setThreadID(evt.RoomID, utils.MessageID(evt.ID, domain), threadID)
b.setThreadID(evt.RoomID, utils.MessageID(msgID, domain), threadID)
b.setThreadID(evt.RoomID, email.MessageID(evt.ID, domain), threadID)
b.setThreadID(evt.RoomID, email.MessageID(msgID, domain), threadID)
b.setLastEventID(evt.RoomID, threadID, msgID)
}

View File

@@ -1,30 +0,0 @@
package bot
import (
"context"
"strings"
)
func (b *Bot) handle(ctx context.Context) {
evt := eventFromContext(ctx)
err := b.lp.GetClient().MarkRead(evt.RoomID, evt.ID)
if err != nil {
b.log.Error("cannot send read receipt: %v", err)
}
content := evt.Content.AsMessage()
if content == nil {
b.Error(ctx, evt.RoomID, "cannot read message")
return
}
message := strings.TrimSpace(content.Body)
cmd := b.parseCommand(message, true)
if cmd == nil {
if content.RelatesTo != nil {
b.SendEmailReply(ctx)
}
return
}
b.handleCommand(ctx, evt, cmd)
}

View File

@@ -1,24 +0,0 @@
package bot
import (
"sync"
)
func (b *Bot) lock(key string) {
_, ok := b.mu[key]
if !ok {
b.mu[key] = &sync.Mutex{}
}
b.mu[key].Lock()
}
func (b *Bot) unlock(key string) {
_, ok := b.mu[key]
if !ok {
return
}
b.mu[key].Unlock()
delete(b.mu, key)
}

View File

@@ -1,153 +0,0 @@
package bot
import (
"strconv"
)
const (
defaultQueueBatch = 1
defaultQueueRetries = 3
)
// ProcessQueue starts queue processing
func (b *Bot) ProcessQueue() {
b.log.Debug("staring queue processing...")
cfg := b.getBotSettings()
batchSize := cfg.QueueBatch()
if batchSize == 0 {
batchSize = defaultQueueBatch
}
retries := cfg.QueueRetries()
if retries == 0 {
retries = defaultQueueRetries
}
b.popqueue(batchSize, retries)
b.log.Debug("ended queue processing")
}
// popqueue gets emails from queue and tries to send them,
// if an email was sent successfully - it will be removed from queue
func (b *Bot) popqueue(batchSize, maxTries int) {
b.lock(acQueueKey)
defer b.unlock(acQueueKey)
index, err := b.lp.GetAccountData(acQueueKey)
if err != nil {
b.log.Error("cannot get queue index: %v", err)
}
i := 0
for id, itemkey := range index {
if i > batchSize {
b.log.Debug("finished re-deliveries from queue")
return
}
if dequeue := b.processQueueItem(itemkey, maxTries); dequeue {
b.log.Debug("email %s has been delivered", id)
err = b.dequeueEmail(id)
if err != nil {
b.log.Error("cannot dequeue email %s: %v", id, err)
}
}
i++
}
}
// processQueueItem tries to process an item from queue
// returns should the item be dequeued or not
func (b *Bot) processQueueItem(itemkey string, maxRetries int) bool {
b.lock(itemkey)
defer b.unlock(itemkey)
item, err := b.lp.GetAccountData(itemkey)
if err != nil {
b.log.Error("cannot retrieve a queue item %s: %v", itemkey, err)
return false
}
b.log.Debug("processing queue item %+v", item)
attempts, err := strconv.Atoi(item["attempts"])
if err != nil {
b.log.Error("cannot parse attempts of %s: %v", itemkey, err)
return false
}
if attempts > maxRetries {
return true
}
err = b.sendmail(item["from"], item["to"], item["data"])
if err == nil {
b.log.Debug("email %s from queue was delivered")
return true
}
b.log.Debug("attempted to deliver email id=%s, retry=%s, but it's not ready yet: %v", item["id"], item["attempts"], err)
attempts++
item["attempts"] = strconv.Itoa(attempts)
err = b.lp.SetAccountData(itemkey, item)
if err != nil {
b.log.Error("cannot update attempt count on email %s: %v", itemkey, err)
}
return false
}
// enqueueEmail adds an email to the queue
func (b *Bot) enqueueEmail(id, from, to, data string) error {
itemkey := acQueueKey + "." + id
item := map[string]string{
"attempts": "0",
"data": data,
"from": from,
"to": to,
"id": id,
}
b.lock(itemkey)
defer b.unlock(itemkey)
err := b.lp.SetAccountData(itemkey, item)
if err != nil {
b.log.Error("cannot enqueue email id=%s: %v", id, err)
return err
}
b.lock(acQueueKey)
defer b.unlock(acQueueKey)
queueIndex, err := b.lp.GetAccountData(acQueueKey)
if err != nil {
b.log.Error("cannot get queue index: %v", err)
return err
}
queueIndex[id] = itemkey
err = b.lp.SetAccountData(acQueueKey, queueIndex)
if err != nil {
b.log.Error("cannot save queue index: %v", err)
return err
}
return nil
}
// dequeueEmail removes an email from the queue
func (b *Bot) dequeueEmail(id string) error {
index, err := b.lp.GetAccountData(acQueueKey)
if err != nil {
b.log.Error("cannot get queue index: %v", err)
return err
}
itemkey := index[id]
if itemkey == "" {
itemkey = acQueueKey + "." + id
}
delete(index, id)
err = b.lp.SetAccountData(acQueueKey, index)
if err != nil {
b.log.Error("cannot update queue index: %v", err)
return err
}
b.lock(itemkey)
defer b.unlock(itemkey)
return b.lp.SetAccountData(itemkey, nil)
}

79
bot/queue/manager.go Normal file
View File

@@ -0,0 +1,79 @@
package queue
import (
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/linkpearl"
"gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/utils"
)
const (
acQueueKey = "cc.etke.postmoogle.mailqueue"
defaultQueueBatch = 1
defaultQueueRetries = 3
)
// Queue manager
type Queue struct {
mu utils.Mutex
lp *linkpearl.Linkpearl
cfg *config.Manager
log *logger.Logger
sendmail func(string, string, string) error
}
// New queue
func New(lp *linkpearl.Linkpearl, cfg *config.Manager, log *logger.Logger) *Queue {
return &Queue{
mu: utils.Mutex{},
lp: lp,
cfg: cfg,
log: log,
}
}
// SetSendmail func
func (q *Queue) SetSendmail(function func(string, string, string) error) {
q.sendmail = function
}
// Process queue
func (q *Queue) Process() {
q.log.Debug("staring queue processing...")
cfg := q.cfg.GetBot()
batchSize := cfg.QueueBatch()
if batchSize == 0 {
batchSize = defaultQueueBatch
}
maxRetries := cfg.QueueRetries()
if maxRetries == 0 {
maxRetries = defaultQueueRetries
}
q.mu.Lock(acQueueKey)
defer q.mu.Unlock(acQueueKey)
index, err := q.lp.GetAccountData(acQueueKey)
if err != nil {
q.log.Error("cannot get queue index: %v", err)
}
i := 0
for id, itemkey := range index {
if i > batchSize {
q.log.Debug("finished re-deliveries from queue")
return
}
if dequeue := q.try(itemkey, maxRetries); dequeue {
q.log.Debug("email %q has been delivered", id)
err = q.Remove(id)
if err != nil {
q.log.Error("cannot dequeue email %q: %v", id, err)
}
}
i++
}
q.log.Debug("ended queue processing")
}

101
bot/queue/queue.go Normal file
View File

@@ -0,0 +1,101 @@
package queue
import (
"strconv"
)
// Add to queue
func (q *Queue) Add(id, from, to, data string) error {
itemkey := acQueueKey + "." + id
item := map[string]string{
"attempts": "0",
"data": data,
"from": from,
"to": to,
"id": id,
}
q.mu.Lock(itemkey)
defer q.mu.Unlock(itemkey)
err := q.lp.SetAccountData(itemkey, item)
if err != nil {
q.log.Error("cannot enqueue email id=%q: %v", id, err)
return err
}
q.mu.Lock(acQueueKey)
defer q.mu.Unlock(acQueueKey)
queueIndex, err := q.lp.GetAccountData(acQueueKey)
if err != nil {
q.log.Error("cannot get queue index: %v", err)
return err
}
queueIndex[id] = itemkey
err = q.lp.SetAccountData(acQueueKey, queueIndex)
if err != nil {
q.log.Error("cannot save queue index: %v", err)
return err
}
return nil
}
// Remove from queue
func (q *Queue) Remove(id string) error {
index, err := q.lp.GetAccountData(acQueueKey)
if err != nil {
q.log.Error("cannot get queue index: %v", err)
return err
}
itemkey := index[id]
if itemkey == "" {
itemkey = acQueueKey + "." + id
}
delete(index, id)
err = q.lp.SetAccountData(acQueueKey, index)
if err != nil {
q.log.Error("cannot update queue index: %v", err)
return err
}
q.mu.Lock(itemkey)
defer q.mu.Unlock(itemkey)
return q.lp.SetAccountData(itemkey, nil)
}
// try to send email
func (q *Queue) try(itemkey string, maxRetries int) bool {
q.mu.Lock(itemkey)
defer q.mu.Unlock(itemkey)
item, err := q.lp.GetAccountData(itemkey)
if err != nil {
q.log.Error("cannot retrieve a queue item %q: %v", itemkey, err)
return false
}
q.log.Debug("processing queue item %+v", item)
attempts, err := strconv.Atoi(item["attempts"])
if err != nil {
q.log.Error("cannot parse attempts of %q: %v", itemkey, err)
return false
}
if attempts > maxRetries {
return true
}
err = q.sendmail(item["from"], item["to"], item["data"])
if err == nil {
q.log.Debug("email %q from queue was delivered")
return true
}
q.log.Debug("attempted to deliver email id=%q, retry=%q, but it's not ready yet: %v", item["id"], item["attempts"], err)
attempts++
item["attempts"] = strconv.Itoa(attempts)
err = q.lp.SetAccountData(itemkey, item)
if err != nil {
q.log.Error("cannot update attempt count on email %q: %v", itemkey, err)
}
return false
}

View File

@@ -1,114 +0,0 @@
package bot
import (
"strings"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data key
const acBotSettingsKey = "cc.etke.postmoogle.config"
// bot options keys
const (
botOptionUsers = "users"
botOptionCatchAll = "catch-all"
botOptionDKIMSignature = "dkim.pub"
botOptionDKIMPrivateKey = "dkim.pem"
botOptionQueueBatch = "queue:batch"
botOptionQueueRetries = "queue:retries"
botOptionBanlistEnabled = "banlist:enabled"
botOptionGreylist = "greylist"
)
type botSettings map[string]string
// Get option
func (s botSettings) Get(key string) string {
return s[strings.ToLower(strings.TrimSpace(key))]
}
// Set option
func (s botSettings) Set(key, value string) {
s[strings.ToLower(strings.TrimSpace(key))] = value
}
// Users option
func (s botSettings) Users() []string {
value := s.Get(botOptionUsers)
if value == "" {
return []string{}
}
if strings.Contains(value, " ") {
return strings.Split(value, " ")
}
return []string{value}
}
// CatchAll option
func (s botSettings) CatchAll() string {
return s.Get(botOptionCatchAll)
}
// BanlistEnabled option
func (s botSettings) BanlistEnabled() bool {
return utils.Bool(s.Get(botOptionBanlistEnabled))
}
// Greylist option (duration in minutes)
func (s botSettings) Greylist() int {
return utils.Int(s.Get(botOptionGreylist))
}
// DKIMSignature (DNS TXT record)
func (s botSettings) DKIMSignature() string {
return s.Get(botOptionDKIMSignature)
}
// DKIMPrivateKey keep it secret
func (s botSettings) DKIMPrivateKey() string {
return s.Get(botOptionDKIMPrivateKey)
}
// QueueBatch option
func (s botSettings) QueueBatch() int {
return utils.Int(s.Get(botOptionQueueBatch))
}
// QueueRetries option
func (s botSettings) QueueRetries() int {
return utils.Int(s.Get(botOptionQueueRetries))
}
func (b *Bot) initBotUsers() ([]string, error) {
config := b.getBotSettings()
cfgUsers := config.Users()
if len(cfgUsers) > 0 {
return cfgUsers, nil
}
_, homeserver, err := b.lp.GetClient().UserID.Parse()
if err != nil {
return nil, err
}
config.Set(botOptionUsers, "@*:"+homeserver)
return config.Users(), b.setBotSettings(config)
}
func (b *Bot) getBotSettings() botSettings {
config, err := b.lp.GetAccountData(acBotSettingsKey)
if err != nil {
b.log.Error("cannot get bot settings: %v", utils.UnwrapError(err))
}
if config == nil {
config = map[string]string{}
}
return config
}
func (b *Bot) setBotSettings(cfg botSettings) error {
return utils.UnwrapError(b.lp.SetAccountData(acBotSettingsKey, cfg))
}

View File

@@ -1,116 +0,0 @@
package bot
import (
"net"
"sort"
"time"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data keys
const (
acBanlistKey = "cc.etke.postmoogle.banlist"
acGreylistKey = "cc.etke.postmoogle.greylist"
)
type bglist map[string]string
// Slice returns slice of ban- or greylist items
func (b bglist) Slice() []string {
slice := make([]string, 0, len(b))
for item := range b {
slice = append(slice, item)
}
sort.Strings(slice)
return slice
}
func (b bglist) getKey(addr net.Addr) string {
key := addr.String()
host, _, _ := net.SplitHostPort(key) //nolint:errcheck // either way it's ok
if host != "" {
key = host
}
return key
}
// Has addr in ban- or greylist
func (b bglist) Has(addr net.Addr) bool {
_, ok := b[b.getKey(addr)]
return ok
}
// Get when addr was added in ban- or greylist
func (b bglist) Get(addr net.Addr) (time.Time, bool) {
from := b[b.getKey(addr)]
if from == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC1123Z, from)
if err != nil {
return time.Time{}, false
}
return t, true
}
// Add an addr to ban- or greylist
func (b bglist) Add(addr net.Addr) {
key := b.getKey(addr)
if _, ok := b[key]; ok {
return
}
b[key] = time.Now().UTC().Format(time.RFC1123Z)
}
// Remove an addr from ban- or greylist
func (b bglist) Remove(addr net.Addr) {
key := b.getKey(addr)
if _, ok := b[key]; !ok {
return
}
delete(b, key)
}
func (b *Bot) getBanlist() bglist {
config, err := b.lp.GetAccountData(acBanlistKey)
if err != nil {
b.log.Error("cannot get banlist: %v", utils.UnwrapError(err))
}
if config == nil {
config = make(bglist, 0)
}
return config
}
func (b *Bot) setBanlist(cfg bglist) error {
b.lock("banlist")
if cfg == nil {
cfg = make(bglist, 0)
}
b.banlist = cfg
defer b.unlock("banlist")
return utils.UnwrapError(b.lp.SetAccountData(acBanlistKey, cfg))
}
func (b *Bot) getGreylist() bglist {
config, err := b.lp.GetAccountData(acGreylistKey)
if err != nil {
b.log.Error("cannot get banlist: %v", utils.UnwrapError(err))
}
if config == nil {
config = make(bglist, 0)
}
return config
}
func (b *Bot) setGreylist(cfg bglist) error {
return utils.UnwrapError(b.lp.SetAccountData(acGreylistKey, cfg))
}

View File

@@ -1,191 +0,0 @@
package bot
import (
"strings"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data key
const acRoomSettingsKey = "cc.etke.postmoogle.settings"
// option keys
const (
roomOptionOwner = "owner"
roomOptionMailbox = "mailbox"
roomOptionDomain = "domain"
roomOptionNoSend = "nosend"
roomOptionNoSender = "nosender"
roomOptionNoRecipient = "norecipient"
roomOptionNoSubject = "nosubject"
roomOptionNoHTML = "nohtml"
roomOptionNoThreads = "nothreads"
roomOptionNoFiles = "nofiles"
roomOptionPassword = "password"
roomOptionSpamcheckSMTP = "spamcheck:smtp"
roomOptionSpamcheckMX = "spamcheck:mx"
roomOptionSpamlist = "spamlist"
)
type roomSettings map[string]string
// Get option
func (s roomSettings) Get(key string) string {
return s[strings.ToLower(strings.TrimSpace(key))]
}
// Set option
func (s roomSettings) Set(key, value string) {
s[strings.ToLower(strings.TrimSpace(key))] = value
}
func (s roomSettings) Mailbox() string {
return s.Get(roomOptionMailbox)
}
func (s roomSettings) Domain() string {
return s.Get(roomOptionDomain)
}
func (s roomSettings) Owner() string {
return s.Get(roomOptionOwner)
}
func (s roomSettings) Password() string {
return s.Get(roomOptionPassword)
}
func (s roomSettings) NoSend() bool {
return utils.Bool(s.Get(roomOptionNoSend))
}
func (s roomSettings) NoSender() bool {
return utils.Bool(s.Get(roomOptionNoSender))
}
func (s roomSettings) NoRecipient() bool {
return utils.Bool(s.Get(roomOptionNoRecipient))
}
func (s roomSettings) NoSubject() bool {
return utils.Bool(s.Get(roomOptionNoSubject))
}
func (s roomSettings) NoHTML() bool {
return utils.Bool(s.Get(roomOptionNoHTML))
}
func (s roomSettings) NoThreads() bool {
return utils.Bool(s.Get(roomOptionNoThreads))
}
func (s roomSettings) NoFiles() bool {
return utils.Bool(s.Get(roomOptionNoFiles))
}
func (s roomSettings) SpamcheckSMTP() bool {
return utils.Bool(s.Get(roomOptionSpamcheckSMTP))
}
func (s roomSettings) SpamcheckMX() bool {
return utils.Bool(s.Get(roomOptionSpamcheckMX))
}
func (s roomSettings) Spamlist() []string {
return utils.StringSlice(s.Get(roomOptionSpamlist))
}
func (s roomSettings) migrateSpamlistSettings() {
uniq := map[string]struct{}{}
emails := utils.StringSlice(s.Get("spamlist:emails"))
localparts := utils.StringSlice(s.Get("spamlist:localparts"))
hosts := utils.StringSlice(s.Get("spamlist:hosts"))
list := utils.StringSlice(s.Get(roomOptionSpamlist))
delete(s, "spamlist:emails")
delete(s, "spamlist:localparts")
delete(s, "spamlist:hosts")
for _, email := range emails {
if email == "" {
continue
}
uniq[email] = struct{}{}
}
for _, localpart := range localparts {
if localpart == "" {
continue
}
uniq[localpart+"@*"] = struct{}{}
}
for _, host := range hosts {
if host == "" {
continue
}
uniq["*@"+host] = struct{}{}
}
for _, item := range list {
if item == "" {
continue
}
uniq[item] = struct{}{}
}
spamlist := make([]string, 0, len(uniq))
for item := range uniq {
spamlist = append(spamlist, item)
}
s.Set(roomOptionSpamlist, strings.Join(spamlist, ","))
}
// ContentOptions converts room display settings to content options
func (s roomSettings) ContentOptions() *utils.ContentOptions {
return &utils.ContentOptions{
HTML: !s.NoHTML(),
Sender: !s.NoSender(),
Recipient: !s.NoRecipient(),
Subject: !s.NoSubject(),
Threads: !s.NoThreads(),
ToKey: eventToKey,
FromKey: eventFromKey,
SubjectKey: eventSubjectKey,
MessageIDKey: eventMessageIDkey,
InReplyToKey: eventInReplyToKey,
ReferencesKey: eventReferencesKey,
}
}
func (b *Bot) getRoomSettings(roomID id.RoomID) (roomSettings, error) {
config, err := b.lp.GetRoomAccountData(roomID, acRoomSettingsKey)
if config == nil {
config = map[string]string{}
}
return config, utils.UnwrapError(err)
}
func (b *Bot) setRoomSettings(roomID id.RoomID, cfg roomSettings) error {
return utils.UnwrapError(b.lp.SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
}
func (b *Bot) migrateRoomSettings(roomID id.RoomID) {
cfg, err := b.getRoomSettings(roomID)
if err != nil {
b.log.Error("cannot retrieve room settings: %v", err)
return
}
if cfg["spamlist:emails"] == "" && cfg["spamlist:localparts"] == "" && cfg["spamlist:hosts"] == "" {
return
}
cfg.migrateSpamlistSettings()
err = b.setRoomSettings(roomID, cfg)
if err != nil {
b.log.Error("cannot migrate room settings: %v", err)
}
}

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -11,17 +12,23 @@ import (
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/mileusna/crontab"
"gitlab.com/etke.cc/go/healthchecks"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/linkpearl"
lpcfg "gitlab.com/etke.cc/linkpearl/config"
"gitlab.com/etke.cc/postmoogle/bot"
mxconfig "gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/bot/queue"
"gitlab.com/etke.cc/postmoogle/config"
"gitlab.com/etke.cc/postmoogle/smtp"
"gitlab.com/etke.cc/postmoogle/utils"
)
var (
q *queue.Queue
hc *healthchecks.Client
mxc *mxconfig.Manager
mxb *bot.Bot
cron *crontab.Crontab
smtpm *smtp.Manager
@@ -43,7 +50,8 @@ func main() {
log.Debug("starting internal components...")
initSentry(cfg)
initBot(cfg)
initHealthchecks(cfg)
initMatrix(cfg)
initSMTP(cfg)
initCron()
initShutdown(quit)
@@ -61,20 +69,35 @@ func main() {
func initSentry(cfg *config.Config) {
err := sentry.Init(sentry.ClientOptions{
Dsn: cfg.Sentry.DSN,
Dsn: cfg.Monitoring.SentryDSN,
AttachStacktrace: true,
TracesSampleRate: float64(cfg.Monitoring.SentrySampleRate) / 100,
})
if err != nil {
log.Fatal("cannot initialize sentry: %v", err)
}
}
func initBot(cfg *config.Config) {
func initHealthchecks(cfg *config.Config) {
if cfg.Monitoring.HealchecksUUID == "" {
return
}
hc = healthchecks.New(cfg.Monitoring.HealchecksUUID, func(operation string, err error) {
log.Error("healthchecks operation %q failed: %v", operation, err)
})
hc.Start(strings.NewReader("starting postmoogle"))
go hc.Auto(cfg.Monitoring.HealthechsDuration)
}
func initMatrix(cfg *config.Config) {
db, err := sql.Open(cfg.DB.Dialect, cfg.DB.DSN)
if err != nil {
log.Fatal("cannot initialize SQL database: %v", err)
}
mxlog := logger.New("matrix.", cfg.LogLevel)
cfglog := logger.New("config.", cfg.LogLevel)
qlog := logger.New("queue.", cfg.LogLevel)
lp, err := linkpearl.New(&lpcfg.Config{
Homeserver: cfg.Homeserver,
Login: cfg.Login,
@@ -98,7 +121,9 @@ func initBot(cfg *config.Config) {
log.Fatal("cannot initialize matrix bot: %v", err)
}
mxb, err = bot.New(lp, mxlog, cfg.Prefix, cfg.Domains, cfg.Admins)
mxc = mxconfig.New(lp, cfglog)
q = queue.New(lp, mxc, qlog)
mxb, err = bot.New(q, lp, mxlog, mxc, cfg.Proxies, cfg.Prefix, cfg.Domains, cfg.Admins, bot.MBXConfig(cfg.Mailboxes))
if err != nil {
// nolint // Fatal = panic, not os.Exit()
log.Fatal("cannot start matrix bot: %v", err)
@@ -117,15 +142,16 @@ func initSMTP(cfg *config.Config) {
LogLevel: cfg.LogLevel,
MaxSize: cfg.MaxSize,
Bot: mxb,
Callers: []smtp.Caller{mxb, q},
})
}
func initCron() {
cron = crontab.New()
err := cron.AddJob("* * * * *", mxb.ProcessQueue)
err := cron.AddJob("* * * * *", q.Process)
if err != nil {
log.Error("cannot start ProcessQueue cronjob: %v", err)
log.Error("cannot start queue processing cronjob: %v", err)
}
}
@@ -155,6 +181,10 @@ func shutdown() {
cron.Shutdown()
smtpm.Stop()
mxb.Stop()
if hc != nil {
hc.Shutdown()
hc.ExitStatus(0, strings.NewReader("shutting down postmoogle"))
}
sentry.Flush(5 * time.Second)
log.Info("Postmoogle has been stopped")
@@ -164,8 +194,7 @@ func shutdown() {
func recovery() {
defer shutdown()
err := recover()
// no problem just shutdown
if err == nil {
if err != nil {
sentry.CurrentHub().Recover(err)
}
}

View File

@@ -1,6 +1,8 @@
package config
import (
"time"
"gitlab.com/etke.cc/go/env"
)
@@ -17,19 +19,27 @@ func New() *Config {
Prefix: env.String("prefix", defaultConfig.Prefix),
Domains: migrateDomains("domain", "domains"),
Port: env.String("port", defaultConfig.Port),
Proxies: env.Slice("proxies"),
NoEncryption: env.Bool("noencryption"),
DataSecret: env.String("data.secret", defaultConfig.DataSecret),
MaxSize: env.Int("maxsize", defaultConfig.MaxSize),
StatusMsg: env.String("statusmsg", defaultConfig.StatusMsg),
Admins: env.Slice("admins"),
Mailboxes: Mailboxes{
Reserved: env.Slice("mailboxes.reserved"),
Activation: env.String("mailboxes.activation", defaultConfig.Mailboxes.Activation),
},
TLS: TLS{
Certs: env.Slice("tls.cert"),
Keys: env.Slice("tls.key"),
Required: env.Bool("tls.required"),
Port: env.String("tls.port", defaultConfig.TLS.Port),
},
Sentry: Sentry{
DSN: env.String("sentry.dsn", defaultConfig.Sentry.DSN),
Monitoring: Monitoring{
SentryDSN: env.String("monitoring.sentry.dsn", env.String("sentry.dsn", "")),
SentrySampleRate: env.Int("monitoring.sentry.rate", env.Int("sentry.rate", 0)),
HealchecksUUID: env.String("monitoring.healthchecks.uuid", ""),
HealthechsDuration: time.Duration(env.Int("monitoring.healthchecks.duration", int(defaultConfig.Monitoring.HealthechsDuration))) * time.Second,
},
LogLevel: env.String("loglevel", defaultConfig.LogLevel),
DB: DB{

View File

@@ -7,10 +7,17 @@ var defaultConfig = &Config{
Prefix: "!pm",
MaxSize: 1024,
StatusMsg: "Delivering emails",
Mailboxes: Mailboxes{
Activation: "none",
},
DB: DB{
DSN: "local.db",
Dialect: "sqlite3",
},
Monitoring: Monitoring{
SentrySampleRate: 20,
HealthechsDuration: 5,
},
TLS: TLS{
Port: "587",
},

View File

@@ -1,5 +1,7 @@
package config
import "time"
// Config of Postmoogle
type Config struct {
// Homeserver url
@@ -12,6 +14,8 @@ type Config struct {
Domains []string
// Port for SMTP
Port string
// Proxies is list of trusted SMTP proxies
Proxies []string
// RoomID of the admin room
LogLevel string
// DataSecret is account data secret key (password) to encrypt all account data values
@@ -24,6 +28,8 @@ type Config struct {
MaxSize int
// StatusMsg of the bot
StatusMsg string
// Mailboxes config
Mailboxes Mailboxes
// Admins holds list of admin users (wildcards supported), e.g.: @*:example.com, @bot.*:example.com, @admin:*. Empty = no admins
Admins []string
@@ -33,8 +39,8 @@ type Config struct {
// TLS config
TLS TLS
// Sentry config
Sentry Sentry
// Monitoring config
Monitoring Monitoring
}
// DB config
@@ -53,7 +59,16 @@ type TLS struct {
Required bool
}
// Sentry config
type Sentry struct {
DSN string
// Monitoring config
type Monitoring struct {
SentryDSN string
SentrySampleRate int
HealchecksUUID string
HealthechsDuration time.Duration
}
// Mailboxes config
type Mailboxes struct {
Reserved []string
Activation string
}

25
docs/mailboxes.md Normal file
View File

@@ -0,0 +1,25 @@
# Mailboxes configuration
## `POSTMOOGLE_MAILBOXES_RESERVED`
Space separated list of reserved mailboxes, example:
```bash
export POSTMOOGLE_MAILBOXES_RESERVED=admin root postmaster
```
Nobody can create a mailbox from that list
## `POSTMOOGLE_MAILBOXES_ACTIVATION`
Type of activation flow:
### `none` (default)
If `POSTMOOGLE_MAILBOXES_ACTIVATION=none` mailbox will be just created as is, without any additional checks.
### `notify`
If `POSTMOOGLE_MAILBOXES_ACTIVATION=notify`, mailbox will be created as in `none` case **and** notification will be sent to one of the mailboxes managed by a postmoogle admin.
To make it work, a postmoogle admin (or multiple admins) should either set `!pm adminroom` or create at least one mailbox.

42
docs/tricks.md Normal file
View File

@@ -0,0 +1,42 @@
# tricks
<!-- vim-markdown-toc GitLab -->
* [Logs](#logs)
* [get most active hosts](#get-most-active-hosts)
<!-- vim-markdown-toc -->
## Logs
### get most active hosts
Even if you use postmoogle as an internal mail server and contact "outside internet" quite rarely,
you will see lots of connections to your SMTP servers from random hosts over internet that do... nothing?
They don't send any valid emails or do something meaningful, thus you can safely assume they are spammers.
To get top X (in example: top 10) hosts with biggest count of attempts to connect to your postmoogle instance, follow the steps:
1. enable debug log: `export POSTMOOGLE_LOGLEVEL=debug`
2. restart postmoogle and wait some time to get stats
3. run the following bash one-liner to show top 10 hosts by connections count:
```bash
journalctl -o cat -u postmoogle | grep "smtp.DEBUG accepted connection from " | grep -oE "[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}" | sort | uniq -ci | sort -rn | head -n 10
253 111.111.111.111
183 222.222.222.222
39 333.333.333.333
38 444.444.444.444
18 555.555.555.555
16 666.666.666.666
8 777.777.777.777
5 888.888.888.888
5 999.999.999.999
4 010.010.010.010
```
of course, IP addresses above are crafted just to visualize their place in that top, according to the number of connections done.
In reality, you will see real IP addresses here. Usually, only hosts with hundreds or thousands of connections for the last 7 days worth checking.
What's next?
Do **not** ban them right away. Check WHOIS info for each host and only after that decide if you really want to ban that host or not.

View File

@@ -1,27 +1,19 @@
package utils
package email
import (
"crypto"
"crypto/x509"
"encoding/pem"
"fmt"
"net/mail"
"strings"
"time"
"github.com/emersion/go-msgauth/dkim"
"github.com/jhillyerd/enmime"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
// IncomingFilteringOptions for incoming mail
type IncomingFilteringOptions interface {
SpamcheckSMTP() bool
SpamcheckMX() bool
Spamlist() []string
}
"gitlab.com/etke.cc/postmoogle/utils"
)
// Email object
type Email struct {
@@ -31,50 +23,25 @@ type Email struct {
References string
From string
To string
RcptTo string
CC []string
Subject string
Text string
HTML string
Files []*File
Files []*utils.File
}
// ContentOptions represents settings that specify how an email is to be converted to a Matrix message
type ContentOptions struct {
// On/Off
Sender bool
Recipient bool
Subject bool
HTML bool
Threads bool
// Keys
MessageIDKey string
InReplyToKey string
ReferencesKey string
SubjectKey string
FromKey string
ToKey string
}
// AddressValid checks if email address is valid
func AddressValid(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}
// MessageID generates email Message-Id from matrix event ID
func MessageID(eventID id.EventID, domain string) string {
return fmt.Sprintf("<%s@%s>", eventID, domain)
}
// NewEmail constructs Email object
func NewEmail(messageID, inReplyTo, references, subject, from, to, text, html string, files []*File) *Email {
// New constructs Email object
func New(messageID, inReplyTo, references, subject, from, to, rcptto, cc, text, html string, files []*utils.File) *Email {
email := &Email{
Date: time.Now().UTC().Format(time.RFC1123Z),
Date: dateNow(),
MessageID: messageID,
InReplyTo: inReplyTo,
References: references,
From: from,
To: to,
From: Address(from),
To: Address(to),
CC: AddressList(cc),
RcptTo: rcptto,
Subject: subject,
Text: text,
HTML: html,
@@ -82,11 +49,42 @@ func NewEmail(messageID, inReplyTo, references, subject, from, to, text, html st
}
if html != "" {
var err error
html, err = StripHTMLTag(html, "style")
if err == nil {
email.HTML = html
}
html = styleRegex.ReplaceAllString(html, "")
email.HTML = html
}
return email
}
// FromEnvelope constructs Email object from envelope
func FromEnvelope(rcptto string, envelope *enmime.Envelope) *Email {
datetime, _ := envelope.Date() //nolint:errcheck // handled in dateNow()
date := dateNow(datetime)
var html string
if envelope.HTML != "" {
html = styleRegex.ReplaceAllString(envelope.HTML, "")
}
files := make([]*utils.File, 0, len(envelope.Attachments))
for _, attachment := range envelope.Attachments {
file := utils.NewFile(attachment.FileName, attachment.Content)
files = append(files, file)
}
email := &Email{
Date: date,
MessageID: envelope.GetHeader("Message-Id"),
InReplyTo: envelope.GetHeader("In-Reply-To"),
References: envelope.GetHeader("References"),
From: Address(envelope.GetHeader("From")),
To: Address(envelope.GetHeader("To")),
RcptTo: Address(rcptto),
CC: AddressList(envelope.GetHeader("Cc")),
Subject: envelope.GetHeader("Subject"),
Text: envelope.Text,
HTML: html,
Files: files,
}
return email
@@ -95,9 +93,9 @@ func NewEmail(messageID, inReplyTo, references, subject, from, to, text, html st
// Mailbox returns postmoogle's mailbox, parsing it from FROM (if incoming=false) or TO (incoming=true)
func (e *Email) Mailbox(incoming bool) string {
if incoming {
return Mailbox(e.To)
return utils.Mailbox(e.RcptTo)
}
return Mailbox(e.From)
return utils.Mailbox(e.From)
}
// Content converts the email object to a Matrix event content
@@ -110,7 +108,11 @@ func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Con
text.WriteString(" ➡️ ")
text.WriteString(e.To)
}
if options.Sender || options.Recipient {
if options.CC && len(e.CC) > 0 {
text.WriteString("\ncc: ")
text.WriteString(strings.Join(e.CC, ", "))
}
if options.Sender || options.Recipient || options.CC {
text.WriteString("\n\n")
}
if options.Subject && threadID == "" {
@@ -125,7 +127,12 @@ func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Con
}
parsed := format.RenderMarkdown(text.String(), true, true)
parsed.RelatesTo = RelatesTo(options.Threads, threadID)
parsed.RelatesTo = utils.RelatesTo(options.Threads, threadID)
var cc string
if len(e.CC) > 0 {
cc = strings.Join(e.CC, ", ")
}
content := event.Content{
Raw: map[string]interface{}{
@@ -133,8 +140,10 @@ func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Con
options.InReplyToKey: e.InReplyTo,
options.ReferencesKey: e.References,
options.SubjectKey: e.Subject,
options.RcptToKey: e.RcptTo,
options.FromKey: e.From,
options.ToKey: e.To,
options.CcKey: cc,
},
Parsed: &parsed,
}
@@ -166,16 +175,19 @@ func (e *Email) Compose(privkey string) string {
if e.References != "" {
mail = mail.Header("References", e.References)
}
if len(e.CC) > 0 {
for _, addr := range e.CC {
mail = mail.CC("", addr)
}
}
root, err := mail.Build()
if err != nil {
log.Error("cannot compose email: %v", err)
return ""
}
var data strings.Builder
err = root.Encode(&data)
if err != nil {
log.Error("cannot encode email: %v", err)
return ""
}

31
email/options.go Normal file
View File

@@ -0,0 +1,31 @@
package email
// IncomingFilteringOptions for incoming mail
type IncomingFilteringOptions interface {
SpamcheckDKIM() bool
SpamcheckSMTP() bool
SpamcheckSPF() bool
SpamcheckMX() bool
Spamlist() []string
}
// ContentOptions represents settings that specify how an email is to be converted to a Matrix message
type ContentOptions struct {
// On/Off
CC bool
Sender bool
Recipient bool
Subject bool
HTML bool
Threads bool
// Keys
MessageIDKey string
InReplyToKey string
ReferencesKey string
SubjectKey string
FromKey string
ToKey string
CcKey string
RcptToKey string
}

61
email/utils.go Normal file
View File

@@ -0,0 +1,61 @@
package email
import (
"fmt"
"net/mail"
"regexp"
"time"
"maunium.net/go/mautrix/id"
)
var styleRegex = regexp.MustCompile("<style((.|\n|\r)*?)<\\/style>")
// AddressValid checks if email address is valid
func AddressValid(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}
// MessageID generates email Message-Id from matrix event ID
func MessageID(eventID id.EventID, domain string) string {
return fmt.Sprintf("<%s@%s>", eventID, domain)
}
// Address gets email address from a valid email address notation (eg: "Jane Doe" <jane@example.com> -> jane@example.com)
func Address(email string) string {
addr, _ := mail.ParseAddress(email) //nolint:errcheck // if it fails here, nothing will help
if addr == nil {
return email
}
return addr.Address
}
// Address gets email address from a valid email address notation (eg: "Jane Doe" <jane@example.com>, john.doe@example.com -> jane@example.com, john.doe@example.com)
func AddressList(emailList string) []string {
if emailList == "" {
return []string{}
}
list, _ := mail.ParseAddressList(emailList) //nolint:errcheck // if it fails here, nothing will help
if len(list) == 0 {
return []string{}
}
addrs := make([]string, 0, len(list))
for _, addr := range list {
addrs = append(addrs, addr.Address)
}
return addrs
}
// dateNow returns Date in RFC1123 with numeric timezone
func dateNow(original ...time.Time) string {
now := time.Now().UTC()
if len(original) > 0 && !original[0].IsZero() {
now = original[0]
}
return now.Format(time.RFC1123Z)
}

7
go.mod
View File

@@ -16,20 +16,22 @@ require (
github.com/mileusna/crontab v1.2.0
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39
gitlab.com/etke.cc/go/env v1.0.0
gitlab.com/etke.cc/go/healthchecks v1.0.1
gitlab.com/etke.cc/go/logger v1.1.0
gitlab.com/etke.cc/go/mxidwc v1.0.0
gitlab.com/etke.cc/go/secgen v1.1.1
gitlab.com/etke.cc/go/trysmtp v1.0.0
gitlab.com/etke.cc/go/validator v1.0.4
gitlab.com/etke.cc/go/validator v1.0.6
gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6
golang.org/x/net v0.2.0
maunium.net/go/mautrix v0.12.3
)
require (
blitiri.com.ar/go/spf v1.5.1 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.1 // indirect
@@ -49,6 +51,7 @@ require (
github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.5.3 // indirect
golang.org/x/crypto v0.3.0 // indirect
golang.org/x/net v0.2.0 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/text v0.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

10
go.sum
View File

@@ -1,3 +1,5 @@
blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE=
blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
@@ -28,6 +30,8 @@ github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@@ -91,6 +95,8 @@ github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
gitlab.com/etke.cc/go/env v1.0.0 h1:J98BwzOuELnjsVPFvz5wa79L7IoRV9CmrS41xLYXtSw=
gitlab.com/etke.cc/go/env v1.0.0/go.mod h1:e1l4RM5MA1sc0R1w/RBDAESWRwgo5cOG9gx8BKUn2C4=
gitlab.com/etke.cc/go/healthchecks v1.0.1 h1:IxPB+r4KtEM6wf4K7MeQoH1XnuBITMGUqFaaRIgxeUY=
gitlab.com/etke.cc/go/healthchecks v1.0.1/go.mod h1:EzQjwSawh8tQEX43Ls0dI9mND6iWd5NHtmapdO24fMI=
gitlab.com/etke.cc/go/logger v1.1.0 h1:Yngp/DDLmJ0jJNLvLXrfan5Gi5QV+r7z6kCczTv8t4U=
gitlab.com/etke.cc/go/logger v1.1.0/go.mod h1:8Vw5HFXlZQ5XeqvUs5zan+GnhrQyYtm/xe+yj8H/0zk=
gitlab.com/etke.cc/go/mxidwc v1.0.0 h1:6EAlJXvs3nU4RaMegYq6iFlyVvLw7JZYnZmNCGMYQP0=
@@ -99,8 +105,8 @@ gitlab.com/etke.cc/go/secgen v1.1.1 h1:RmKOki725HIhWJHzPtAc9X4YvBneczndchpMgoDkE
gitlab.com/etke.cc/go/secgen v1.1.1/go.mod h1:3pJqRGeWApzx7qXjABqz2o2SMCNpKSZao/gXVdasqE8=
gitlab.com/etke.cc/go/trysmtp v1.0.0 h1:f/7gSmzohKniVeLSLevI+ZsySYcPUGkT9cRlOTwjOr8=
gitlab.com/etke.cc/go/trysmtp v1.0.0/go.mod h1:KqRuIB2IPElEEbAxXmFyKtm7S5YiuEb4lxwWthccqyE=
gitlab.com/etke.cc/go/validator v1.0.4 h1:2HIBP12f/RZr/7KTNH5/PgPTzl1vi7Co3lmhNTWB31A=
gitlab.com/etke.cc/go/validator v1.0.4/go.mod h1:3vdssRG4LwgdTr9IHz9MjGSEO+3/FO9hXPGMuSeweJ8=
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/linkpearl v0.0.0-20221116205701-65547c5608e6 h1:+HDT2/bx3Hug++aeDE/PaoRRcnKdYzEm6i2RlOAzPXo=
gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6/go.mod h1:Dgtu0qvymNjjky4Bu5WC8+iSohcb5xZ9CtkD3ezDqIA=
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=

View File

@@ -9,6 +9,7 @@ import (
// Listener that rejects connections from banned hosts
type Listener struct {
log *logger.Logger
done chan struct{}
listener net.Listener
isBanned func(net.Addr) bool
}
@@ -16,6 +17,7 @@ type Listener struct {
func NewListener(actual net.Listener, isBanned func(net.Addr) bool, log *logger.Logger) *Listener {
return &Listener{
log: log,
done: make(chan struct{}, 1),
listener: actual,
isBanned: isBanned,
}
@@ -23,24 +25,32 @@ func NewListener(actual net.Listener, isBanned func(net.Addr) bool, log *logger.
// Accept waits for and returns the next connection to the listener.
func (l *Listener) Accept() (net.Conn, error) {
conn, err := l.listener.Accept()
if err != nil {
return conn, err
}
if l.isBanned(conn.RemoteAddr()) {
conn.Close()
l.log.Info("rejected connection from %q (already banned)", conn.RemoteAddr())
// Due to go-smtp design, any error returned here will crash whole server,
// thus we have to forge a connection
return &net.TCPConn{}, nil
}
for {
conn, err := l.listener.Accept()
if err != nil {
select {
case <-l.done:
return conn, err
default:
l.log.Warn("cannot accept connection: %v", err)
continue
}
}
if l.isBanned(conn.RemoteAddr()) {
conn.Close()
l.log.Info("rejected connection from %q (already banned)", conn.RemoteAddr())
continue
}
return conn, nil
l.log.Debug("accepted connection from %q", conn.RemoteAddr())
return conn, nil
}
}
// Close closes the listener.
// Any blocked Accept operations will be unblocked and return errors.
func (l *Listener) Close() error {
close(l.done)
return l.listener.Close()
}

29
smtp/logger.go Normal file
View File

@@ -0,0 +1,29 @@
package smtp
import (
"strings"
)
// loggerWrapper is a wrapper around logger.Logger to implement smtp.Logger interface
type loggerWrapper struct {
log func(string, ...interface{})
}
func (l loggerWrapper) Printf(format string, v ...interface{}) {
l.log(format, v...)
}
func (l loggerWrapper) Println(v ...interface{}) {
msg := strings.Repeat("%v ", len(v))
l.log(msg, v...)
}
// loggerWriter is a wrapper around io.Writer to implement io.Writer interface
type loggerWriter struct {
log func(string)
}
func (l loggerWriter) Write(p []byte) (n int, err error) {
l.log(string(p))
return len(p), nil
}

View File

@@ -4,14 +4,13 @@ import (
"context"
"crypto/tls"
"net"
"os"
"time"
"github.com/emersion/go-smtp"
"gitlab.com/etke.cc/go/logger"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
"gitlab.com/etke.cc/postmoogle/email"
)
type Config struct {
@@ -26,6 +25,7 @@ type Config struct {
LogLevel string
MaxSize int
Bot matrixbot
Callers []Caller
}
type Manager struct {
@@ -40,17 +40,22 @@ type Manager struct {
}
type matrixbot interface {
AllowAuth(string, string) bool
AllowAuth(string, string) (id.RoomID, bool)
IsGreylisted(net.Addr) bool
IsBanned(net.Addr) bool
IsTrusted(net.Addr) bool
Ban(net.Addr)
GetMapping(string) (id.RoomID, bool)
GetIFOptions(id.RoomID) utils.IncomingFilteringOptions
IncomingEmail(context.Context, *utils.Email) error
SetSendmail(func(string, string, string) error)
GetIFOptions(id.RoomID) email.IncomingFilteringOptions
IncomingEmail(context.Context, *email.Email) error
GetDKIMprivkey() string
}
// Caller is Sendmail caller
type Caller interface {
SetSendmail(func(string, string, string) error)
}
// NewManager creates new SMTP server manager
func NewManager(cfg *Config) *Manager {
log := logger.New("smtp.", cfg.LogLevel)
@@ -59,9 +64,12 @@ func NewManager(cfg *Config) *Manager {
bot: cfg.Bot,
domains: cfg.Domains,
}
cfg.Bot.SetSendmail(mailsrv.SendEmail)
for _, caller := range cfg.Callers {
caller.SetSendmail(mailsrv.SendEmail)
}
s := smtp.NewServer(mailsrv)
s.ErrorLog = loggerWrapper{func(s string, i ...interface{}) { log.Error(s, i...) }}
s.ReadTimeout = 10 * time.Second
s.WriteTimeout = 10 * time.Second
s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024
@@ -73,7 +81,7 @@ func NewManager(cfg *Config) *Manager {
s.Domain = cfg.Domains[0]
}
if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
s.Debug = os.Stdout
s.Debug = loggerWriter{func(s string) { log.Debug(s) }}
}
m := &Manager{

View File

@@ -10,7 +10,7 @@ import (
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/trysmtp"
"gitlab.com/etke.cc/postmoogle/utils"
"gitlab.com/etke.cc/postmoogle/email"
)
var (
@@ -41,25 +41,29 @@ func (m *mailServer) Login(state *smtp.ConnectionState, username, password strin
return nil, ErrBanned
}
if !utils.AddressValid(username) {
if !email.AddressValid(username) {
m.log.Debug("address %s is invalid", username)
m.bot.Ban(state.RemoteAddr)
return nil, ErrBanned
}
if !m.bot.AllowAuth(username, password) {
roomID, allow := m.bot.AllowAuth(username, password)
if !allow {
m.log.Debug("username=%s or password=<redacted> is invalid", username)
m.bot.Ban(state.RemoteAddr)
return nil, ErrBanned
}
return &outgoingSession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
sendmail: m.SendEmail,
privkey: m.bot.GetDKIMprivkey(),
from: username,
log: m.log,
domains: m.domains,
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
sendmail: m.SendEmail,
privkey: m.bot.GetDKIMprivkey(),
from: username,
log: m.log,
domains: m.domains,
getRoomID: m.bot.GetMapping,
fromRoom: roomID,
tos: []string{},
}, nil
}
@@ -77,9 +81,11 @@ func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session,
receiveEmail: m.ReceiveEmail,
ban: m.bot.Ban,
greylisted: m.bot.IsGreylisted,
trusted: m.bot.IsTrusted,
log: m.log,
domains: m.domains,
addr: state.RemoteAddr,
tos: []string{},
}, nil
}
@@ -112,6 +118,6 @@ func (m *mailServer) SendEmail(from, to, data string) error {
}
// ReceiveEmail - incoming mail into matrix room
func (m *mailServer) ReceiveEmail(ctx context.Context, email *utils.Email) error {
return m.bot.IncomingEmail(ctx, email)
func (m *mailServer) ReceiveEmail(ctx context.Context, eml *email.Email) error {
return m.bot.IncomingEmail(ctx, eml)
}

View File

@@ -1,11 +1,14 @@
package smtp
import (
"bytes"
"context"
"errors"
"io"
"net"
"strconv"
"github.com/emersion/go-msgauth/dkim"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"github.com/jhillyerd/enmime"
@@ -13,6 +16,7 @@ import (
"gitlab.com/etke.cc/go/validator"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils"
)
@@ -20,21 +24,23 @@ import (
type incomingSession struct {
log *logger.Logger
getRoomID func(string) (id.RoomID, bool)
getFilters func(id.RoomID) utils.IncomingFilteringOptions
receiveEmail func(context.Context, *utils.Email) error
getFilters func(id.RoomID) email.IncomingFilteringOptions
receiveEmail func(context.Context, *email.Email) error
greylisted func(net.Addr) bool
trusted func(net.Addr) bool
ban func(net.Addr)
domains []string
roomID id.RoomID
ctx context.Context
addr net.Addr
to string
tos []string
from string
}
func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
if !utils.AddressValid(from) {
if !email.AddressValid(from) {
s.log.Debug("address %s is invalid", from)
s.ban(s.addr)
return ErrBanned
@@ -46,10 +52,11 @@ func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error {
func (s *incomingSession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.to = to
s.tos = append(s.tos, to)
hostname := utils.Hostname(to)
var domainok bool
for _, domain := range s.domains {
if utils.Hostname(to) == domain {
if hostname == domain {
domainok = true
break
}
@@ -59,77 +66,143 @@ func (s *incomingSession) Rcpt(to string) error {
return ErrNoUser
}
roomID, ok := s.getRoomID(utils.Mailbox(to))
var ok bool
s.roomID, ok = s.getRoomID(utils.Mailbox(to))
if !ok {
s.log.Debug("mapping for %s not found", to)
return ErrNoUser
}
validations := s.getFilters(roomID)
if !validateEmail(s.from, s.to, s.log, validations) {
s.ban(s.addr)
return ErrBanned
}
s.log.Debug("mail to %s", to)
return nil
}
// getAddr gets real address of incoming email serder,
// including special case of trusted proxy
func (s *incomingSession) getAddr(envelope *enmime.Envelope) net.Addr {
if !s.trusted(s.addr) {
return s.addr
}
addrHeader := envelope.GetHeader("X-Real-Addr")
if addrHeader == "" {
return s.addr
}
host, portString, _ := net.SplitHostPort(addrHeader) //nolint:errcheck
if host == "" {
return s.addr
}
var port int
port, _ = strconv.Atoi(portString) //nolint:errcheck
realAddr := &net.TCPAddr{IP: net.ParseIP(host), Port: port}
s.log.Info("real address: %s", realAddr.String())
return realAddr
}
func (s *incomingSession) Data(r io.Reader) error {
if s.greylisted(s.addr) {
data, err := io.ReadAll(r)
if err != nil {
s.log.Error("cannot read DATA: %v", err)
return err
}
reader := bytes.NewReader(data)
parser := enmime.NewParser()
envelope, err := parser.ReadEnvelope(reader)
if err != nil {
return err
}
addr := s.getAddr(envelope)
reader.Seek(0, io.SeekStart) //nolint:errcheck
validations := s.getFilters(s.roomID)
if !validateIncoming(s.from, s.tos[0], addr, s.log, validations) {
s.ban(addr)
return ErrBanned
}
if s.greylisted(addr) {
return &smtp.SMTPError{
Code: 451,
EnhancedCode: smtp.EnhancedCode{4, 5, 1},
Message: "You have been greylisted, try again a bit later.",
}
}
parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r)
if err != nil {
return err
if validations.SpamcheckDKIM() {
results, verr := dkim.Verify(reader)
if verr != nil {
s.log.Error("cannot verify DKIM: %v", verr)
return verr
}
for _, result := range results {
if result.Err != nil {
s.log.Info("DKIM verification of %q failed: %v", result.Domain, result.Err)
return result.Err
}
}
}
files := parseAttachments(eml.Attachments, s.log)
email := utils.NewEmail(
eml.GetHeader("Message-Id"),
eml.GetHeader("In-Reply-To"),
eml.GetHeader("References"),
eml.GetHeader("Subject"),
s.from,
s.to,
eml.Text,
eml.HTML,
files)
return s.receiveEmail(s.ctx, email)
eml := email.FromEnvelope(s.tos[0], envelope)
for _, to := range s.tos {
eml.RcptTo = to
err := s.receiveEmail(s.ctx, eml)
if err != nil {
return err
}
}
return nil
}
func (s *incomingSession) Reset() {}
func (s *incomingSession) Logout() error { return nil }
// outgoingSession represents an SMTP-submission session sending emails from external scripts, using postmoogle as SMTP server
type outgoingSession struct {
log *logger.Logger
sendmail func(string, string, string) error
privkey string
domains []string
log *logger.Logger
sendmail func(string, string, string) error
privkey string
domains []string
getRoomID func(string) (id.RoomID, bool)
ctx context.Context
to string
from string
ctx context.Context
tos []string
from string
fromRoom id.RoomID
}
func (s *outgoingSession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
if !utils.AddressValid(from) {
if !email.AddressValid(from) {
return errors.New("please, provide email address")
}
hostname := utils.Hostname(from)
var domainok bool
for _, domain := range s.domains {
if hostname == domain {
domainok = true
break
}
}
if !domainok {
s.log.Debug("wrong domain of %s", from)
return ErrNoUser
}
roomID, ok := s.getRoomID(utils.Mailbox(from))
if !ok {
s.log.Debug("mapping for %s not found", from)
return ErrNoUser
}
if s.fromRoom != roomID {
s.log.Warn("sender from %q tries to impersonate %q", s.fromRoom, roomID)
return ErrNoUser
}
return nil
}
func (s *outgoingSession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.to = to
s.tos = append(s.tos, to)
s.log.Debug("mail to %s", to)
return nil
@@ -137,49 +210,41 @@ func (s *outgoingSession) Rcpt(to string) error {
func (s *outgoingSession) Data(r io.Reader) error {
parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r)
envelope, err := parser.ReadEnvelope(r)
if err != nil {
return err
}
eml := email.FromEnvelope(s.tos[0], envelope)
for _, to := range s.tos {
eml.RcptTo = to
err := s.sendmail(eml.From, to, eml.Compose(s.privkey))
if err != nil {
return err
}
}
files := parseAttachments(eml.Attachments, s.log)
email := utils.NewEmail(
eml.GetHeader("Message-Id"),
eml.GetHeader("In-Reply-To"),
eml.GetHeader("References"),
eml.GetHeader("Subject"),
s.from,
s.to,
eml.Text,
eml.HTML,
files)
return s.sendmail(email.From, email.To, email.Compose(s.privkey))
return nil
}
func (s *outgoingSession) Reset() {}
func (s *outgoingSession) Logout() error { return nil }
func validateEmail(from, to string, log *logger.Logger, options utils.IncomingFilteringOptions) bool {
func validateIncoming(from, to string, senderAddr net.Addr, log *logger.Logger, options email.IncomingFilteringOptions) bool {
var sender net.IP
switch netaddr := senderAddr.(type) {
case *net.TCPAddr:
sender = netaddr.IP
default:
host, _, _ := net.SplitHostPort(senderAddr.String()) // nolint:errcheck
sender = net.ParseIP(host)
}
enforce := validator.Enforce{
Email: true,
MX: options.SpamcheckMX(),
SPF: options.SpamcheckSPF(),
SMTP: options.SpamcheckSMTP(),
}
v := validator.New(options.Spamlist(), enforce, to, log)
return v.Email(from)
}
func parseAttachments(parts []*enmime.Part, log *logger.Logger) []*utils.File {
files := make([]*utils.File, 0, len(parts))
for _, attachment := range parts {
for _, err := range attachment.Errors {
log.Warn("attachment error: %v", err)
}
file := utils.NewFile(attachment.FileName, attachment.Content)
files = append(files, file)
}
return files
return v.Email(from, sender)
}

View File

@@ -1,44 +0,0 @@
package utils
import (
"bytes"
"strings"
"golang.org/x/net/html"
)
// StripHTMLTag from text
//
// Source: https://siongui.github.io/2018/01/16/go-remove-html-inline-style/
func StripHTMLTag(text, tag string) (string, error) {
doc, err := html.Parse(strings.NewReader(text))
if err != nil {
return "", err
}
stripHTMLTag(doc, tag)
var out bytes.Buffer
err = html.Render(&out, doc)
if err != nil {
return "", err
}
return out.String(), nil
}
func stripHTMLTag(node *html.Node, tag string) {
i := -1
for index, attr := range node.Attr {
if attr.Key == tag {
i = index
break
}
}
if i != -1 {
node.Attr = append(node.Attr[:i], node.Attr[i+1:]...)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
stripHTMLTag(child, tag)
}
}

41
utils/mail.go Normal file
View File

@@ -0,0 +1,41 @@
package utils
import "strings"
// Mailbox returns mailbox part from email address
func Mailbox(email string) string {
index := strings.LastIndex(email, "@")
if index == -1 {
return email
}
return email[:index]
}
// EmailsList returns human-readable list of mailbox's emails for all available domains
func EmailsList(mailbox string, domain string) string {
var msg strings.Builder
domain = SanitizeDomain(domain)
msg.WriteString(mailbox)
msg.WriteString("@")
msg.WriteString(domain)
count := len(domains) - 1
for i, aliasDomain := range domains {
if i < count {
msg.WriteString(", ")
}
if aliasDomain == domain {
continue
}
msg.WriteString(mailbox)
msg.WriteString("@")
msg.WriteString(aliasDomain)
}
return msg.String()
}
// Hostname returns hostname part from email address
func Hostname(email string) string {
return email[strings.LastIndex(email, "@")+1:]
}

View File

@@ -32,16 +32,17 @@ func EventParent(currentID id.EventID, content *event.MessageEventContent) id.Ev
return currentID
}
if content.GetRelatesTo() == nil {
relation := content.OptionalGetRelatesTo()
if relation == nil {
return currentID
}
threadParent := content.RelatesTo.GetThreadParent()
threadParent := relation.GetThreadParent()
if threadParent != "" {
return threadParent
}
replyParent := content.RelatesTo.GetReplyTo()
replyParent := relation.GetReplyTo()
if replyParent != "" {
return replyParent
}
@@ -50,7 +51,7 @@ func EventParent(currentID id.EventID, content *event.MessageEventContent) id.Ev
}
// EventField returns field value from raw event content
func EventField[T comparable](content *event.Content, field string) T {
func EventField[T any](content *event.Content, field string) T {
var zero T
raw := content.Raw[field]
if raw == nil {

32
utils/mutex.go Normal file
View File

@@ -0,0 +1,32 @@
package utils
import "sync"
// Mutex map
type Mutex map[string]*sync.Mutex
// NewMutex map
func NewMutex() Mutex {
return Mutex{}
}
// Lock by key
func (m Mutex) Lock(key string) {
_, ok := m[key]
if !ok {
m[key] = &sync.Mutex{}
}
m[key].Lock()
}
// Unlock by key
func (m Mutex) Unlock(key string) {
_, ok := m[key]
if !ok {
return
}
m[key].Unlock()
delete(m, key)
}

View File

@@ -1,6 +1,7 @@
package utils
import (
"net"
"strconv"
"strings"
@@ -22,42 +23,14 @@ func SetDomains(slice []string) {
domains = slice
}
// Mailbox returns mailbox part from email address
func Mailbox(email string) string {
index := strings.LastIndex(email, "@")
if index == -1 {
return email
// AddrIP returns IP from a network address
func AddrIP(addr net.Addr) string {
key := addr.String()
host, _, _ := net.SplitHostPort(key) //nolint:errcheck // either way it's ok
if host != "" {
key = host
}
return email[:index]
}
// EmailsList returns human-readable list of mailbox's emails for all available domains
func EmailsList(mailbox string, domain string) string {
var msg strings.Builder
domain = SanitizeDomain(domain)
msg.WriteString(mailbox)
msg.WriteString("@")
msg.WriteString(domain)
count := len(domains) - 1
for i, aliasDomain := range domains {
if i < count {
msg.WriteString(", ")
}
if aliasDomain == domain {
continue
}
msg.WriteString(mailbox)
msg.WriteString("@")
msg.WriteString(aliasDomain)
}
return msg.String()
}
// Hostname returns hostname part from email address
func Hostname(email string) string {
return email[strings.LastIndex(email, "@")+1:]
return key
}
// SanitizeDomain checks that input domain is available for use

10
vendor/blitiri.com.ar/go/spf/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Ignore anything beginning with a dot: these are usually temporary or
# unimportant.
.*
# Exceptions to the rule above: files we care about that would otherwise be
# excluded.
!.gitignore
# go-fuzz build artifacts.
*-fuzz.zip

27
vendor/blitiri.com.ar/go/spf/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,27 @@
Licensed under the MIT licence, which is reproduced below (from
https://opensource.org/licenses/MIT).
-----
Copyright (c) 2016
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.

49
vendor/blitiri.com.ar/go/spf/README.md generated vendored Normal file
View File

@@ -0,0 +1,49 @@
# blitiri.com.ar/go/spf
[![GoDoc](https://godoc.org/blitiri.com.ar/go/spf?status.svg)](https://pkg.go.dev/blitiri.com.ar/go/spf)
[![Build Status](https://gitlab.com/albertito/spf/badges/master/pipeline.svg)](https://gitlab.com/albertito/spf/-/pipelines)
[![Go Report Card](https://goreportcard.com/badge/github.com/albertito/spf)](https://goreportcard.com/report/github.com/albertito/spf)
[![Coverage Status](https://coveralls.io/repos/github/albertito/spf/badge.svg?branch=next)](https://coveralls.io/github/albertito/spf)
[spf](https://godoc.org/blitiri.com.ar/go/spf) is an open source
implementation of the [Sender Policy Framework
(SPF)](https://en.wikipedia.org/wiki/Sender_Policy_Framework) in Go.
It is used by the [chasquid](https://blitiri.com.ar/p/chasquid/) and
[maddy](https://maddy.email) SMTP servers.
## Example
```go
// Check if `sender` is authorized to send from the given `ip`. The `domain`
// is used if the sender doesn't have one.
result, err := spf.CheckHostWithSender(ip, domain, sender)
if result == spf.Fail {
// Not authorized to send.
}
```
See the [package documentation](https://pkg.go.dev/blitiri.com.ar/go/spf) for
more details.
## Status
All SPF mechanisms, modifiers, and macros are supported.
The API should be considered stable. Major version changes will be announced
to the mailing list (details below).
## Contact
If you have any questions, comments or patches please send them to the mailing
list, `chasquid@googlegroups.com`.
To subscribe, send an email to `chasquid+subscribe@googlegroups.com`.
You can also browse the
[archives](https://groups.google.com/forum/#!forum/chasquid).

58
vendor/blitiri.com.ar/go/spf/fuzz.go generated vendored Normal file
View File

@@ -0,0 +1,58 @@
// Fuzz testing for package spf.
//
// Run it with:
//
// go-fuzz-build blitiri.com.ar/go/spf
// go-fuzz -bin=./spf-fuzz.zip -workdir=testdata/fuzz
//
//go:build gofuzz
// +build gofuzz
package spf
import (
"net"
"blitiri.com.ar/go/spf/internal/dnstest"
)
// Parsed IP addresses, for convenience.
var (
ip1110 = net.ParseIP("1.1.1.0")
ip1111 = net.ParseIP("1.1.1.1")
ip6666 = net.ParseIP("2001:db8::68")
ip6660 = net.ParseIP("2001:db8::0")
)
// DNS resolver to use. Will be initialized once with the expected fixtures,
// and then reused on each fuzz run.
var dns = dnstest.NewResolver()
func init() {
dns.Ip["d1111"] = []net.IP{ip1111}
dns.Ip["d1110"] = []net.IP{ip1110}
dns.Mx["d1110"] = []*net.MX{{"d1110", 5}, {"nothing", 10}}
dns.Ip["d6666"] = []net.IP{ip6666}
dns.Ip["d6660"] = []net.IP{ip6660}
dns.Mx["d6660"] = []*net.MX{{"d6660", 5}, {"nothing", 10}}
dns.Addr["2001:db8::68"] = []string{"sonlas6.", "domain.", "d6666."}
dns.Addr["1.1.1.1"] = []string{"lalala.", "domain.", "d1111."}
}
func Fuzz(data []byte) int {
// The domain's TXT record comes from the fuzzer.
dns.Txt["domain"] = []string{string(data)}
v4result, _ := CheckHostWithSender(
ip1111, "helo", "domain", WithResolver(dns))
v6result, _ := CheckHostWithSender(
ip6666, "helo", "domain", WithResolver(dns))
// Raise priority if any of the results was something other than
// PermError, as it means the data was better formed.
if v4result != PermError || v6result != PermError {
return 1
}
return 0
}

111
vendor/blitiri.com.ar/go/spf/internal/dnstest/dns.go generated vendored Normal file
View File

@@ -0,0 +1,111 @@
// DNS resolver for testing purposes.
//
// In the future, when go fuzz can make use of _test.go files, we can rename
// this file dns_test.go and remove this extra package entirely.
// Until then, unfortunately this is the most reasonable way to share these
// helpers between go and fuzz tests.
package dnstest
import (
"context"
"net"
"strings"
)
// Testing DNS resolver.
//
// Not exported since this is not part of the public API and only used
// internally on tests.
//
type TestResolver struct {
Txt map[string][]string
Mx map[string][]*net.MX
Ip map[string][]net.IP
Addr map[string][]string
Cname map[string]string
Errors map[string]error
}
func NewResolver() *TestResolver {
return &TestResolver{
Txt: map[string][]string{},
Mx: map[string][]*net.MX{},
Ip: map[string][]net.IP{},
Addr: map[string][]string{},
Cname: map[string]string{},
Errors: map[string]error{},
}
}
var nxDomainErr = &net.DNSError{
Err: "domain not found (for testing)",
IsNotFound: true,
}
func (r *TestResolver) LookupTXT(ctx context.Context, domain string) (txts []string, err error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
domain = strings.ToLower(domain)
domain = strings.TrimRight(domain, ".")
if cname, ok := r.Cname[domain]; ok {
return r.LookupTXT(ctx, cname)
}
if _, ok := r.Txt[domain]; !ok && r.Errors[domain] == nil {
return nil, nxDomainErr
}
return r.Txt[domain], r.Errors[domain]
}
func (r *TestResolver) LookupMX(ctx context.Context, domain string) (mxs []*net.MX, err error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
domain = strings.ToLower(domain)
domain = strings.TrimRight(domain, ".")
if cname, ok := r.Cname[domain]; ok {
return r.LookupMX(ctx, cname)
}
if _, ok := r.Mx[domain]; !ok && r.Errors[domain] == nil {
return nil, nxDomainErr
}
return r.Mx[domain], r.Errors[domain]
}
func (r *TestResolver) LookupIPAddr(ctx context.Context, host string) (as []net.IPAddr, err error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
host = strings.ToLower(host)
host = strings.TrimRight(host, ".")
if cname, ok := r.Cname[host]; ok {
return r.LookupIPAddr(ctx, cname)
}
if _, ok := r.Ip[host]; !ok && r.Errors[host] == nil {
return nil, nxDomainErr
}
return ipsToAddrs(r.Ip[host]), r.Errors[host]
}
func ipsToAddrs(ips []net.IP) []net.IPAddr {
as := []net.IPAddr{}
for _, ip := range ips {
as = append(as, net.IPAddr{IP: ip, Zone: ""})
}
return as
}
func (r *TestResolver) LookupAddr(ctx context.Context, host string) (addrs []string, err error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
host = strings.ToLower(host)
host = strings.TrimRight(host, ".")
if cname, ok := r.Cname[host]; ok {
return r.LookupAddr(ctx, cname)
}
if _, ok := r.Addr[host]; !ok && r.Errors[host] == nil {
return nil, nxDomainErr
}
return r.Addr[host], r.Errors[host]
}

1044
vendor/blitiri.com.ar/go/spf/spf.go generated vendored Normal file

File diff suppressed because it is too large Load Diff

9
vendor/github.com/google/uuid/.travis.yml generated vendored Normal file
View File

@@ -0,0 +1,9 @@
language: go
go:
- 1.4.3
- 1.5.3
- tip
script:
- go test -v ./...

10
vendor/github.com/google/uuid/CONTRIBUTING.md generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# How to contribute
We definitely welcome patches and contribution to this project!
### Legal requirements
In order to protect both you and ourselves, you will need to sign the
[Contributor License Agreement](https://cla.developers.google.com/clas).
You may have already signed it for other Google projects.

9
vendor/github.com/google/uuid/CONTRIBUTORS generated vendored Normal file
View File

@@ -0,0 +1,9 @@
Paul Borman <borman@google.com>
bmatsuo
shawnps
theory
jboverfelt
dsymonds
cd1
wallclockbuilder
dansouza

27
vendor/github.com/google/uuid/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,27 @@
Copyright (c) 2009,2014 Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

19
vendor/github.com/google/uuid/README.md generated vendored Normal file
View File

@@ -0,0 +1,19 @@
# uuid ![build status](https://travis-ci.org/google/uuid.svg?branch=master)
The uuid package generates and inspects UUIDs based on
[RFC 4122](http://tools.ietf.org/html/rfc4122)
and DCE 1.1: Authentication and Security Services.
This package is based on the github.com/pborman/uuid package (previously named
code.google.com/p/go-uuid). It differs from these earlier packages in that
a UUID is a 16 byte array rather than a byte slice. One loss due to this
change is the ability to represent an invalid UUID (vs a NIL UUID).
###### Install
`go get github.com/google/uuid`
###### Documentation
[![GoDoc](https://godoc.org/github.com/google/uuid?status.svg)](http://godoc.org/github.com/google/uuid)
Full `go doc` style documentation for the package can be viewed online without
installing this package by using the GoDoc site here:
http://pkg.go.dev/github.com/google/uuid

80
vendor/github.com/google/uuid/dce.go generated vendored Normal file
View File

@@ -0,0 +1,80 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"encoding/binary"
"fmt"
"os"
)
// A Domain represents a Version 2 domain
type Domain byte
// Domain constants for DCE Security (Version 2) UUIDs.
const (
Person = Domain(0)
Group = Domain(1)
Org = Domain(2)
)
// NewDCESecurity returns a DCE Security (Version 2) UUID.
//
// The domain should be one of Person, Group or Org.
// On a POSIX system the id should be the users UID for the Person
// domain and the users GID for the Group. The meaning of id for
// the domain Org or on non-POSIX systems is site defined.
//
// For a given domain/id pair the same token may be returned for up to
// 7 minutes and 10 seconds.
func NewDCESecurity(domain Domain, id uint32) (UUID, error) {
uuid, err := NewUUID()
if err == nil {
uuid[6] = (uuid[6] & 0x0f) | 0x20 // Version 2
uuid[9] = byte(domain)
binary.BigEndian.PutUint32(uuid[0:], id)
}
return uuid, err
}
// NewDCEPerson returns a DCE Security (Version 2) UUID in the person
// domain with the id returned by os.Getuid.
//
// NewDCESecurity(Person, uint32(os.Getuid()))
func NewDCEPerson() (UUID, error) {
return NewDCESecurity(Person, uint32(os.Getuid()))
}
// NewDCEGroup returns a DCE Security (Version 2) UUID in the group
// domain with the id returned by os.Getgid.
//
// NewDCESecurity(Group, uint32(os.Getgid()))
func NewDCEGroup() (UUID, error) {
return NewDCESecurity(Group, uint32(os.Getgid()))
}
// Domain returns the domain for a Version 2 UUID. Domains are only defined
// for Version 2 UUIDs.
func (uuid UUID) Domain() Domain {
return Domain(uuid[9])
}
// ID returns the id for a Version 2 UUID. IDs are only defined for Version 2
// UUIDs.
func (uuid UUID) ID() uint32 {
return binary.BigEndian.Uint32(uuid[0:4])
}
func (d Domain) String() string {
switch d {
case Person:
return "Person"
case Group:
return "Group"
case Org:
return "Org"
}
return fmt.Sprintf("Domain%d", int(d))
}

12
vendor/github.com/google/uuid/doc.go generated vendored Normal file
View File

@@ -0,0 +1,12 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package uuid generates and inspects UUIDs.
//
// UUIDs are based on RFC 4122 and DCE 1.1: Authentication and Security
// Services.
//
// A UUID is a 16 byte (128 bit) array. UUIDs may be used as keys to
// maps or compared directly.
package uuid

53
vendor/github.com/google/uuid/hash.go generated vendored Normal file
View File

@@ -0,0 +1,53 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"crypto/md5"
"crypto/sha1"
"hash"
)
// Well known namespace IDs and UUIDs
var (
NameSpaceDNS = Must(Parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceURL = Must(Parse("6ba7b811-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceOID = Must(Parse("6ba7b812-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceX500 = Must(Parse("6ba7b814-9dad-11d1-80b4-00c04fd430c8"))
Nil UUID // empty UUID, all zeros
)
// NewHash returns a new UUID derived from the hash of space concatenated with
// data generated by h. The hash should be at least 16 byte in length. The
// first 16 bytes of the hash are used to form the UUID. The version of the
// UUID will be the lower 4 bits of version. NewHash is used to implement
// NewMD5 and NewSHA1.
func NewHash(h hash.Hash, space UUID, data []byte, version int) UUID {
h.Reset()
h.Write(space[:]) //nolint:errcheck
h.Write(data) //nolint:errcheck
s := h.Sum(nil)
var uuid UUID
copy(uuid[:], s)
uuid[6] = (uuid[6] & 0x0f) | uint8((version&0xf)<<4)
uuid[8] = (uuid[8] & 0x3f) | 0x80 // RFC 4122 variant
return uuid
}
// NewMD5 returns a new MD5 (Version 3) UUID based on the
// supplied name space and data. It is the same as calling:
//
// NewHash(md5.New(), space, data, 3)
func NewMD5(space UUID, data []byte) UUID {
return NewHash(md5.New(), space, data, 3)
}
// NewSHA1 returns a new SHA1 (Version 5) UUID based on the
// supplied name space and data. It is the same as calling:
//
// NewHash(sha1.New(), space, data, 5)
func NewSHA1(space UUID, data []byte) UUID {
return NewHash(sha1.New(), space, data, 5)
}

38
vendor/github.com/google/uuid/marshal.go generated vendored Normal file
View File

@@ -0,0 +1,38 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import "fmt"
// MarshalText implements encoding.TextMarshaler.
func (uuid UUID) MarshalText() ([]byte, error) {
var js [36]byte
encodeHex(js[:], uuid)
return js[:], nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (uuid *UUID) UnmarshalText(data []byte) error {
id, err := ParseBytes(data)
if err != nil {
return err
}
*uuid = id
return nil
}
// MarshalBinary implements encoding.BinaryMarshaler.
func (uuid UUID) MarshalBinary() ([]byte, error) {
return uuid[:], nil
}
// UnmarshalBinary implements encoding.BinaryUnmarshaler.
func (uuid *UUID) UnmarshalBinary(data []byte) error {
if len(data) != 16 {
return fmt.Errorf("invalid UUID (got %d bytes)", len(data))
}
copy(uuid[:], data)
return nil
}

90
vendor/github.com/google/uuid/node.go generated vendored Normal file
View File

@@ -0,0 +1,90 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"sync"
)
var (
nodeMu sync.Mutex
ifname string // name of interface being used
nodeID [6]byte // hardware for version 1 UUIDs
zeroID [6]byte // nodeID with only 0's
)
// NodeInterface returns the name of the interface from which the NodeID was
// derived. The interface "user" is returned if the NodeID was set by
// SetNodeID.
func NodeInterface() string {
defer nodeMu.Unlock()
nodeMu.Lock()
return ifname
}
// SetNodeInterface selects the hardware address to be used for Version 1 UUIDs.
// If name is "" then the first usable interface found will be used or a random
// Node ID will be generated. If a named interface cannot be found then false
// is returned.
//
// SetNodeInterface never fails when name is "".
func SetNodeInterface(name string) bool {
defer nodeMu.Unlock()
nodeMu.Lock()
return setNodeInterface(name)
}
func setNodeInterface(name string) bool {
iname, addr := getHardwareInterface(name) // null implementation for js
if iname != "" && addr != nil {
ifname = iname
copy(nodeID[:], addr)
return true
}
// We found no interfaces with a valid hardware address. If name
// does not specify a specific interface generate a random Node ID
// (section 4.1.6)
if name == "" {
ifname = "random"
randomBits(nodeID[:])
return true
}
return false
}
// NodeID returns a slice of a copy of the current Node ID, setting the Node ID
// if not already set.
func NodeID() []byte {
defer nodeMu.Unlock()
nodeMu.Lock()
if nodeID == zeroID {
setNodeInterface("")
}
nid := nodeID
return nid[:]
}
// SetNodeID sets the Node ID to be used for Version 1 UUIDs. The first 6 bytes
// of id are used. If id is less than 6 bytes then false is returned and the
// Node ID is not set.
func SetNodeID(id []byte) bool {
if len(id) < 6 {
return false
}
defer nodeMu.Unlock()
nodeMu.Lock()
copy(nodeID[:], id)
ifname = "user"
return true
}
// NodeID returns the 6 byte node id encoded in uuid. It returns nil if uuid is
// not valid. The NodeID is only well defined for version 1 and 2 UUIDs.
func (uuid UUID) NodeID() []byte {
var node [6]byte
copy(node[:], uuid[10:])
return node[:]
}

12
vendor/github.com/google/uuid/node_js.go generated vendored Normal file
View File

@@ -0,0 +1,12 @@
// Copyright 2017 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build js
package uuid
// getHardwareInterface returns nil values for the JS version of the code.
// This remvoves the "net" dependency, because it is not used in the browser.
// Using the "net" library inflates the size of the transpiled JS code by 673k bytes.
func getHardwareInterface(name string) (string, []byte) { return "", nil }

33
vendor/github.com/google/uuid/node_net.go generated vendored Normal file
View File

@@ -0,0 +1,33 @@
// Copyright 2017 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build !js
package uuid
import "net"
var interfaces []net.Interface // cached list of interfaces
// getHardwareInterface returns the name and hardware address of interface name.
// If name is "" then the name and hardware address of one of the system's
// interfaces is returned. If no interfaces are found (name does not exist or
// there are no interfaces) then "", nil is returned.
//
// Only addresses of at least 6 bytes are returned.
func getHardwareInterface(name string) (string, []byte) {
if interfaces == nil {
var err error
interfaces, err = net.Interfaces()
if err != nil {
return "", nil
}
}
for _, ifs := range interfaces {
if len(ifs.HardwareAddr) >= 6 && (name == "" || name == ifs.Name) {
return ifs.Name, ifs.HardwareAddr
}
}
return "", nil
}

118
vendor/github.com/google/uuid/null.go generated vendored Normal file
View File

@@ -0,0 +1,118 @@
// Copyright 2021 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"bytes"
"database/sql/driver"
"encoding/json"
"fmt"
)
var jsonNull = []byte("null")
// NullUUID represents a UUID that may be null.
// NullUUID implements the SQL driver.Scanner interface so
// it can be used as a scan destination:
//
// var u uuid.NullUUID
// err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&u)
// ...
// if u.Valid {
// // use u.UUID
// } else {
// // NULL value
// }
//
type NullUUID struct {
UUID UUID
Valid bool // Valid is true if UUID is not NULL
}
// Scan implements the SQL driver.Scanner interface.
func (nu *NullUUID) Scan(value interface{}) error {
if value == nil {
nu.UUID, nu.Valid = Nil, false
return nil
}
err := nu.UUID.Scan(value)
if err != nil {
nu.Valid = false
return err
}
nu.Valid = true
return nil
}
// Value implements the driver Valuer interface.
func (nu NullUUID) Value() (driver.Value, error) {
if !nu.Valid {
return nil, nil
}
// Delegate to UUID Value function
return nu.UUID.Value()
}
// MarshalBinary implements encoding.BinaryMarshaler.
func (nu NullUUID) MarshalBinary() ([]byte, error) {
if nu.Valid {
return nu.UUID[:], nil
}
return []byte(nil), nil
}
// UnmarshalBinary implements encoding.BinaryUnmarshaler.
func (nu *NullUUID) UnmarshalBinary(data []byte) error {
if len(data) != 16 {
return fmt.Errorf("invalid UUID (got %d bytes)", len(data))
}
copy(nu.UUID[:], data)
nu.Valid = true
return nil
}
// MarshalText implements encoding.TextMarshaler.
func (nu NullUUID) MarshalText() ([]byte, error) {
if nu.Valid {
return nu.UUID.MarshalText()
}
return jsonNull, nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (nu *NullUUID) UnmarshalText(data []byte) error {
id, err := ParseBytes(data)
if err != nil {
nu.Valid = false
return err
}
nu.UUID = id
nu.Valid = true
return nil
}
// MarshalJSON implements json.Marshaler.
func (nu NullUUID) MarshalJSON() ([]byte, error) {
if nu.Valid {
return json.Marshal(nu.UUID)
}
return jsonNull, nil
}
// UnmarshalJSON implements json.Unmarshaler.
func (nu *NullUUID) UnmarshalJSON(data []byte) error {
if bytes.Equal(data, jsonNull) {
*nu = NullUUID{}
return nil // valid null UUID
}
err := json.Unmarshal(data, &nu.UUID)
nu.Valid = err == nil
return err
}

59
vendor/github.com/google/uuid/sql.go generated vendored Normal file
View File

@@ -0,0 +1,59 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"database/sql/driver"
"fmt"
)
// Scan implements sql.Scanner so UUIDs can be read from databases transparently.
// Currently, database types that map to string and []byte are supported. Please
// consult database-specific driver documentation for matching types.
func (uuid *UUID) Scan(src interface{}) error {
switch src := src.(type) {
case nil:
return nil
case string:
// if an empty UUID comes from a table, we return a null UUID
if src == "" {
return nil
}
// see Parse for required string format
u, err := Parse(src)
if err != nil {
return fmt.Errorf("Scan: %v", err)
}
*uuid = u
case []byte:
// if an empty UUID comes from a table, we return a null UUID
if len(src) == 0 {
return nil
}
// assumes a simple slice of bytes if 16 bytes
// otherwise attempts to parse
if len(src) != 16 {
return uuid.Scan(string(src))
}
copy((*uuid)[:], src)
default:
return fmt.Errorf("Scan: unable to scan type %T into UUID", src)
}
return nil
}
// Value implements sql.Valuer so that UUIDs can be written to databases
// transparently. Currently, UUIDs map to strings. Please consult
// database-specific driver documentation for matching types.
func (uuid UUID) Value() (driver.Value, error) {
return uuid.String(), nil
}

123
vendor/github.com/google/uuid/time.go generated vendored Normal file
View File

@@ -0,0 +1,123 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"encoding/binary"
"sync"
"time"
)
// A Time represents a time as the number of 100's of nanoseconds since 15 Oct
// 1582.
type Time int64
const (
lillian = 2299160 // Julian day of 15 Oct 1582
unix = 2440587 // Julian day of 1 Jan 1970
epoch = unix - lillian // Days between epochs
g1582 = epoch * 86400 // seconds between epochs
g1582ns100 = g1582 * 10000000 // 100s of a nanoseconds between epochs
)
var (
timeMu sync.Mutex
lasttime uint64 // last time we returned
clockSeq uint16 // clock sequence for this run
timeNow = time.Now // for testing
)
// UnixTime converts t the number of seconds and nanoseconds using the Unix
// epoch of 1 Jan 1970.
func (t Time) UnixTime() (sec, nsec int64) {
sec = int64(t - g1582ns100)
nsec = (sec % 10000000) * 100
sec /= 10000000
return sec, nsec
}
// GetTime returns the current Time (100s of nanoseconds since 15 Oct 1582) and
// clock sequence as well as adjusting the clock sequence as needed. An error
// is returned if the current time cannot be determined.
func GetTime() (Time, uint16, error) {
defer timeMu.Unlock()
timeMu.Lock()
return getTime()
}
func getTime() (Time, uint16, error) {
t := timeNow()
// If we don't have a clock sequence already, set one.
if clockSeq == 0 {
setClockSequence(-1)
}
now := uint64(t.UnixNano()/100) + g1582ns100
// If time has gone backwards with this clock sequence then we
// increment the clock sequence
if now <= lasttime {
clockSeq = ((clockSeq + 1) & 0x3fff) | 0x8000
}
lasttime = now
return Time(now), clockSeq, nil
}
// ClockSequence returns the current clock sequence, generating one if not
// already set. The clock sequence is only used for Version 1 UUIDs.
//
// The uuid package does not use global static storage for the clock sequence or
// the last time a UUID was generated. Unless SetClockSequence is used, a new
// random clock sequence is generated the first time a clock sequence is
// requested by ClockSequence, GetTime, or NewUUID. (section 4.2.1.1)
func ClockSequence() int {
defer timeMu.Unlock()
timeMu.Lock()
return clockSequence()
}
func clockSequence() int {
if clockSeq == 0 {
setClockSequence(-1)
}
return int(clockSeq & 0x3fff)
}
// SetClockSequence sets the clock sequence to the lower 14 bits of seq. Setting to
// -1 causes a new sequence to be generated.
func SetClockSequence(seq int) {
defer timeMu.Unlock()
timeMu.Lock()
setClockSequence(seq)
}
func setClockSequence(seq int) {
if seq == -1 {
var b [2]byte
randomBits(b[:]) // clock sequence
seq = int(b[0])<<8 | int(b[1])
}
oldSeq := clockSeq
clockSeq = uint16(seq&0x3fff) | 0x8000 // Set our variant
if oldSeq != clockSeq {
lasttime = 0
}
}
// Time returns the time in 100s of nanoseconds since 15 Oct 1582 encoded in
// uuid. The time is only defined for version 1 and 2 UUIDs.
func (uuid UUID) Time() Time {
time := int64(binary.BigEndian.Uint32(uuid[0:4]))
time |= int64(binary.BigEndian.Uint16(uuid[4:6])) << 32
time |= int64(binary.BigEndian.Uint16(uuid[6:8])&0xfff) << 48
return Time(time)
}
// ClockSequence returns the clock sequence encoded in uuid.
// The clock sequence is only well defined for version 1 and 2 UUIDs.
func (uuid UUID) ClockSequence() int {
return int(binary.BigEndian.Uint16(uuid[8:10])) & 0x3fff
}

43
vendor/github.com/google/uuid/util.go generated vendored Normal file
View File

@@ -0,0 +1,43 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"io"
)
// randomBits completely fills slice b with random data.
func randomBits(b []byte) {
if _, err := io.ReadFull(rander, b); err != nil {
panic(err.Error()) // rand should never fail
}
}
// xvalues returns the value of a byte as a hexadecimal digit or 255.
var xvalues = [256]byte{
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
}
// xtob converts hex characters x1 and x2 into a byte.
func xtob(x1, x2 byte) (byte, bool) {
b1 := xvalues[x1]
b2 := xvalues[x2]
return (b1 << 4) | b2, b1 != 255 && b2 != 255
}

294
vendor/github.com/google/uuid/uuid.go generated vendored Normal file
View File

@@ -0,0 +1,294 @@
// Copyright 2018 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"sync"
)
// A UUID is a 128 bit (16 byte) Universal Unique IDentifier as defined in RFC
// 4122.
type UUID [16]byte
// A Version represents a UUID's version.
type Version byte
// A Variant represents a UUID's variant.
type Variant byte
// Constants returned by Variant.
const (
Invalid = Variant(iota) // Invalid UUID
RFC4122 // The variant specified in RFC4122
Reserved // Reserved, NCS backward compatibility.
Microsoft // Reserved, Microsoft Corporation backward compatibility.
Future // Reserved for future definition.
)
const randPoolSize = 16 * 16
var (
rander = rand.Reader // random function
poolEnabled = false
poolMu sync.Mutex
poolPos = randPoolSize // protected with poolMu
pool [randPoolSize]byte // protected with poolMu
)
type invalidLengthError struct{ len int }
func (err invalidLengthError) Error() string {
return fmt.Sprintf("invalid UUID length: %d", err.len)
}
// IsInvalidLengthError is matcher function for custom error invalidLengthError
func IsInvalidLengthError(err error) bool {
_, ok := err.(invalidLengthError)
return ok
}
// Parse decodes s into a UUID or returns an error. Both the standard UUID
// forms of xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx and
// urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx are decoded as well as the
// Microsoft encoding {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} and the raw hex
// encoding: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.
func Parse(s string) (UUID, error) {
var uuid UUID
switch len(s) {
// xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
case 36:
// urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
case 36 + 9:
if strings.ToLower(s[:9]) != "urn:uuid:" {
return uuid, fmt.Errorf("invalid urn prefix: %q", s[:9])
}
s = s[9:]
// {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
case 36 + 2:
s = s[1:]
// xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
case 32:
var ok bool
for i := range uuid {
uuid[i], ok = xtob(s[i*2], s[i*2+1])
if !ok {
return uuid, errors.New("invalid UUID format")
}
}
return uuid, nil
default:
return uuid, invalidLengthError{len(s)}
}
// s is now at least 36 bytes long
// it must be of the form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' {
return uuid, errors.New("invalid UUID format")
}
for i, x := range [16]int{
0, 2, 4, 6,
9, 11,
14, 16,
19, 21,
24, 26, 28, 30, 32, 34} {
v, ok := xtob(s[x], s[x+1])
if !ok {
return uuid, errors.New("invalid UUID format")
}
uuid[i] = v
}
return uuid, nil
}
// ParseBytes is like Parse, except it parses a byte slice instead of a string.
func ParseBytes(b []byte) (UUID, error) {
var uuid UUID
switch len(b) {
case 36: // xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
case 36 + 9: // urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
if !bytes.Equal(bytes.ToLower(b[:9]), []byte("urn:uuid:")) {
return uuid, fmt.Errorf("invalid urn prefix: %q", b[:9])
}
b = b[9:]
case 36 + 2: // {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
b = b[1:]
case 32: // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
var ok bool
for i := 0; i < 32; i += 2 {
uuid[i/2], ok = xtob(b[i], b[i+1])
if !ok {
return uuid, errors.New("invalid UUID format")
}
}
return uuid, nil
default:
return uuid, invalidLengthError{len(b)}
}
// s is now at least 36 bytes long
// it must be of the form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
if b[8] != '-' || b[13] != '-' || b[18] != '-' || b[23] != '-' {
return uuid, errors.New("invalid UUID format")
}
for i, x := range [16]int{
0, 2, 4, 6,
9, 11,
14, 16,
19, 21,
24, 26, 28, 30, 32, 34} {
v, ok := xtob(b[x], b[x+1])
if !ok {
return uuid, errors.New("invalid UUID format")
}
uuid[i] = v
}
return uuid, nil
}
// MustParse is like Parse but panics if the string cannot be parsed.
// It simplifies safe initialization of global variables holding compiled UUIDs.
func MustParse(s string) UUID {
uuid, err := Parse(s)
if err != nil {
panic(`uuid: Parse(` + s + `): ` + err.Error())
}
return uuid
}
// FromBytes creates a new UUID from a byte slice. Returns an error if the slice
// does not have a length of 16. The bytes are copied from the slice.
func FromBytes(b []byte) (uuid UUID, err error) {
err = uuid.UnmarshalBinary(b)
return uuid, err
}
// Must returns uuid if err is nil and panics otherwise.
func Must(uuid UUID, err error) UUID {
if err != nil {
panic(err)
}
return uuid
}
// String returns the string form of uuid, xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// , or "" if uuid is invalid.
func (uuid UUID) String() string {
var buf [36]byte
encodeHex(buf[:], uuid)
return string(buf[:])
}
// URN returns the RFC 2141 URN form of uuid,
// urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, or "" if uuid is invalid.
func (uuid UUID) URN() string {
var buf [36 + 9]byte
copy(buf[:], "urn:uuid:")
encodeHex(buf[9:], uuid)
return string(buf[:])
}
func encodeHex(dst []byte, uuid UUID) {
hex.Encode(dst, uuid[:4])
dst[8] = '-'
hex.Encode(dst[9:13], uuid[4:6])
dst[13] = '-'
hex.Encode(dst[14:18], uuid[6:8])
dst[18] = '-'
hex.Encode(dst[19:23], uuid[8:10])
dst[23] = '-'
hex.Encode(dst[24:], uuid[10:])
}
// Variant returns the variant encoded in uuid.
func (uuid UUID) Variant() Variant {
switch {
case (uuid[8] & 0xc0) == 0x80:
return RFC4122
case (uuid[8] & 0xe0) == 0xc0:
return Microsoft
case (uuid[8] & 0xe0) == 0xe0:
return Future
default:
return Reserved
}
}
// Version returns the version of uuid.
func (uuid UUID) Version() Version {
return Version(uuid[6] >> 4)
}
func (v Version) String() string {
if v > 15 {
return fmt.Sprintf("BAD_VERSION_%d", v)
}
return fmt.Sprintf("VERSION_%d", v)
}
func (v Variant) String() string {
switch v {
case RFC4122:
return "RFC4122"
case Reserved:
return "Reserved"
case Microsoft:
return "Microsoft"
case Future:
return "Future"
case Invalid:
return "Invalid"
}
return fmt.Sprintf("BadVariant%d", int(v))
}
// SetRand sets the random number generator to r, which implements io.Reader.
// If r.Read returns an error when the package requests random data then
// a panic will be issued.
//
// Calling SetRand with nil sets the random number generator to the default
// generator.
func SetRand(r io.Reader) {
if r == nil {
rander = rand.Reader
return
}
rander = r
}
// EnableRandPool enables internal randomness pool used for Random
// (Version 4) UUID generation. The pool contains random bytes read from
// the random number generator on demand in batches. Enabling the pool
// may improve the UUID generation throughput significantly.
//
// Since the pool is stored on the Go heap, this feature may be a bad fit
// for security sensitive applications.
//
// Both EnableRandPool and DisableRandPool are not thread-safe and should
// only be called when there is no possibility that New or any other
// UUID Version 4 generation function will be called concurrently.
func EnableRandPool() {
poolEnabled = true
}
// DisableRandPool disables the randomness pool if it was previously
// enabled with EnableRandPool.
//
// Both EnableRandPool and DisableRandPool are not thread-safe and should
// only be called when there is no possibility that New or any other
// UUID Version 4 generation function will be called concurrently.
func DisableRandPool() {
poolEnabled = false
defer poolMu.Unlock()
poolMu.Lock()
poolPos = randPoolSize
}

44
vendor/github.com/google/uuid/version1.go generated vendored Normal file
View File

@@ -0,0 +1,44 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"encoding/binary"
)
// NewUUID returns a Version 1 UUID based on the current NodeID and clock
// sequence, and the current time. If the NodeID has not been set by SetNodeID
// or SetNodeInterface then it will be set automatically. If the NodeID cannot
// be set NewUUID returns nil. If clock sequence has not been set by
// SetClockSequence then it will be set automatically. If GetTime fails to
// return the current NewUUID returns nil and an error.
//
// In most cases, New should be used.
func NewUUID() (UUID, error) {
var uuid UUID
now, seq, err := GetTime()
if err != nil {
return uuid, err
}
timeLow := uint32(now & 0xffffffff)
timeMid := uint16((now >> 32) & 0xffff)
timeHi := uint16((now >> 48) & 0x0fff)
timeHi |= 0x1000 // Version 1
binary.BigEndian.PutUint32(uuid[0:], timeLow)
binary.BigEndian.PutUint16(uuid[4:], timeMid)
binary.BigEndian.PutUint16(uuid[6:], timeHi)
binary.BigEndian.PutUint16(uuid[8:], seq)
nodeMu.Lock()
if nodeID == zeroID {
setNodeInterface("")
}
copy(uuid[10:], nodeID[:])
nodeMu.Unlock()
return uuid, nil
}

76
vendor/github.com/google/uuid/version4.go generated vendored Normal file
View File

@@ -0,0 +1,76 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import "io"
// New creates a new random UUID or panics. New is equivalent to
// the expression
//
// uuid.Must(uuid.NewRandom())
func New() UUID {
return Must(NewRandom())
}
// NewString creates a new random UUID and returns it as a string or panics.
// NewString is equivalent to the expression
//
// uuid.New().String()
func NewString() string {
return Must(NewRandom()).String()
}
// NewRandom returns a Random (Version 4) UUID.
//
// The strength of the UUIDs is based on the strength of the crypto/rand
// package.
//
// Uses the randomness pool if it was enabled with EnableRandPool.
//
// A note about uniqueness derived from the UUID Wikipedia entry:
//
// Randomly generated UUIDs have 122 random bits. One's annual risk of being
// hit by a meteorite is estimated to be one chance in 17 billion, that
// means the probability is about 0.00000000006 (6 × 1011),
// equivalent to the odds of creating a few tens of trillions of UUIDs in a
// year and having one duplicate.
func NewRandom() (UUID, error) {
if !poolEnabled {
return NewRandomFromReader(rander)
}
return newRandomFromPool()
}
// NewRandomFromReader returns a UUID based on bytes read from a given io.Reader.
func NewRandomFromReader(r io.Reader) (UUID, error) {
var uuid UUID
_, err := io.ReadFull(r, uuid[:])
if err != nil {
return Nil, err
}
uuid[6] = (uuid[6] & 0x0f) | 0x40 // Version 4
uuid[8] = (uuid[8] & 0x3f) | 0x80 // Variant is 10
return uuid, nil
}
func newRandomFromPool() (UUID, error) {
var uuid UUID
poolMu.Lock()
if poolPos == randPoolSize {
_, err := io.ReadFull(rander, pool[:])
if err != nil {
poolMu.Unlock()
return Nil, err
}
poolPos = 0
}
copy(uuid[:], pool[poolPos:(poolPos+16)])
poolPos += 16
poolMu.Unlock()
uuid[6] = (uuid[6] & 0x0f) | 0x40 // Version 4
uuid[8] = (uuid[8] & 0x3f) | 0x80 // Variant is 10
return uuid, nil
}

1
vendor/gitlab.com/etke.cc/go/healthchecks/.gitignore generated vendored Normal file
View File

@@ -0,0 +1 @@
/testdata

166
vendor/gitlab.com/etke.cc/go/healthchecks/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,166 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

13
vendor/gitlab.com/etke.cc/go/healthchecks/README.md generated vendored Normal file
View File

@@ -0,0 +1,13 @@
# healthchecks
A [healthchecks.io](https://github.com/healthchecks/healthchecks) wrapper
check the godoc for information
```go
hc := healthchecks.New("your-uuid")
go hc.Auto()
hc.Log(strings.NewReader("optional body you can attach to any action"))
hc.Shutdown()
```

24
vendor/gitlab.com/etke.cc/go/healthchecks/auto.go generated vendored Normal file
View File

@@ -0,0 +1,24 @@
package healthchecks
import "time"
// Auto is intended to start as separate goroutine (go c.Auto(5*time.Second))
// it will automatically send Success (ping) requests, leaving the client itself fully usable
// to stop the Auto(), call Shutdown() and destroy the client
func (c *Client) Auto(every time.Duration) {
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.Success()
case <-c.done:
return
}
}
}
// Shutdown the client
func (c *Client) Shutdown() {
c.done <- true
}

70
vendor/gitlab.com/etke.cc/go/healthchecks/client.go generated vendored Normal file
View File

@@ -0,0 +1,70 @@
package healthchecks
import (
"fmt"
"io"
"net/http"
"strconv"
)
// Client for healthchecks
type Client struct {
HTTP *http.Client
log func(string, error)
baseURL string
uuid string
rid string
done chan bool
}
func (c *Client) call(operation, endpoint string, body ...io.Reader) {
var err error
var resp *http.Response
targetURL := fmt.Sprintf("%s/%s%s?rid=%s", c.baseURL, c.uuid, endpoint, c.rid)
if len(body) > 0 {
resp, err = c.HTTP.Post(targetURL, "text/plain; charset=utf-8", body[0])
} else {
resp, err = c.HTTP.Head(targetURL)
}
if err != nil {
c.log(operation, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respb, rerr := io.ReadAll(resp.Body)
if rerr != nil {
c.log(operation+":response", rerr)
return
}
rerr = fmt.Errorf(string(respb))
c.log(operation+":response", rerr)
return
}
}
// Start signal means the job started
func (c *Client) Start(optionalBody ...io.Reader) {
c.call("start", "/start", optionalBody...)
}
// Success signal means the job has completed successfully (or, a continuously running process is still running and healthy).
func (c *Client) Success(optionalBody ...io.Reader) {
c.call("success", "", optionalBody...)
}
// Fail signal means the job failed
func (c *Client) Fail(optionalBody ...io.Reader) {
c.call("fail", "/fail", optionalBody...)
}
// Log signal just adds an event to the job log, without changing job status
func (c *Client) Log(optionalBody ...io.Reader) {
c.call("log", "/log", optionalBody...)
}
// ExitStatus signal sends job's exit code (0-255)
func (c *Client) ExitStatus(exitCode int, optionalBody ...io.Reader) {
c.call("exit status", "/"+strconv.Itoa(exitCode), optionalBody...)
}

View File

@@ -0,0 +1,40 @@
package healthchecks
import (
"fmt"
"net/http"
"time"
"github.com/google/uuid"
)
// DefaultAPI base url for checks
const DefaultAPI = "https://hc-ping.com"
// ErrLog used to log errors occurred during an operation
type ErrLog func(operation string, err error)
// DefaultErrLog if you don't provide one yourself
var DefaultErrLog = func(operation string, err error) {
fmt.Printf("healtchecks operation %q failed: %v\n", operation, err)
}
// New healthchecks client
func New(hcUUID string, errlog ...ErrLog) *Client {
rid, _ := uuid.NewRandom()
c := &Client{
baseURL: DefaultAPI,
uuid: hcUUID,
rid: rid.String(),
done: make(chan bool, 1),
}
c.HTTP = &http.Client{
Timeout: 10 * time.Second,
}
c.log = DefaultErrLog
if len(errlog) > 0 {
c.log = errlog[0]
}
return c
}

View File

@@ -1,80 +1,142 @@
package validator
import (
"fmt"
"net"
"net/mail"
"strings"
"time"
"blitiri.com.ar/go/spf"
"gitlab.com/etke.cc/go/trysmtp"
"golang.org/x/net/context"
)
// Email checks if email is valid
func (v *V) Email(email string) bool {
func (v *V) Email(email string, optionalSenderIP ...net.IP) bool {
// edge case: email may be optional
if email == "" {
return !v.enforce.Email
}
length := len(email)
// email cannot too short and too big
if length < 3 || length > 254 {
v.log.Info("email %s invalid, reason: length", email)
return false
}
_, err := mail.ParseAddress(email)
address, err := mail.ParseAddress(email)
if err != nil {
v.log.Info("email %s invalid, reason: %v", email, err)
return false
}
emailb := []byte(email)
for _, spamregex := range v.spamlist {
if spamregex.Match(emailb) {
v.log.Info("email %s invalid, reason: spamlist", email)
return false
}
}
if v.enforce.MX {
if v.emailNoMX(email) {
return false
}
}
if v.enforce.SMTP {
if v.emailNoSMTP(email) {
return false
}
}
return true
email = address.Address
return v.emailChecks(email, optionalSenderIP...)
}
func (v *V) emailNoMX(email string) bool {
at := strings.LastIndex(email, "@")
domain := email[at+1:]
nomx := !v.MX(domain)
if nomx {
v.log.Info("email %s domain %s invalid, reason: no MX", email, domain)
return true
func (v *V) emailChecks(email string, optionalSenderIP ...net.IP) bool {
maxChecks := 4
var senderIP net.IP
if len(optionalSenderIP) > 0 {
senderIP = optionalSenderIP[0]
}
errchan := make(chan error, maxChecks)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return false
}
go v.emailSpamlist(ctx, email, errchan)
go v.emailNoMX(ctx, email, errchan)
go v.emailNoSPF(ctx, email, senderIP, errchan)
go v.emailNoSMTP(ctx, email, errchan)
func (v *V) emailNoSMTP(email string) bool {
client, err := trysmtp.Connect(v.from, email)
if err != nil {
if strings.HasPrefix(err.Error(), "45") {
v.log.Info("email %s may be invalid, reason: SMTP check (%v)", email, err)
var checks int
for {
checks++
err := <-errchan
if err != nil {
v.log.Info("email %q is invalid, reason: %v", email, err)
return false
}
v.log.Info("email %s invalid, reason: SMTP check (%v)", email, err)
return true
if checks >= maxChecks {
return true
}
}
}
func (v *V) emailSpamlist(ctx context.Context, email string, errchan chan error) {
select {
case <-ctx.Done():
return
default:
emailb := []byte(email)
for _, spamregex := range v.spamlist {
if spamregex.Match(emailb) {
errchan <- fmt.Errorf("spamlist")
return
}
}
errchan <- nil
}
}
func (v *V) emailNoMX(ctx context.Context, email string, errchan chan error) {
select {
case <-ctx.Done():
return
default:
if !v.enforce.MX {
errchan <- nil
return
}
at := strings.LastIndex(email, "@")
domain := email[at+1:]
if !v.MX(domain) {
v.log.Info("email %s domain %s invalid, reason: no MX", email, domain)
errchan <- fmt.Errorf("no MX")
return
}
errchan <- nil
}
}
func (v *V) emailNoSMTP(ctx context.Context, email string, errchan chan error) {
select {
case <-ctx.Done():
return
default:
if !v.enforce.SMTP {
errchan <- nil
return
}
client, err := trysmtp.Connect(v.from, email)
if err != nil {
if strings.HasPrefix(err.Error(), "45") {
v.log.Info("email %s may be invalid, reason: SMTP check (%v)", email, err)
errchan <- nil
return
}
v.log.Info("email %s invalid, reason: SMTP check (%v)", email, err)
errchan <- fmt.Errorf("SMTP")
return
}
client.Close()
errchan <- nil
}
}
func (v *V) emailNoSPF(ctx context.Context, email string, senderIP net.IP, errchan chan error) {
select {
case <-ctx.Done():
return
default:
if !v.enforce.SPF {
errchan <- nil
return
}
result, _ := spf.CheckHostWithSender(senderIP, "", email, spf.WithTraceFunc(v.log.Info)) //nolint:errcheck // not a error
if result == spf.Fail {
errchan <- fmt.Errorf("SPF")
return
}
errchan <- nil
}
defer client.Close()
return false
}

View File

@@ -21,6 +21,8 @@ type Enforce struct {
Domain bool
// SMTP enforces SMTP check (email actually exists on mail server) and rejects non-existing emails
SMTP bool
// SPF enforces SPF record check (sender allowed to use that email and send emails) and rejects unathorized emails
SPF bool
// MX enforces MX records check on email's mail server
MX bool
}

56
vendor/golang.org/x/net/context/context.go generated vendored Normal file
View File

@@ -0,0 +1,56 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package context defines the Context type, which carries deadlines,
// cancelation signals, and other request-scoped values across API boundaries
// and between processes.
// As of Go 1.7 this package is available in the standard library under the
// name context. https://golang.org/pkg/context.
//
// Incoming requests to a server should create a Context, and outgoing calls to
// servers should accept a Context. The chain of function calls between must
// propagate the Context, optionally replacing it with a modified copy created
// using WithDeadline, WithTimeout, WithCancel, or WithValue.
//
// Programs that use Contexts should follow these rules to keep interfaces
// consistent across packages and enable static analysis tools to check context
// propagation:
//
// Do not store Contexts inside a struct type; instead, pass a Context
// explicitly to each function that needs it. The Context should be the first
// parameter, typically named ctx:
//
// func DoSomething(ctx context.Context, arg Arg) error {
// // ... use ctx ...
// }
//
// Do not pass a nil Context, even if a function permits it. Pass context.TODO
// if you are unsure about which Context to use.
//
// Use context Values only for request-scoped data that transits processes and
// APIs, not for passing optional parameters to functions.
//
// The same Context may be passed to functions running in different goroutines;
// Contexts are safe for simultaneous use by multiple goroutines.
//
// See http://blog.golang.org/context for example code for a server that uses
// Contexts.
package context // import "golang.org/x/net/context"
// Background returns a non-nil, empty Context. It is never canceled, has no
// values, and has no deadline. It is typically used by the main function,
// initialization, and tests, and as the top-level Context for incoming
// requests.
func Background() Context {
return background
}
// TODO returns a non-nil, empty Context. Code should use context.TODO when
// it's unclear which Context to use or it is not yet available (because the
// surrounding function has not yet been extended to accept a Context
// parameter). TODO is recognized by static analysis tools that determine
// whether Contexts are propagated correctly in a program.
func TODO() Context {
return todo
}

73
vendor/golang.org/x/net/context/go17.go generated vendored Normal file
View File

@@ -0,0 +1,73 @@
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.7
// +build go1.7
package context
import (
"context" // standard library's context, as of Go 1.7
"time"
)
var (
todo = context.TODO()
background = context.Background()
)
// Canceled is the error returned by Context.Err when the context is canceled.
var Canceled = context.Canceled
// DeadlineExceeded is the error returned by Context.Err when the context's
// deadline passes.
var DeadlineExceeded = context.DeadlineExceeded
// WithCancel returns a copy of parent with a new Done channel. The returned
// context's Done channel is closed when the returned cancel function is called
// or when the parent context's Done channel is closed, whichever happens first.
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
ctx, f := context.WithCancel(parent)
return ctx, f
}
// WithDeadline returns a copy of the parent context with the deadline adjusted
// to be no later than d. If the parent's deadline is already earlier than d,
// WithDeadline(parent, d) is semantically equivalent to parent. The returned
// context's Done channel is closed when the deadline expires, when the returned
// cancel function is called, or when the parent context's Done channel is
// closed, whichever happens first.
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) {
ctx, f := context.WithDeadline(parent, deadline)
return ctx, f
}
// WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete:
//
// func slowOperationWithTimeout(ctx context.Context) (Result, error) {
// ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
// defer cancel() // releases resources if slowOperation completes before timeout elapses
// return slowOperation(ctx)
// }
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
// WithValue returns a copy of parent in which the value associated with key is
// val.
//
// Use context Values only for request-scoped data that transits processes and
// APIs, not for passing optional parameters to functions.
func WithValue(parent Context, key interface{}, val interface{}) Context {
return context.WithValue(parent, key, val)
}

21
vendor/golang.org/x/net/context/go19.go generated vendored Normal file
View File

@@ -0,0 +1,21 @@
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.9
// +build go1.9
package context
import "context" // standard library's context, as of Go 1.7
// A Context carries a deadline, a cancelation signal, and other values across
// API boundaries.
//
// Context's methods may be called by multiple goroutines simultaneously.
type Context = context.Context
// A CancelFunc tells an operation to abandon its work.
// A CancelFunc does not wait for the work to stop.
// After the first call, subsequent calls to a CancelFunc do nothing.
type CancelFunc = context.CancelFunc

301
vendor/golang.org/x/net/context/pre_go17.go generated vendored Normal file
View File

@@ -0,0 +1,301 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !go1.7
// +build !go1.7
package context
import (
"errors"
"fmt"
"sync"
"time"
)
// An emptyCtx is never canceled, has no values, and has no deadline. It is not
// struct{}, since vars of this type must have distinct addresses.
type emptyCtx int
func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}
func (*emptyCtx) Done() <-chan struct{} {
return nil
}
func (*emptyCtx) Err() error {
return nil
}
func (*emptyCtx) Value(key interface{}) interface{} {
return nil
}
func (e *emptyCtx) String() string {
switch e {
case background:
return "context.Background"
case todo:
return "context.TODO"
}
return "unknown empty Context"
}
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
// Canceled is the error returned by Context.Err when the context is canceled.
var Canceled = errors.New("context canceled")
// DeadlineExceeded is the error returned by Context.Err when the context's
// deadline passes.
var DeadlineExceeded = errors.New("context deadline exceeded")
// WithCancel returns a copy of parent with a new Done channel. The returned
// context's Done channel is closed when the returned cancel function is called
// or when the parent context's Done channel is closed, whichever happens first.
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, c)
return c, func() { c.cancel(true, Canceled) }
}
// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) *cancelCtx {
return &cancelCtx{
Context: parent,
done: make(chan struct{}),
}
}
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
if parent.Done() == nil {
return // parent is never canceled
}
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// parent has already been canceled
child.cancel(false, p.err)
} else {
if p.children == nil {
p.children = make(map[canceler]bool)
}
p.children[child] = true
}
p.mu.Unlock()
} else {
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
// parentCancelCtx follows a chain of parent references until it finds a
// *cancelCtx. This function understands how each of the concrete types in this
// package represents its parent.
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
for {
switch c := parent.(type) {
case *cancelCtx:
return c, true
case *timerCtx:
return c.cancelCtx, true
case *valueCtx:
parent = c.Context
default:
return nil, false
}
}
}
// removeChild removes a context from its parent.
func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}
// A canceler is a context type that can be canceled directly. The
// implementations are *cancelCtx and *timerCtx.
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}
// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
Context
done chan struct{} // closed by the first cancel call.
mu sync.Mutex
children map[canceler]bool // set to nil by the first cancel call
err error // set to non-nil by the first cancel call
}
func (c *cancelCtx) Done() <-chan struct{} {
return c.done
}
func (c *cancelCtx) Err() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.err
}
func (c *cancelCtx) String() string {
return fmt.Sprintf("%v.WithCancel", c.Context)
}
// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // already canceled
}
c.err = err
close(c.done)
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c)
}
}
// WithDeadline returns a copy of the parent context with the deadline adjusted
// to be no later than d. If the parent's deadline is already earlier than d,
// WithDeadline(parent, d) is semantically equivalent to parent. The returned
// context's Done channel is closed when the deadline expires, when the returned
// cancel function is called, or when the parent context's Done channel is
// closed, whichever happens first.
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) {
if cur, ok := parent.Deadline(); ok && cur.Before(deadline) {
// The current deadline is already sooner than the new one.
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: deadline,
}
propagateCancel(parent, c)
d := deadline.Sub(time.Now())
if d <= 0 {
c.cancel(true, DeadlineExceeded) // deadline has already passed
return c, func() { c.cancel(true, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
c.timer = time.AfterFunc(d, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
// A timerCtx carries a timer and a deadline. It embeds a cancelCtx to
// implement Done and Err. It implements cancel by stopping its timer then
// delegating to cancelCtx.cancel.
type timerCtx struct {
*cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}
func (c *timerCtx) String() string {
return fmt.Sprintf("%v.WithDeadline(%s [%s])", c.cancelCtx.Context, c.deadline, c.deadline.Sub(time.Now()))
}
func (c *timerCtx) cancel(removeFromParent bool, err error) {
c.cancelCtx.cancel(false, err)
if removeFromParent {
// Remove this timerCtx from its parent cancelCtx's children.
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
// WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete:
//
// func slowOperationWithTimeout(ctx context.Context) (Result, error) {
// ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
// defer cancel() // releases resources if slowOperation completes before timeout elapses
// return slowOperation(ctx)
// }
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
// WithValue returns a copy of parent in which the value associated with key is
// val.
//
// Use context Values only for request-scoped data that transits processes and
// APIs, not for passing optional parameters to functions.
func WithValue(parent Context, key interface{}, val interface{}) Context {
return &valueCtx{parent, key, val}
}
// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
Context
key, val interface{}
}
func (c *valueCtx) String() string {
return fmt.Sprintf("%v.WithValue(%#v, %#v)", c.Context, c.key, c.val)
}
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}

110
vendor/golang.org/x/net/context/pre_go19.go generated vendored Normal file
View File

@@ -0,0 +1,110 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build !go1.9
// +build !go1.9
package context
import "time"
// A Context carries a deadline, a cancelation signal, and other values across
// API boundaries.
//
// Context's methods may be called by multiple goroutines simultaneously.
type Context interface {
// Deadline returns the time when work done on behalf of this context
// should be canceled. Deadline returns ok==false when no deadline is
// set. Successive calls to Deadline return the same results.
Deadline() (deadline time.Time, ok bool)
// Done returns a channel that's closed when work done on behalf of this
// context should be canceled. Done may return nil if this context can
// never be canceled. Successive calls to Done return the same value.
//
// WithCancel arranges for Done to be closed when cancel is called;
// WithDeadline arranges for Done to be closed when the deadline
// expires; WithTimeout arranges for Done to be closed when the timeout
// elapses.
//
// Done is provided for use in select statements:
//
// // Stream generates values with DoSomething and sends them to out
// // until DoSomething returns an error or ctx.Done is closed.
// func Stream(ctx context.Context, out chan<- Value) error {
// for {
// v, err := DoSomething(ctx)
// if err != nil {
// return err
// }
// select {
// case <-ctx.Done():
// return ctx.Err()
// case out <- v:
// }
// }
// }
//
// See http://blog.golang.org/pipelines for more examples of how to use
// a Done channel for cancelation.
Done() <-chan struct{}
// Err returns a non-nil error value after Done is closed. Err returns
// Canceled if the context was canceled or DeadlineExceeded if the
// context's deadline passed. No other values for Err are defined.
// After Done is closed, successive calls to Err return the same value.
Err() error
// Value returns the value associated with this context for key, or nil
// if no value is associated with key. Successive calls to Value with
// the same key returns the same result.
//
// Use context values only for request-scoped data that transits
// processes and API boundaries, not for passing optional parameters to
// functions.
//
// A key identifies a specific value in a Context. Functions that wish
// to store values in Context typically allocate a key in a global
// variable then use that key as the argument to context.WithValue and
// Context.Value. A key can be any type that supports equality;
// packages should define keys as an unexported type to avoid
// collisions.
//
// Packages that define a Context key should provide type-safe accessors
// for the values stores using that key:
//
// // Package user defines a User type that's stored in Contexts.
// package user
//
// import "golang.org/x/net/context"
//
// // User is the type of value stored in the Contexts.
// type User struct {...}
//
// // key is an unexported type for keys defined in this package.
// // This prevents collisions with keys defined in other packages.
// type key int
//
// // userKey is the key for user.User values in Contexts. It is
// // unexported; clients use user.NewContext and user.FromContext
// // instead of using this key directly.
// var userKey key = 0
//
// // NewContext returns a new Context that carries value u.
// func NewContext(ctx context.Context, u *User) context.Context {
// return context.WithValue(ctx, userKey, u)
// }
//
// // FromContext returns the User value stored in ctx, if any.
// func FromContext(ctx context.Context) (*User, bool) {
// u, ok := ctx.Value(userKey).(*User)
// return u, ok
// }
Value(key interface{}) interface{}
}
// A CancelFunc tells an operation to abandon its work.
// A CancelFunc does not wait for the work to stop.
// After the first call, subsequent calls to a CancelFunc do nothing.
type CancelFunc func()

13
vendor/modules.txt vendored
View File

@@ -1,3 +1,7 @@
# blitiri.com.ar/go/spf v1.5.1
## explicit; go 1.15
blitiri.com.ar/go/spf
blitiri.com.ar/go/spf/internal/dnstest
# github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a
## explicit
github.com/cention-sany/utf7
@@ -27,6 +31,9 @@ github.com/getsentry/sentry-go/internal/ratelimit
github.com/gogs/chardet
# github.com/google/go-cmp v0.5.8
## explicit; go 1.13
# github.com/google/uuid v1.3.0
## explicit
github.com/google/uuid
# github.com/gorilla/mux v1.8.0
## explicit; go 1.12
github.com/gorilla/mux
@@ -115,6 +122,9 @@ github.com/yuin/goldmark/util
# gitlab.com/etke.cc/go/env v1.0.0
## explicit; go 1.18
gitlab.com/etke.cc/go/env
# gitlab.com/etke.cc/go/healthchecks v1.0.1
## explicit; go 1.18
gitlab.com/etke.cc/go/healthchecks
# gitlab.com/etke.cc/go/logger v1.1.0
## explicit; go 1.18
gitlab.com/etke.cc/go/logger
@@ -127,7 +137,7 @@ gitlab.com/etke.cc/go/secgen
# gitlab.com/etke.cc/go/trysmtp v1.0.0
## explicit; go 1.18
gitlab.com/etke.cc/go/trysmtp
# gitlab.com/etke.cc/go/validator v1.0.4
# gitlab.com/etke.cc/go/validator v1.0.6
## explicit; go 1.18
gitlab.com/etke.cc/go/validator
# gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6
@@ -152,6 +162,7 @@ golang.org/x/crypto/ssh
golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
# golang.org/x/net v0.2.0
## explicit; go 1.17
golang.org/x/net/context
golang.org/x/net/html
golang.org/x/net/html/atom
golang.org/x/net/publicsuffix