36 Commits

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

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

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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -4,8 +4,9 @@
An Email to Matrix bridge. 1 room = 1 mailbox. An Email to Matrix bridge. 1 room = 1 mailbox.
Postmoogle is an actual SMTP server that allows you to receive emails on your matrix server. Postmoogle is an actual SMTP server that allows you to send and receive emails on your matrix server.
It can't be used with arbitrary email providers, but setup your own provider "with matrix interface" instead. It can't be used with arbitrary email providers, because it acts as an actual email provider itself,
so you can use it to send emails from your apps and scripts as well.
## Roadmap ## Roadmap
@@ -16,6 +17,7 @@ It can't be used with arbitrary email providers, but setup your own provider "wi
- [x] Configuration in room's account data - [x] Configuration in room's account data
- [x] Receive emails to matrix rooms - [x] Receive emails to matrix rooms
- [x] Receive attachments - [x] Receive attachments
- [x] Catch-all mailbox
- [x] Map email threads to matrix threads - [x] Map email threads to matrix threads
#### deep dive #### deep dive
@@ -30,6 +32,7 @@ It can't be used with arbitrary email providers, but setup your own provider "wi
### Send ### Send
- [x] SMTP client - [x] SMTP client
- [x] SMTP server (you can use Postmoogle as general purpose SMTP server to send emails from your scripts or apps)
- [x] Send a message to matrix room with special format to send a new email - [x] Send a message to matrix room with special format to send a new email
- [ ] Reply to matrix thread sends reply into email thread - [ ] Reply to matrix thread sends reply into email thread
@@ -52,7 +55,8 @@ env vars
* **POSTMOOGLE_TLS_CERT** - path to your SSL certificate (chain) * **POSTMOOGLE_TLS_CERT** - path to your SSL certificate (chain)
* **POSTMOOGLE_TLS_KEY** - path to your SSL certificate's private key * **POSTMOOGLE_TLS_KEY** - path to your SSL certificate's private key
* **POSTMOOGLE_TLS_REQUIRED** - require TLS connection, **even** on the non-TLS port (`POSTMOOGLE_PORT`). TLS connections are always required on the TLS port (`POSTMOOGLE_TLS_PORT`) regardless of this setting. * **POSTMOOGLE_TLS_REQUIRED** - require TLS connection, **even** on the non-TLS port (`POSTMOOGLE_PORT`). TLS connections are always required on the TLS port (`POSTMOOGLE_TLS_PORT`) regardless of this setting.
* **POSTMOOGLE_NOENCRYPTION** - disable encryption support * **POSTMOOGLE_DATA_SECRET** - secure key (password) to encrypt account data, must be 16, 24, or 32 bytes long
* **POSTMOOGLE_NOENCRYPTION** - disable matrix encryption (libolm) support
* **POSTMOOGLE_STATUSMSG** - presence status message * **POSTMOOGLE_STATUSMSG** - presence status message
* **POSTMOOGLE_SENTRY_DSN** - sentry DSN * **POSTMOOGLE_SENTRY_DSN** - sentry DSN
* **POSTMOOGLE_LOGLEVEL** - log level * **POSTMOOGLE_LOGLEVEL** - log level
@@ -237,10 +241,12 @@ If you want to change them - check available options in the help message (`!pm h
* **!pm mailbox** - Get or set mailbox of the room * **!pm mailbox** - Get or set mailbox of the room
* **!pm owner** - Get or set owner of the room * **!pm owner** - Get or set owner of the room
* **!pm password** - Get or set SMTP password of the room's mailbox
--- ---
* **!pm nosender** - Get or set `nosender` of the room (`true` - hide email sender; `false` - show email sender) * **!pm nosender** - Get or set `nosender` of the room (`true` - hide email sender; `false` - show email sender)
* **!pm norecipient** - Get or set `norecipient` of the room (`true` - hide recipient; `false` - show recipient)
* **!pm nosubject** - Get or set `nosubject` of the room (`true` - hide email subject; `false` - show email subject) * **!pm 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 nohtml** - Get or set `nohtml` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)
* **!pm nothreads** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads) * **!pm nothreads** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)
@@ -248,7 +254,16 @@ If you want to change them - check available options in the help message (`!pm h
--- ---
* **!pm spamcheck:mx** - only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)
* **!pm spamcheck:smtp** - only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)
* **!pm spamlist:emails** - Get or set `spamlist:emails` of the room (comma-separated list), eg: `spammer@example.com,sspam@example.org`
* **!pm spamlist:hosts** - Get or set `spamlist:hosts` of the room (comma-separated list), eg: `spammer.com,scammer.com,morespam.com`
* **!pm spamlist:mailboxes** - Get or set `spamlist:mailboxes` of the room (comma-separated list), eg: `notspam,noreply,no-reply`
---
* **!pm dkim** - Get DKIM signature * **!pm dkim** - Get DKIM signature
* **!pm catch-all** - Configure catch-all mailbox
* **!pm users** - Get or set allowed users patterns * **!pm users** - Get or set allowed users patterns
* **!pm mailboxes** - Show the list of all mailboxes * **!pm mailboxes** - Show the list of all mailboxes
* **!pm delete** <mailbox> - Delete specific mailbox * **!pm delete** <mailbox> - Delete specific mailbox

