add !pm relay - per-mailbox relay config
This commit is contained in:
11
README.md
11
README.md
@@ -35,6 +35,7 @@ so you can use it to send emails from your apps and scripts as well.
|
||||
|
||||
- [x] SMTP client
|
||||
- [x] SMTP server (you can use Postmoogle as general purpose SMTP server to send emails from your scripts or apps)
|
||||
- [x] SMTP Relaying (postmoogle can send emails via relay host), global and per-mailbox
|
||||
- [x] Send a message to matrix room with special format to send a new email, even to multiple email addresses at once
|
||||
- [x] Reply to matrix thread sends reply into email thread
|
||||
- [x] Email signatures
|
||||
@@ -76,10 +77,10 @@ env vars
|
||||
* **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
|
||||
* **POSTMOOGLE_RELAY_HOST** - SMTP hostname of relay host (e.g. Sendgrid)
|
||||
* **POSTMOOGLE_RELAY_PORT** - SMTP port of relay host
|
||||
* **POSTMOOGLE_RELAY_USERNAME** - Username of relay host
|
||||
* **POSTMOOGLE_RELAY_PASSWORD** - Password of relay host
|
||||
* **POSTMOOGLE_RELAY_HOST** - (global) SMTP hostname of relay host (e.g. Sendgrid)
|
||||
* **POSTMOOGLE_RELAY_PORT** - (global) SMTP port of relay host
|
||||
* **POSTMOOGLE_RELAY_USERNAME** - (global) Username of relay host
|
||||
* **POSTMOOGLE_RELAY_PASSWORD** - (global) Password of relay host
|
||||
|
||||
You can find default values in [config/defaults.go](config/defaults.go)
|
||||
|
||||
@@ -118,7 +119,7 @@ If you want to change them - check available options in the help message (`!pm h
|
||||
* **`!pm domain`** - Get or set default domain 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 relay`** - Get or set SMTP relay of that mailbox. Format: `smtp://user:password@host:port`, e.g. `smtp://54b7bfb9-b95f-44b8-9879-9b560baf4e3a:8528a3a9-bea8-4583-9912-d4357ba565eb@example.com:587`
|
||||
---
|
||||
|
||||
#### mailbox options
|
||||
|
||||
@@ -3,6 +3,7 @@ package bot
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
@@ -36,7 +37,7 @@ type Bot struct {
|
||||
commands commandList
|
||||
rooms sync.Map
|
||||
proxies []string
|
||||
sendmail func(string, string, string) error
|
||||
sendmail func(string, string, string, *url.URL) error
|
||||
psdc *psd.Client
|
||||
cfg *config.Manager
|
||||
log *zerolog.Logger
|
||||
|
||||
@@ -103,6 +103,12 @@ func (b *Bot) initCommands() commandList {
|
||||
description: "Get or set SMTP password of the room's mailbox",
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: config.RoomRelay,
|
||||
description: "Configure SMTP Relay for that mailbox, format: `smtp://user:pass@host:port`",
|
||||
sanitizer: utils.SanitizeURL,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowOwner, description: "mailbox options"}, // delimiter
|
||||
{
|
||||
key: config.RoomAutoreply,
|
||||
@@ -630,7 +636,7 @@ func (b *Bot) runSendCommand(ctx context.Context, cfg config.Room, tos []string,
|
||||
b.lp.SendNotice(ctx, evt.RoomID, "email body is empty", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
return
|
||||
}
|
||||
queued, err := b.Sendmail(ctx, evt.ID, from, to, data)
|
||||
queued, err := b.Sendmail(ctx, evt.ID, from, to, data, cfg.Relay())
|
||||
if queued {
|
||||
b.log.Warn().Err(err).Msg("email has been queued")
|
||||
b.saveSentMetadata(ctx, queued, evt.ID, to, eml, cfg)
|
||||
|
||||
@@ -51,6 +51,8 @@ func (b *Bot) handleOption(ctx context.Context, cmd []string) {
|
||||
b.setMailbox(ctx, cmd[1])
|
||||
case config.RoomPassword:
|
||||
b.setPassword(ctx)
|
||||
case config.RoomRelay:
|
||||
b.setRelay(ctx)
|
||||
default:
|
||||
b.setOption(ctx, cmd[0], cmd[1])
|
||||
}
|
||||
@@ -152,6 +154,25 @@ func (b *Bot) setPassword(ctx context.Context) {
|
||||
b.lp.SendNotice(ctx, evt.RoomID, "SMTP password has been set", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
func (b *Bot) setRelay(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg, err := b.cfg.GetRoom(ctx, evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, "failed to retrieve settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
value := b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case
|
||||
cfg.Set(config.RoomRelay, value)
|
||||
err = b.cfg.SetRoom(ctx, evt.RoomID, cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot update settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.lp.SendNotice(ctx, evt.RoomID, "Relay config has been set", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
func (b *Bot) setOption(ctx context.Context, name, value string) {
|
||||
cmd := b.commands.get(name)
|
||||
if cmd != nil && cmd.sanitizer != nil {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/etke.cc/go/healthchecks/v2"
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
@@ -21,6 +24,7 @@ const (
|
||||
RoomPassword = "password"
|
||||
RoomSignature = "signature"
|
||||
RoomAutoreply = "autoreply"
|
||||
RoomRelay = "relay"
|
||||
|
||||
RoomThreadify = "threadify"
|
||||
RoomStripify = "stripify"
|
||||
@@ -69,6 +73,20 @@ func (s Room) Active() bool {
|
||||
return utils.Bool(s.Get(RoomActive))
|
||||
}
|
||||
|
||||
// Relay returns the SMTP Relay configuration in a manner of URL: smtp://user:pass@host:port
|
||||
func (s Room) Relay() *url.URL {
|
||||
relay := s.Get(RoomRelay)
|
||||
if relay == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(relay)
|
||||
if err != nil {
|
||||
healthchecks.Global().Fail(strings.NewReader(fmt.Sprintf("cannot parse relay URL %q: %v", relay, err)))
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (s Room) Password() string {
|
||||
return s.Get(RoomPassword)
|
||||
}
|
||||
|
||||
23
bot/email.go
23
bot/email.go
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,7 +37,7 @@ const (
|
||||
var ErrNoRoom = errors.New("room not found")
|
||||
|
||||
// SetSendmail sets mail sending func to the bot
|
||||
func (b *Bot) SetSendmail(sendmail func(string, string, string) error) {
|
||||
func (b *Bot) SetSendmail(sendmail func(string, string, string, *url.URL) error) {
|
||||
b.sendmail = sendmail
|
||||
b.q.SetSendmail(sendmail)
|
||||
}
|
||||
@@ -60,14 +61,14 @@ func (b *Bot) shouldQueue(msg string) bool {
|
||||
|
||||
// Sendmail tries to send email immediately, but if it gets 4xx error (greylisting),
|
||||
// the email will be added to the queue and retried several times after that
|
||||
func (b *Bot) Sendmail(ctx context.Context, eventID id.EventID, from, to, data string) (bool, error) {
|
||||
func (b *Bot) Sendmail(ctx context.Context, eventID id.EventID, from, to, data string, relayOverride *url.URL) (bool, error) {
|
||||
log := b.log.With().Str("from", from).Str("to", to).Str("eventID", eventID.String()).Logger()
|
||||
log.Info().Msg("attempting to deliver email")
|
||||
err := b.sendmail(from, to, data)
|
||||
err := b.sendmail(from, to, data, relayOverride)
|
||||
if err != nil {
|
||||
if b.shouldQueue(err.Error()) {
|
||||
log.Info().Err(err).Msg("email has been added to the queue")
|
||||
return true, b.q.Add(ctx, eventID.String(), from, to, data)
|
||||
return true, b.q.Add(ctx, eventID.String(), from, to, data, relayOverride)
|
||||
}
|
||||
log.Warn().Err(err).Msg("email delivery failed")
|
||||
return false, err
|
||||
@@ -82,6 +83,16 @@ func (b *Bot) GetDKIMprivkey(ctx context.Context) string {
|
||||
return b.cfg.GetBot(ctx).DKIMPrivateKey()
|
||||
}
|
||||
|
||||
// GetRelayConfig returns relay config for specific room (mailbox) if set
|
||||
func (b *Bot) GetRelayConfig(ctx context.Context, roomID id.RoomID) *url.URL {
|
||||
cfg, err := b.cfg.GetRoom(ctx, roomID)
|
||||
if err != nil {
|
||||
b.log.Error().Err(err).Str("room_id", roomID.String()).Msg("cannot get room config")
|
||||
return nil
|
||||
}
|
||||
return cfg.Relay()
|
||||
}
|
||||
|
||||
func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
|
||||
v, ok := b.rooms.Load(mailbox)
|
||||
if !ok {
|
||||
@@ -277,7 +288,7 @@ func (b *Bot) sendAutoreply(ctx context.Context, roomID id.RoomID, threadID id.E
|
||||
ctx = newContext(ctx, threadEvt)
|
||||
recipients := meta.Recipients
|
||||
for _, to := range recipients {
|
||||
queued, err = b.Sendmail(ctx, evt.ID, meta.From, to, data)
|
||||
queued, err = b.Sendmail(ctx, evt.ID, meta.From, to, data, cfg.Relay())
|
||||
if queued {
|
||||
b.log.Info().Err(err).Str("from", meta.From).Str("to", to).Msg("email has been queued")
|
||||
b.saveSentMetadata(ctx, queued, meta.ThreadID, to, eml, cfg, "Autoreply has been sent to "+to+" (queued)")
|
||||
@@ -361,7 +372,7 @@ func (b *Bot) SendEmailReply(ctx context.Context) {
|
||||
var queued bool
|
||||
recipients := meta.Recipients
|
||||
for _, to := range recipients {
|
||||
queued, err = b.Sendmail(ctx, evt.ID, meta.From, to, data)
|
||||
queued, err = b.Sendmail(ctx, evt.ID, meta.From, to, data, cfg.Relay())
|
||||
if queued {
|
||||
b.log.Info().Err(err).Str("from", meta.From).Str("to", to).Msg("email has been queued")
|
||||
b.saveSentMetadata(ctx, queued, meta.ThreadID, to, eml, cfg)
|
||||
|
||||
@@ -2,6 +2,7 @@ package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
@@ -22,7 +23,7 @@ type Queue struct {
|
||||
lp *linkpearl.Linkpearl
|
||||
cfg *config.Manager
|
||||
log *zerolog.Logger
|
||||
sendmail func(string, string, string) error
|
||||
sendmail func(string, string, string, *url.URL) error
|
||||
}
|
||||
|
||||
// New queue
|
||||
@@ -36,7 +37,7 @@ func New(lp *linkpearl.Linkpearl, cfg *config.Manager, log *zerolog.Logger) *Que
|
||||
}
|
||||
|
||||
// SetSendmail func
|
||||
func (q *Queue) SetSendmail(function func(string, string, string) error) {
|
||||
func (q *Queue) SetSendmail(function func(string, string, string, *url.URL) error) {
|
||||
q.sendmail = function
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,20 @@ package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Add to queue
|
||||
func (q *Queue) Add(ctx context.Context, id, from, to, data string) error {
|
||||
func (q *Queue) Add(ctx context.Context, id, from, to, data string, relayOverride ...*url.URL) error {
|
||||
itemkey := acQueueKey + "." + id
|
||||
relay := ""
|
||||
if len(relayOverride) > 0 {
|
||||
relay = relayOverride[0].String()
|
||||
}
|
||||
item := map[string]string{
|
||||
"attempts": "0",
|
||||
"relay": relay,
|
||||
"data": data,
|
||||
"from": from,
|
||||
"to": to,
|
||||
@@ -84,7 +90,12 @@ func (q *Queue) try(ctx context.Context, itemkey string, maxRetries int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
err = q.sendmail(item["from"], item["to"], item["data"])
|
||||
var relayOverride *url.URL
|
||||
if item["relay"] != "" {
|
||||
relayOverride, _ = url.Parse(item["relay"]) //nolint:errcheck // doesn't matter
|
||||
}
|
||||
|
||||
err = q.sendmail(item["from"], item["to"], item["data"], relayOverride)
|
||||
if err == nil {
|
||||
q.log.Info().Str("id", itemkey).Msg("email from queue was delivered")
|
||||
return true
|
||||
|
||||
@@ -148,7 +148,7 @@ func initSMTP(cfg *config.Config) {
|
||||
Relay: &smtp.RelayConfig{
|
||||
Host: cfg.Relay.Host,
|
||||
Port: cfg.Relay.Port,
|
||||
Usename: cfg.Relay.Username,
|
||||
Username: cfg.Relay.Username,
|
||||
Password: cfg.Relay.Password,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,13 +6,14 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type MailSender interface {
|
||||
Send(from, to, data string) error
|
||||
Send(from, to, data string, relayOverride *url.URL) error
|
||||
}
|
||||
|
||||
// SMTP client
|
||||
@@ -30,16 +31,35 @@ func newClient(cfg *RelayConfig, log *zerolog.Logger) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// relayFromURL creates a RelayConfig from a URL
|
||||
func relayFromURL(relayURL *url.URL) *RelayConfig {
|
||||
if relayURL == nil {
|
||||
return nil
|
||||
}
|
||||
password, _ := relayURL.User.Password()
|
||||
return &RelayConfig{
|
||||
Host: relayURL.Hostname(),
|
||||
Port: relayURL.Port(),
|
||||
Username: relayURL.User.Username(),
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
// Send email
|
||||
func (c Client) Send(from, to, data string) error {
|
||||
func (c Client) Send(from, to, data string, relayOverride *url.URL) error {
|
||||
log := c.log.With().Str("from", from).Str("to", to).Logger()
|
||||
log.Debug().Msg("sending email")
|
||||
|
||||
relay := c.config
|
||||
if relayOverrideCfg := relayFromURL(relayOverride); relayOverrideCfg != nil {
|
||||
relay = relayOverrideCfg
|
||||
}
|
||||
|
||||
var conn *smtp.Client
|
||||
var err error
|
||||
if c.config.Host != "" {
|
||||
if relay != nil && relay.Host != "" {
|
||||
log.Debug().Msg("creating relay client...")
|
||||
conn, err = c.createRelayClient(from, to)
|
||||
conn, err = c.createRelayClient(relay, from, to)
|
||||
} else {
|
||||
log.Debug().Msg("trying direct SMTP connection...")
|
||||
conn, err = c.createDirectClient(from, to)
|
||||
@@ -73,9 +93,9 @@ func (c Client) Send(from, to, data string) error {
|
||||
}
|
||||
|
||||
// createRelayClientconnects directly to the provided smtp host
|
||||
func (c *Client) createRelayClient(from, to string) (*smtp.Client, error) {
|
||||
func (c *Client) createRelayClient(config *RelayConfig, from, to string) (*smtp.Client, error) {
|
||||
localname := strings.SplitN(from, "@", 2)[1]
|
||||
target := c.config.Host + ":" + c.config.Port
|
||||
target := config.Host + ":" + config.Port
|
||||
conn, err := smtp.Dial(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -87,12 +107,12 @@ func (c *Client) createRelayClient(from, to string) (*smtp.Client, error) {
|
||||
}
|
||||
|
||||
if ok, _ := conn.Extension("STARTTLS"); ok {
|
||||
config := &tls.Config{ServerName: c.config.Host} //nolint:gosec // it's smtp, even that is too strict sometimes
|
||||
conn.StartTLS(config) //nolint:errcheck // if it doesn't work - we can't do anything anyway
|
||||
tlsConfig := &tls.Config{ServerName: config.Host} //nolint:gosec // it's smtp, even that is too strict sometimes
|
||||
conn.StartTLS(tlsConfig) //nolint:errcheck // if it doesn't work - we can't do anything anyway
|
||||
}
|
||||
|
||||
if c.config.Usename != "" {
|
||||
err = conn.Auth(smtp.PlainAuth("", c.config.Usename, c.config.Password, c.config.Host))
|
||||
if config.Username != "" {
|
||||
err = conn.Auth(smtp.PlainAuth("", config.Username, config.Password, config.Host))
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -44,7 +45,7 @@ type TLSConfig struct {
|
||||
type RelayConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Usename string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
@@ -70,11 +71,12 @@ type matrixbot interface {
|
||||
GetIFOptions(context.Context, id.RoomID) email.IncomingFilteringOptions
|
||||
IncomingEmail(context.Context, *email.Email) error
|
||||
GetDKIMprivkey(context.Context) string
|
||||
GetRelayConfig(context.Context, id.RoomID) *url.URL
|
||||
}
|
||||
|
||||
// Caller is Sendmail caller
|
||||
type Caller interface {
|
||||
SetSendmail(func(string, string, string) error)
|
||||
SetSendmail(func(string, string, string, *url.URL) error)
|
||||
}
|
||||
|
||||
// NewManager creates new SMTP server manager
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
@@ -40,7 +41,7 @@ type session struct {
|
||||
ctx context.Context //nolint:containedctx // that's session
|
||||
conn *smtp.Conn
|
||||
domains []string
|
||||
sendmail func(string, string, string) error
|
||||
sendmail func(string, string, string, *url.URL) error
|
||||
|
||||
dir string
|
||||
tos []string
|
||||
@@ -124,7 +125,7 @@ func (s *session) outgoingData(r io.Reader) error {
|
||||
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))
|
||||
err := s.sendmail(eml.From, to, eml.Compose(s.privkey), s.bot.GetRelayConfig(s.ctx, s.fromRoom))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -40,6 +41,15 @@ func SanitizeDomain(domain string) string {
|
||||
return domains[0]
|
||||
}
|
||||
|
||||
// SanitizeURL checks that input URL is valid
|
||||
func SanitizeURL(str string) string {
|
||||
parsed, err := url.Parse(str)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// Bool converts string to boolean
|
||||
func Bool(str string) bool {
|
||||
str = strings.ToLower(str)
|
||||
|
||||
Reference in New Issue
Block a user