use postmoogle as general purpose SMTP server and allow other apps or scripts to send emails through it
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
/local.db
|
/local.db
|
||||||
/local.db-journal
|
/local.db-journal
|
||||||
/cover.out
|
/cover.out
|
||||||
|
/e2e/main.go
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
13
bot/email.go
13
bot/email.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
22
smtp/msa.go
22
smtp/msa.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user