View File

@@ -3,9 +3,14 @@ package bot
import ( import (
"context" "context"
"regexp" "regexp"
"strings"
"github.com/getsentry/sentry-go"
"github.com/raja/argon2pw"
"gitlab.com/etke.cc/go/mxidwc" "gitlab.com/etke.cc/go/mxidwc"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
) )
func parseMXIDpatterns(patterns []string, defaultPattern string) ([]*regexp.Regexp, error) { func parseMXIDpatterns(patterns []string, defaultPattern string) ([]*regexp.Regexp, error) {
@@ -36,7 +41,7 @@ func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
} }
cfg, err := b.getRoomSettings(targetRoomID) cfg, err := b.getRoomSettings(targetRoomID)
if err != nil { if err != nil {
b.Error(context.Background(), targetRoomID, "failed to retrieve settings: %v", err) b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
return false return false
} }
@@ -59,9 +64,32 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
cfg, err := b.getRoomSettings(targetRoomID) cfg, err := b.getRoomSettings(targetRoomID)
if err != nil { if err != nil {
b.Error(context.Background(), targetRoomID, "failed to retrieve settings: %v", err) b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
return false return false
} }
return !cfg.NoSend() return !cfg.NoSend()
} }
// AllowAuth check if SMTP login (email) and password are valid
func (b *Bot) AllowAuth(email, password string) bool {
if !strings.HasSuffix(email, "@"+b.domain) {
return false
}
roomID, ok := b.GetMapping(utils.Mailbox(email))
if !ok {
return false
}
cfg, err := b.getRoomSettings(roomID)
if err != nil {
b.log.Error("failed to retrieve settings: %v", err)
return false
}
allow, err := argon2pw.CompareHashWithPassword(cfg.Password(), password)
if err != nil {
b.log.Warn("Password for %s is not valid: %v", email, err)
}
return allow
}

View File

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

View File

