diff --git a/README.md b/README.md index e9a99cb..7e201cc 100644 --- a/README.md +++ b/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 diff --git a/bot/bot.go b/bot/bot.go index 23bb86c..ca633d3 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -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 diff --git a/bot/command.go b/bot/command.go index 01cc8fe..a37404b 100644 --- a/bot/command.go +++ b/bot/command.go @@ -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) diff --git a/bot/command_owner.go b/bot/command_owner.go index a175d58..57fe0d2 100644 --- a/bot/command_owner.go +++ b/bot/command_owner.go @@ -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 { diff --git a/bot/config/room.go b/bot/config/room.go index 104bc9e..cc06bbf 100644 --- a/bot/config/room.go +++ b/bot/config/room.go @@ -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) } diff --git a/bot/email.go b/bot/email.go index cf81146..c2c03bf 100644 --- a/bot/email.go +++ b/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) diff --git a/bot/queue/manager.go b/bot/queue/manager.go index 286acc2..a2004ae 100644 --- a/bot/queue/manager.go +++ b/bot/queue/manager.go @@ -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 } diff --git a/bot/queue/queue.go b/bot/queue/queue.go index 50501ee..37b7118 100644 --- a/bot/queue/queue.go +++ b/bot/queue/queue.go @@ -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 diff --git a/cmd/cmd.go b/cmd/cmd.go index 26fc5d8..7d1011e 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -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, }, }) diff --git a/smtp/client.go b/smtp/client.go index c6667a4..3183556 100644 --- a/smtp/client.go +++ b/smtp/client.go @@ -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 diff --git a/smtp/manager.go b/smtp/manager.go index 7fc1c20..64f81e1 100644 --- a/smtp/manager.go +++ b/smtp/manager.go @@ -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 diff --git a/smtp/session.go b/smtp/session.go index 5ced23d..01e05ea 100644 --- a/smtp/session.go +++ b/smtp/session.go @@ -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 } diff --git a/utils/utils.go b/utils/utils.go index bef0742..704c6b3 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -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)