use postmoogle as general purpose SMTP server and allow other apps or scripts to send emails through it

This commit is contained in:
Aine
2022-09-22 18:21:17 +03:00
parent c9c871287d
commit 070a6ffc76
11 changed files with 108 additions and 20 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

@@ -30,6 +30,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
@@ -237,6 +238,7 @@ 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
--- ---

View File

@@ -65,3 +65,18 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
return !cfg.NoSend() return !cfg.NoSend()
} }
// AllowAuth check if SMTP login (mailbox) and password are valid
func (b *Bot) AllowAuth(mailbox, password string) bool {
roomID, ok := b.GetMapping(mailbox)
if !ok {
return false
}
cfg, err := b.getRoomSettings(roomID)
if err != nil {
b.log.Error("failed to retrieve settings: %v", err)
return false
}
return cfg.Password() != "" && cfg.Password() == password
}

View File

@@ -73,6 +73,12 @@ 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",
sanitizer: func(s string) string { return strings.TrimSpace(s) },
allowed: b.allowOwner,
},
{allowed: b.allowOwner}, // delimiter {allowed: b.allowOwner}, // delimiter
{ {
key: roomOptionNoSend, key: roomOptionNoSend,
@@ -293,6 +299,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

@@ -46,8 +46,8 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
} }
// 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, local bool) error {
roomID, ok := b.GetMapping(utils.Mailbox(email.To)) roomID, ok := b.GetMapping(email.Mailbox(local))
if !ok { if !ok {
return errors.New("room not found") return errors.New("room not found")
} }
@@ -59,6 +59,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 !local && 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 +85,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 !local {
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
} }

View File

@@ -21,6 +21,7 @@ const (
roomOptionNoHTML = "nohtml" roomOptionNoHTML = "nohtml"
roomOptionNoThreads = "nothreads" roomOptionNoThreads = "nothreads"
roomOptionNoFiles = "nofiles" roomOptionNoFiles = "nofiles"
roomOptionPassword = "password"
) )
type roomSettings map[string]string type roomSettings map[string]string
@@ -43,6 +44,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))
} }

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,11 +16,15 @@ 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, local bool) *msasession {
return &msasession{ return &msasession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()), ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
mta: m.mta,
from: from,
local: local,
log: m.log, log: m.log,
bot: m.bot, bot: m.bot,
domain: m.domain, domain: m.domain,
@@ -25,9 +32,18 @@ func (m *msa) newSession() *msasession {
} }
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 email address")
}
mailbox := utils.Mailbox(username)
if !m.bot.AllowAuth(mailbox, 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,6 +2,7 @@ package smtp
import ( import (
"context" "context"
"errors"
"io" "io"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
@@ -15,32 +16,41 @@ import (
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 local 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.local {
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 { if s.local {
s.log.Debug("wrong domain of %s", to) if utils.Hostname(to) != s.domain {
return smtp.ErrAuthRequired s.log.Debug("wrong domain of %s", to)
} return smtp.ErrAuthRequired
}
_, ok := s.bot.GetMapping(utils.Mailbox(to)) _, ok := s.bot.GetMapping(utils.Mailbox(to))
if !ok { if !ok {
s.log.Debug("mapping for %s not found", to) s.log.Debug("mapping for %s not found", to)
return smtp.ErrAuthRequired return smtp.ErrAuthRequired
}
} }
s.to = to s.to = to
@@ -80,7 +90,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.local)
} }
func (s *msasession) Reset() {} func (s *msasession) Reset() {}

View File

@@ -17,8 +17,9 @@ 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 Send2Matrix(ctx context.Context, email *utils.Email, local bool) error
SetMTA(mta utils.MTA) SetMTA(mta utils.MTA)
} }

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,
} }
@@ -51,6 +52,7 @@ func NewServer(cfg *Config) *Server {
s.WriteTimeout = 10 * time.Second s.WriteTimeout = 10 * time.Second
s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024 s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024
s.EnableREQUIRETLS = cfg.TLSRequired s.EnableREQUIRETLS = cfg.TLSRequired
s.AllowInsecureAuth = !cfg.TLSRequired
if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" { if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
s.Debug = os.Stdout s.Debug = os.Stdout
} }

View File

@@ -4,6 +4,7 @@ import (
"crypto" "crypto"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"regexp"
"strings" "strings"
"time" "time"
@@ -13,6 +14,8 @@ import (
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
// MTA is mail transfer agent // MTA is mail transfer agent
type MTA interface { type MTA interface {
Send(from, to, data string) error Send(from, to, data string) error
@@ -46,6 +49,11 @@ type ContentOptions struct {
FromKey string FromKey string
} }
// AddressValid checks if email address is valid
func AddressValid(email string) bool {
return !emailRegex.MatchString(email)
}
// 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,6 +79,14 @@ 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 local=false) or TO (local=true)
func (e *Email) Mailbox(local bool) string {
if local {
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