@@ -17,6 +17,7 @@ const (
commandStop = "stop" commandStop = "stop"
commandSend = "send" commandSend = "send"
commandDKIM = "dkim" commandDKIM = "dkim"
commandCatchAll = botOptionCatchAll
commandUsers = botOptionUsers commandUsers = botOptionUsers
commandDelete = "delete" commandDelete = "delete"
commandMailboxes = "mailboxes" commandMailboxes = "mailboxes"
@@ -73,11 +74,16 @@ func (b *Bot) initCommands() commandList {
sanitizer: func(s string) string { return s }, sanitizer: func(s string) string { return s },
allowed: b.allowOwner, allowed: b.allowOwner,
}, },
{
key: roomOptionPassword,
description: "Get or set SMTP password of the room's mailbox",
allowed: b.allowOwner,
},
{allowed: b.allowOwner}, // delimiter {allowed: b.allowOwner}, // delimiter
{ {
key: roomOptionNoSend, key: roomOptionNoSend,
description: fmt.Sprintf( description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - enable email sending; `false` - disable email sending)", "Get or set `%s` of the room (`true` - disable email sending; `false` - enable email sending)",
roomOptionNoSend, roomOptionNoSend,
), ),
sanitizer: utils.SanitizeBoolString, sanitizer: utils.SanitizeBoolString,
@@ -92,6 +98,15 @@ func (b *Bot) initCommands() commandList {
sanitizer: utils.SanitizeBoolString, sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner, allowed: b.allowOwner,
}, },
{
key: roomOptionNoRecipient,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide recipient; `false` - show recipient)",
roomOptionNoRecipient,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{ {
key: roomOptionNoSubject, key: roomOptionNoSubject,
description: fmt.Sprintf( description: fmt.Sprintf(
@@ -128,6 +143,46 @@ func (b *Bot) initCommands() commandList {
sanitizer: utils.SanitizeBoolString, sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner, allowed: b.allowOwner,
}, },
{allowed: b.allowOwner}, // delimiter
{
key: roomOptionSpamcheckMX,
description: "only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)",
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionSpamcheckSMTP,
description: "only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)",
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionSpamlistEmails,
description: fmt.Sprintf(
"Get or set `%s` of the room (comma-separated list), eg: `spammer@example.com,sspam@example.org`",
roomOptionSpamlistEmails,
),
sanitizer: utils.SanitizeStringSlice,
allowed: b.allowOwner,
},
{
key: roomOptionSpamlistHosts,
description: fmt.Sprintf(
"Get or set `%s` of the room (comma-separated list), eg: `spammer.com,scammer.com,morespam.com`",
roomOptionSpamlistHosts,
),
sanitizer: utils.SanitizeStringSlice,
allowed: b.allowOwner,
},
{
key: roomOptionSpamlistLocalparts,
description: fmt.Sprintf(
"Get or set `%s` of the room (comma-separated list), eg: `notspam,noreply,no-reply`",
roomOptionSpamlistLocalparts,
),
sanitizer: utils.SanitizeStringSlice,
allowed: b.allowOwner,
},
{allowed: b.allowAdmin}, // delimiter {allowed: b.allowAdmin}, // delimiter
{ {
key: botOptionUsers, key: botOptionUsers,
@@ -139,6 +194,11 @@ func (b *Bot) initCommands() commandList {
description: "Get DKIM signature", description: "Get DKIM signature",
allowed: b.allowAdmin, allowed: b.allowAdmin,
}, },
{
key: commandCatchAll,
description: "Get or set catch-all mailbox",
allowed: b.allowAdmin,
},
{ {
key: commandMailboxes, key: commandMailboxes,
description: "Show the list of all mailboxes", description: "Show the list of all mailboxes",
@@ -179,6 +239,8 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
b.runDKIM(ctx, commandSlice) b.runDKIM(ctx, commandSlice)
case commandUsers: case commandUsers:
b.runUsers(ctx, commandSlice) b.runUsers(ctx, commandSlice)
case commandCatchAll:
b.runCatchAll(ctx, commandSlice)
case commandDelete: case commandDelete:
b.runDelete(ctx, commandSlice) b.runDelete(ctx, commandSlice)
case commandMailboxes: case commandMailboxes:
@@ -293,6 +355,11 @@ func (b *Bot) runSend(ctx context.Context) {
return return
} }
if !utils.AddressValid(to) {
b.Error(ctx, evt.RoomID, "email address is not valid")
return
}
cfg, err := b.getRoomSettings(evt.RoomID) cfg, err := b.getRoomSettings(evt.RoomID)
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve room settings: %v", err) b.Error(ctx, evt.RoomID, "failed to retrieve room settings: %v", err)

View File

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

View File

@@ -3,6 +3,8 @@ package bot
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/raja/argon2pw"
) )
func (b *Bot) runStop(ctx context.Context) { func (b *Bot) runStop(ctx context.Context) {
@@ -62,12 +64,20 @@ func (b *Bot) getOption(ctx context.Context, name string) {
msg := fmt.Sprintf("`%s` of this room is `%s`\n"+ msg := fmt.Sprintf("`%s` of this room is `%s`\n"+
"To set it to a new value, send a `%s %s VALUE` command.", "To set it to a new value, send a `%s %s VALUE` command.",
name, value, b.prefix, name) name, value, b.prefix, name)
if name == roomOptionPassword {
msg = fmt.Sprintf("There is an SMTP password already set for this room/mailbox. "+
"It's stored in a secure hashed manner, so we can't tell you what the original raw password was. "+
"To find the raw password, try to find your old message which had originally set it, "+
"or just set a new one with `%s %s NEW_PASSWORD`.",
b.prefix, name)
}
b.SendNotice(ctx, evt.RoomID, msg) b.SendNotice(ctx, evt.RoomID, msg)
} }
//nolint:gocognit
func (b *Bot) setOption(ctx context.Context, name, value string) { func (b *Bot) setOption(ctx context.Context, name, value string) {
cmd := b.commands.get(name) cmd := b.commands.get(name)
if cmd != nil { if cmd != nil && cmd.sanitizer != nil {
value = cmd.sanitizer(value) value = cmd.sanitizer(value)
} }
@@ -86,6 +96,14 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
return return
} }
if name == roomOptionPassword {
value, err = argon2pw.GenerateSaltedHash(value)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to hash password: %v", err)
return
}
}
old := cfg.Get(name) old := cfg.Get(name)
cfg.Set(name, value) cfg.Set(name, value)
@@ -104,5 +122,9 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
return return
} }
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("`%s` of this room set to `%s`", name, value)) msg := fmt.Sprintf("`%s` of this room set to `%s`", name, value)
if name == roomOptionPassword {
msg = "SMTP password has been set"
}
b.SendNotice(ctx, evt.RoomID, msg)
} }

View File

@@ -31,12 +31,12 @@ func (b *Bot) SetMTA(mta utils.MTA) {
b.mta = mta b.mta = mta
} }
// GetMapping returns mapping of mailbox = room func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
v, ok := b.rooms.Load(mailbox) v, ok := b.rooms.Load(mailbox)
if !ok { if !ok {
return "", ok return "", ok
} }
roomID, ok := v.(id.RoomID) roomID, ok := v.(id.RoomID)
if !ok { if !ok {
return "", ok return "", ok
@@ -45,9 +45,34 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
return roomID, ok return roomID, ok
} }
// GetMapping returns mapping of mailbox = room
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
roomID, ok := b.getMapping(mailbox)
if !ok {
catchAll := b.getBotSettings().CatchAll()
if catchAll == "" {
return roomID, ok
}
return b.getMapping(catchAll)
}
return roomID, ok
}
// GetIFOptions returns incoming email filtering options (room settings)
func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions {
cfg, err := b.getRoomSettings(roomID)
if err != nil {
b.log.Error("cannot retrieve room settings: %v", err)
return roomSettings{}
}
return cfg
}
// Send email to matrix room // Send email to matrix room
func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email) error { func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) error {
roomID, ok := b.GetMapping(utils.Mailbox(email.To)) roomID, ok := b.GetMapping(email.Mailbox(incoming))
if !ok { if !ok {
return errors.New("room not found") return errors.New("room not found")
} }
@@ -59,6 +84,10 @@ func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email) error {
b.Error(ctx, roomID, "cannot get settings: %v", err) b.Error(ctx, roomID, "cannot get settings: %v", err)
} }
if !incoming && cfg.NoSend() {
return errors.New("that mailbox is receive-only")
}
var threadID id.EventID var threadID id.EventID
if email.InReplyTo != "" && !cfg.NoThreads() { if email.InReplyTo != "" && !cfg.NoThreads() {
threadID = b.getThreadID(roomID, email.InReplyTo) threadID = b.getThreadID(roomID, email.InReplyTo)
@@ -81,6 +110,11 @@ func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email) error {
if !cfg.NoFiles() { if !cfg.NoFiles() {
b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID) b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID)
} }
if !incoming {
email.MessageID = fmt.Sprintf("<%s@%s>", eventID, b.domain)
return b.mta.Send(email.From, email.To, email.Compose(b.getBotSettings().DKIMPrivateKey()))
}
return nil return nil
} }
@@ -165,20 +199,8 @@ func (b *Bot) Send2Email(ctx context.Context, to, subject, body string) error {
func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) { func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) {
for _, file := range files { for _, file := range files {
req := file.Convert() req := file.Convert()
resp, err := b.lp.GetClient().UploadMedia(req) err := b.lp.SendFile(roomID, req, file.MsgType, utils.RelatesTo(!noThreads, parentID))
if err != nil { b.Error(ctx, roomID, "cannot upload file %s: %v", req.FileName, err)
b.Error(ctx, roomID, "cannot upload file %s: %v", req.FileName, err)
continue
}
_, err = b.lp.Send(roomID, &event.MessageEventContent{
MsgType: file.MsgType,
Body: req.FileName,
URL: resp.ContentURI.CUString(),
RelatesTo: utils.RelatesTo(!noThreads, parentID),
})
if err != nil {
b.Error(ctx, roomID, "cannot send uploaded file %s: %v", req.FileName, err)
}
} }
} }

View File

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

View File

@@ -13,14 +13,21 @@ const acRoomSettingsKey = "cc.etke.postmoogle.settings"
// option keys // option keys
const ( const (
roomOptionOwner = "owner" roomOptionOwner = "owner"
roomOptionMailbox = "mailbox" roomOptionMailbox = "mailbox"
roomOptionNoSend = "nosend" roomOptionNoSend = "nosend"
roomOptionNoSender = "nosender" roomOptionNoSender = "nosender"
roomOptionNoSubject = "nosubject" roomOptionNoRecipient = "norecipient"
roomOptionNoHTML = "nohtml" roomOptionNoSubject = "nosubject"
roomOptionNoThreads = "nothreads" roomOptionNoHTML = "nohtml"
roomOptionNoFiles = "nofiles" roomOptionNoThreads = "nothreads"
roomOptionNoFiles = "nofiles"
roomOptionPassword = "password"
roomOptionSpamcheckSMTP = "spamcheck:smtp"
roomOptionSpamcheckMX = "spamcheck:mx"
roomOptionSpamlistEmails = "spamlist:emails"
roomOptionSpamlistHosts = "spamlist:hosts"
roomOptionSpamlistLocalparts = "spamlist:mailboxes"
) )
type roomSettings map[string]string type roomSettings map[string]string
@@ -43,6 +50,10 @@ func (s roomSettings) Owner() string {
return s.Get(roomOptionOwner) return s.Get(roomOptionOwner)
} }
func (s roomSettings) Password() string {
return s.Get(roomOptionPassword)
}
func (s roomSettings) NoSend() bool { func (s roomSettings) NoSend() bool {
return utils.Bool(s.Get(roomOptionNoSend)) return utils.Bool(s.Get(roomOptionNoSend))
} }
@@ -51,6 +62,10 @@ func (s roomSettings) NoSender() bool {
return utils.Bool(s.Get(roomOptionNoSender)) return utils.Bool(s.Get(roomOptionNoSender))
} }
func (s roomSettings) NoRecipient() bool {
return utils.Bool(s.Get(roomOptionNoRecipient))
}
func (s roomSettings) NoSubject() bool { func (s roomSettings) NoSubject() bool {
return utils.Bool(s.Get(roomOptionNoSubject)) return utils.Bool(s.Get(roomOptionNoSubject))
} }
@@ -67,13 +82,34 @@ func (s roomSettings) NoFiles() bool {
return utils.Bool(s.Get(roomOptionNoFiles)) return utils.Bool(s.Get(roomOptionNoFiles))
} }
func (s roomSettings) SpamcheckSMTP() bool {
return utils.Bool(s.Get(roomOptionSpamcheckSMTP))
}
func (s roomSettings) SpamcheckMX() bool {
return utils.Bool(s.Get(roomOptionSpamcheckMX))
}
func (s roomSettings) SpamlistEmails() []string {
return utils.StringSlice(s.Get(roomOptionSpamlistEmails))
}
func (s roomSettings) SpamlistHosts() []string {
return utils.StringSlice(s.Get(roomOptionSpamlistHosts))
}
func (s roomSettings) SpamlistLocalparts() []string {
return utils.StringSlice(s.Get(roomOptionSpamlistLocalparts))
}
// ContentOptions converts room display settings to content options // ContentOptions converts room display settings to content options
func (s roomSettings) ContentOptions() *utils.ContentOptions { func (s roomSettings) ContentOptions() *utils.ContentOptions {
return &utils.ContentOptions{ return &utils.ContentOptions{
HTML: !s.NoHTML(), HTML: !s.NoHTML(),
Sender: !s.NoSender(), Sender: !s.NoSender(),
Subject: !s.NoSubject(), Recipient: !s.NoRecipient(),
Threads: !s.NoThreads(), Subject: !s.NoSubject(),
Threads: !s.NoThreads(),
FromKey: eventFromKey, FromKey: eventFromKey,
SubjectKey: eventSubjectKey, SubjectKey: eventSubjectKey,
@@ -83,30 +119,14 @@ func (s roomSettings) ContentOptions() *utils.ContentOptions {
} }
func (b *Bot) getRoomSettings(roomID id.RoomID) (roomSettings, error) { func (b *Bot) getRoomSettings(roomID id.RoomID) (roomSettings, error) {
cfg := b.cfg.Get(roomID.String()) config, err := b.lp.GetRoomAccountData(roomID, acRoomSettingsKey)
if cfg != nil { if config == nil {
return cfg, nil config = map[string]string{}
}
config := roomSettings{}
err := b.lp.GetClient().GetRoomAccountData(roomID, acRoomSettingsKey, &config)
if err != nil {
if strings.Contains(err.Error(), "M_NOT_FOUND") {
// Suppress `M_NOT_FOUND (HTTP 404): Room account data not found` errors.
// Until some settings are explicitly set, we don't store any.
// In such cases, just return a default (empty) settings object.
err = nil
}
}
if err == nil {
b.cfg.Set(roomID.String(), config)
} }
return config, utils.UnwrapError(err) return config, utils.UnwrapError(err)
} }
func (b *Bot) setRoomSettings(roomID id.RoomID, cfg roomSettings) error { func (b *Bot) setRoomSettings(roomID id.RoomID, cfg roomSettings) error {
b.cfg.Set(roomID.String(), cfg) return utils.UnwrapError(b.lp.SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
return utils.UnwrapError(b.lp.GetClient().SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
} }

View File

@@ -70,16 +70,17 @@ func initBot(cfg *config.Config) {
} }
mxlog := logger.New("matrix.", cfg.LogLevel) mxlog := logger.New("matrix.", cfg.LogLevel)
lp, err := linkpearl.New(&lpcfg.Config{ lp, err := linkpearl.New(&lpcfg.Config{
Homeserver: cfg.Homeserver, Homeserver: cfg.Homeserver,
Login: cfg.Login, Login: cfg.Login,
Password: cfg.Password, Password: cfg.Password,
DB: db, DB: db,
Dialect: cfg.DB.Dialect, Dialect: cfg.DB.Dialect,
NoEncryption: cfg.NoEncryption, NoEncryption: cfg.NoEncryption,
LPLogger: mxlog, AccountDataSecret: cfg.DataSecret,
APILogger: logger.New("api.", cfg.LogLevel), LPLogger: mxlog,
StoreLogger: logger.New("store.", cfg.LogLevel), APILogger: logger.New("api.", cfg.LogLevel),
CryptoLogger: logger.New("olm.", cfg.LogLevel), StoreLogger: logger.New("store.", cfg.LogLevel),
CryptoLogger: logger.New("olm.", cfg.LogLevel),
}) })
if err != nil { if err != nil {
// nolint // Fatal = panic, not os.Exit() // nolint // Fatal = panic, not os.Exit()

View File

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

View File

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

19
go.mod
View File

@@ -2,31 +2,36 @@ module gitlab.com/etke.cc/postmoogle
go 1.18 go 1.18
// replace gitlab.com/etke.cc/linkpearl => ../linkpearl
require ( require (
git.sr.ht/~xn/cache/v2 v2.0.0
github.com/emersion/go-msgauth v0.6.6 github.com/emersion/go-msgauth v0.6.6
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.15.0 github.com/emersion/go-smtp v0.15.0
github.com/gabriel-vasile/mimetype v1.4.1 github.com/gabriel-vasile/mimetype v1.4.1
github.com/getsentry/sentry-go v0.13.0 github.com/getsentry/sentry-go v0.13.0
github.com/jhillyerd/enmime v0.10.0 github.com/jhillyerd/enmime v0.10.0
github.com/lib/pq v1.10.6 github.com/lib/pq v1.10.6
github.com/mattn/go-sqlite3 v1.14.15 github.com/mattn/go-sqlite3 v1.14.15
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39
gitlab.com/etke.cc/go/env v1.0.0 gitlab.com/etke.cc/go/env v1.0.0
gitlab.com/etke.cc/go/logger v1.1.0 gitlab.com/etke.cc/go/logger v1.1.0
gitlab.com/etke.cc/go/mxidwc v1.0.0 gitlab.com/etke.cc/go/mxidwc v1.0.0
gitlab.com/etke.cc/go/secgen v1.1.1 gitlab.com/etke.cc/go/secgen v1.1.1
gitlab.com/etke.cc/linkpearl v0.0.0-20220921080011-9407dc599571 gitlab.com/etke.cc/go/trysmtp v1.0.0
golang.org/x/net v0.0.0-20220920203100-d0c6ba3f52d9 gitlab.com/etke.cc/go/validator v1.0.1
gitlab.com/etke.cc/linkpearl v0.0.0-20221008191655-865ae3362a01
golang.org/x/net v0.0.0-20221004154528-8021a29435af
maunium.net/go/mautrix v0.12.1 maunium.net/go/mautrix v0.12.1
) )
require ( require (
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
github.com/google/go-cmp v0.5.8 // indirect github.com/google/go-cmp v0.5.8 // indirect
github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-isatty v0.0.16 // indirect
@@ -39,11 +44,11 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/tidwall/gjson v1.14.3 // indirect github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.4.13 // indirect github.com/yuin/goldmark v1.4.13 // indirect
golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 // indirect golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b // indirect
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
golang.org/x/text v0.3.7 // indirect golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/maulogger/v2 v2.3.2 // indirect maunium.net/go/maulogger/v2 v2.3.2 // indirect

29
go.sum
View File

@@ -1,5 +1,3 @@
git.sr.ht/~xn/cache/v2 v2.0.0 h1:aYzwGDyVIzjCl2yqcxZjprnu++Q3BmUQeK2agqvcQt8=
git.sr.ht/~xn/cache/v2 v2.0.0/go.mod h1:HIPSMiDudQ483tRDup586e0YZdwMySIZFWXMPwYMuV8=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/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 h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
@@ -34,6 +32,8 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc= github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s= github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
@@ -61,6 +61,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39 h1:2by0+lF6NfaNWhlpsv1DfBQzwbAyYUPIsMWYapek/Sk=
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39/go.mod h1:idX/fPqwjX31YMTF2iIpEpNApV2YbQhSFr4iIhJaqp4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -78,8 +80,9 @@ github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
@@ -92,16 +95,20 @@ gitlab.com/etke.cc/go/mxidwc v1.0.0 h1:6EAlJXvs3nU4RaMegYq6iFlyVvLw7JZYnZmNCGMYQ
gitlab.com/etke.cc/go/mxidwc v1.0.0/go.mod h1:E/0kh45SAN9+ntTG0cwkAEKdaPxzvxVmnjwivm9nmz8= gitlab.com/etke.cc/go/mxidwc v1.0.0/go.mod h1:E/0kh45SAN9+ntTG0cwkAEKdaPxzvxVmnjwivm9nmz8=
gitlab.com/etke.cc/go/secgen v1.1.1 h1:RmKOki725HIhWJHzPtAc9X4YvBneczndchpMgoDkE8w= gitlab.com/etke.cc/go/secgen v1.1.1 h1:RmKOki725HIhWJHzPtAc9X4YvBneczndchpMgoDkE8w=
gitlab.com/etke.cc/go/secgen v1.1.1/go.mod h1:3pJqRGeWApzx7qXjABqz2o2SMCNpKSZao/gXVdasqE8= gitlab.com/etke.cc/go/secgen v1.1.1/go.mod h1:3pJqRGeWApzx7qXjABqz2o2SMCNpKSZao/gXVdasqE8=
gitlab.com/etke.cc/linkpearl v0.0.0-20220921080011-9407dc599571 h1:ool1wnAnnIhZjwPMd0LUebpfxqXZcVhRli2UDhay0bA= gitlab.com/etke.cc/go/trysmtp v1.0.0 h1:f/7gSmzohKniVeLSLevI+ZsySYcPUGkT9cRlOTwjOr8=
gitlab.com/etke.cc/linkpearl v0.0.0-20220921080011-9407dc599571/go.mod h1:4qbyfbuJSj89jFW7F+YjIbYrwJTrALQf4Otw0KGkIWE= gitlab.com/etke.cc/go/trysmtp v1.0.0/go.mod h1:KqRuIB2IPElEEbAxXmFyKtm7S5YiuEb4lxwWthccqyE=
gitlab.com/etke.cc/go/validator v1.0.1 h1:xp1tAzgCu9A1pga8rFUo7hODaEcCR1nkkodw96+dYuA=
gitlab.com/etke.cc/go/validator v1.0.1/go.mod h1:3vdssRG4LwgdTr9IHz9MjGSEO+3/FO9hXPGMuSeweJ8=
gitlab.com/etke.cc/linkpearl v0.0.0-20221008191655-865ae3362a01 h1:rlcxjSCCG18sbNT2CsCRKjtwQ2UjkuTutkRHSCGhhxs=
gitlab.com/etke.cc/linkpearl v0.0.0-20221008191655-865ae3362a01/go.mod h1:HkUHUkhbkDueEJVc7h/zBfz2hjhl4xxjQKv9Itrdf9k=
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 h1:a5Yg6ylndHHYJqIPrdq0AhvR6KTvDTAvgBtaidhEevY= golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0=
golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220920203100-d0c6ba3f52d9 h1:asZqf0wXastQr+DudYagQS8uBO8bHKeYD1vbAvGmFL8= golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4=
golang.org/x/net v0.0.0-20220920203100-d0c6ba3f52d9/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -109,8 +116,8 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc= golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 h1:AzgQNqF+FKwyQ5LbVrVqOcuuFB67N47F9+htZYH0wFM=
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

View File

@@ -2,10 +2,13 @@ package smtp
import ( import (
"context" "context"
"errors"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"gitlab.com/etke.cc/go/logger" "gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/postmoogle/utils"
) )
// msa is mail submission agent, implements smtp.Backend // msa is mail submission agent, implements smtp.Backend
@@ -13,21 +16,33 @@ type msa struct {
log *logger.Logger log *logger.Logger
domain string domain string
bot Bot bot Bot
mta utils.MTA
} }
func (m *msa) newSession() *msasession { func (m *msa) newSession(from string, incoming bool) *msasession {
return &msasession{ return &msasession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()), ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
log: m.log, mta: m.mta,
bot: m.bot, from: from,
domain: m.domain, incoming: incoming,
log: m.log,
bot: m.bot,
domain: m.domain,
} }
} }
func (m *msa) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { func (m *msa) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
return nil, smtp.ErrAuthUnsupported if !utils.AddressValid(username) {
return nil, errors.New("please, provide an email address")
}
if !m.bot.AllowAuth(username, password) {
return nil, errors.New("email or password is invalid")
}
return m.newSession(username, false), nil
} }
func (m *msa) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { func (m *msa) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
return m.newSession(), nil return m.newSession("", true), nil
} }

View File

@@ -2,48 +2,68 @@ package smtp
import ( import (
"context" "context"
"errors"
"io" "io"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
"gitlab.com/etke.cc/go/logger" "gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/validator"
"gitlab.com/etke.cc/postmoogle/utils" "gitlab.com/etke.cc/postmoogle/utils"
) )
// msasession represents an SMTP-submission session.
// This can be used in 2 directions:
// - receiving emails from remote servers, in which case: `incoming = true`
// - sending emails from local users, in which case: `incoming = false`
type msasession struct { type msasession struct {
log *logger.Logger log *logger.Logger
bot Bot bot Bot
mta utils.MTA
domain string domain string
ctx context.Context ctx context.Context
to string incoming bool
from string to string
from string
} }
func (s *msasession) Mail(from string, opts smtp.MailOptions) error { func (s *msasession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from) sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
s.from = from if !utils.AddressValid(from) {
s.log.Debug("mail from %s, options: %+v", from, opts) return errors.New("please, provide email address")
}
if s.incoming {
s.from = from
s.log.Debug("mail from %s, options: %+v", from, opts)
}
return nil return nil
} }
func (s *msasession) Rcpt(to string) error { func (s *msasession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to) sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
if utils.Hostname(to) != s.domain {
s.log.Debug("wrong domain of %s", to)
return smtp.ErrAuthRequired
}
_, ok := s.bot.GetMapping(utils.Mailbox(to))
if !ok {
s.log.Debug("mapping for %s not found", to)
return smtp.ErrAuthRequired
}
s.to = to s.to = to
if s.incoming {
if utils.Hostname(to) != s.domain {
s.log.Debug("wrong domain of %s", to)
return smtp.ErrAuthRequired
}
roomID, ok := s.bot.GetMapping(utils.Mailbox(to))
if !ok {
s.log.Debug("mapping for %s not found", to)
return smtp.ErrAuthRequired
}
validations := s.bot.GetIFOptions(roomID)
if !s.validate(validations) {
return smtp.ErrAuthRequired
}
}
s.log.Debug("mail to %s", to) s.log.Debug("mail to %s", to)
return nil return nil
} }
@@ -61,6 +81,21 @@ func (s *msasession) parseAttachments(parts []*enmime.Part) []*utils.File {
return files return files
} }
func (s *msasession) validate(options utils.IncomingFilteringOptions) bool {
spam := validator.Spam{
Emails: options.SpamlistEmails(),
Hosts: options.SpamlistHosts(),
Localparts: options.SpamlistLocalparts(),
}
enforce := validator.Enforce{
MX: options.SpamcheckMX(),
SMTP: options.SpamcheckMX(),
}
v := validator.New(spam, enforce, s.to, s.log)
return v.Email(s.from)
}
func (s *msasession) Data(r io.Reader) error { func (s *msasession) Data(r io.Reader) error {
parser := enmime.NewParser() parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r) eml, err := parser.ReadEnvelope(r)
@@ -80,7 +115,7 @@ func (s *msasession) Data(r io.Reader) error {
eml.HTML, eml.HTML,
files) files)
return s.bot.Send2Matrix(s.ctx, email) return s.bot.Send2Matrix(s.ctx, email, s.incoming)
} }
func (s *msasession) Reset() {} func (s *msasession) Reset() {}

View File

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

View File

@@ -40,6 +40,7 @@ func NewServer(cfg *Config) *Server {
sender := NewMTA(cfg.LogLevel) sender := NewMTA(cfg.LogLevel)
receiver := &msa{ receiver := &msa{
log: log, log: log,
mta: sender,
bot: cfg.Bot, bot: cfg.Bot,
domain: cfg.Domain, domain: cfg.Domain,
} }
@@ -50,7 +51,9 @@ func NewServer(cfg *Config) *Server {
s.ReadTimeout = 10 * time.Second s.ReadTimeout = 10 * time.Second
s.WriteTimeout = 10 * time.Second s.WriteTimeout = 10 * time.Second
s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024 s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024
s.AllowInsecureAuth = !cfg.TLSRequired
s.EnableREQUIRETLS = cfg.TLSRequired s.EnableREQUIRETLS = cfg.TLSRequired
s.EnableSMTPUTF8 = true
if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" { if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
s.Debug = os.Stdout s.Debug = os.Stdout
} }

View File

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

View File

@@ -4,6 +4,7 @@ import (
"crypto" "crypto"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"net/mail"
"strings" "strings"
"time" "time"
@@ -18,6 +19,15 @@ type MTA interface {
Send(from, to, data string) error Send(from, to, data string) error
} }
// IncomingFilteringOptions for incoming mail
type IncomingFilteringOptions interface {
SpamcheckSMTP() bool
SpamcheckMX() bool
SpamlistEmails() []string
SpamlistHosts() []string
SpamlistLocalparts() []string
}
// Email object // Email object
type Email struct { type Email struct {
Date string Date string
@@ -34,10 +44,11 @@ type Email struct {
// ContentOptions represents settings that specify how an email is to be converted to a Matrix message // ContentOptions represents settings that specify how an email is to be converted to a Matrix message
type ContentOptions struct { type ContentOptions struct {
// On/Off // On/Off
Sender bool Sender bool
Subject bool Recipient bool
HTML bool Subject bool
Threads bool HTML bool
Threads bool
// Keys // Keys
MessageIDKey string MessageIDKey string
@@ -46,6 +57,12 @@ type ContentOptions struct {
FromKey string FromKey string
} }
// AddressValid checks if email address is valid
func AddressValid(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}
// NewEmail constructs Email object // NewEmail constructs Email object
func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files []*File) *Email { func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files []*File) *Email {
email := &Email{ email := &Email{
@@ -71,13 +88,26 @@ func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files
return email return email
} }
// Mailbox returns postmoogle's mailbox, parsing it from FROM (if incoming=false) or TO (incoming=true)
func (e *Email) Mailbox(incoming bool) string {
if incoming {
return Mailbox(e.To)
}
return Mailbox(e.From)
}
// Content converts the email object to a Matrix event content // Content converts the email object to a Matrix event content
func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Content { func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Content {
var text strings.Builder var text strings.Builder
if options.Sender { if options.Sender {
text.WriteString("From: ") text.WriteString("From: ")
text.WriteString(e.From) text.WriteString(e.From)
text.WriteString("\n\n") text.WriteString("\n")
}
if options.Recipient {
text.WriteString("To: ")
text.WriteString(e.To)
text.WriteString("\n")
} }
if options.Subject { if options.Subject {
text.WriteString("# ") text.WriteString("# ")
@@ -110,6 +140,13 @@ func (e *Email) Compose(privkey string) string {
var data strings.Builder var data strings.Builder
domain := strings.SplitN(e.From, "@", 2)[1] domain := strings.SplitN(e.From, "@", 2)[1]
data.WriteString("Content-Type: text/plain; charset=\"UTF-8\"")
data.WriteString("\r\n")
data.WriteString("Content-Transfer-Encoding: 8BIT")
data.WriteString("\r\n")
data.WriteString("From: ") data.WriteString("From: ")
data.WriteString(e.From) data.WriteString(e.From)
data.WriteString("\r\n") data.WriteString("\r\n")

View File

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

View File

@@ -33,3 +33,31 @@ func Bool(str string) bool {
func SanitizeBoolString(str string) string { func SanitizeBoolString(str string) string {
return strconv.FormatBool(Bool(str)) return strconv.FormatBool(Bool(str))
} }
// StringSlice converts comma-separated string to slice
func StringSlice(str string) []string {
if str == "" {
return nil
}
str = strings.TrimSpace(str)
if strings.IndexByte(str, ',') == -1 {
return []string{str}
}
return strings.Split(str, ",")
}
// SanitizeBoolString converts string to slice and back to string
func SanitizeStringSlice(str string) string {
parts := StringSlice(str)
if len(parts) == 0 {
return str
}
for i, part := range parts {
parts[i] = strings.TrimSpace(part)
}
return strings.Join(parts, ",")
}