diff --git a/README.md b/README.md index c6576d9..abd8417 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An Email to Matrix bridge ## Features / Roadmap / TODO -- [ ] SMTP server +- [x] SMTP server - [ ] SMTP client - [x] Matrix bot - [x] Configuration in room's account data diff --git a/bot/bot.go b/bot/bot.go index eef9e50..8c634e5 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -2,21 +2,27 @@ package bot import ( "context" + "errors" "fmt" + "strings" + "sync" "github.com/getsentry/sentry-go" "gitlab.com/etke.cc/go/logger" "gitlab.com/etke.cc/linkpearl" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" ) // Bot represents matrix bot type Bot struct { - prefix string - domain string - log *logger.Logger - lp *linkpearl.Linkpearl + prefix string + domain string + rooms map[string]id.RoomID + roomsmu *sync.Mutex + log *logger.Logger + lp *linkpearl.Linkpearl } // New creates a new matrix bot @@ -52,6 +58,10 @@ func (b *Bot) Start() error { if err := b.migrate(); err != nil { return err } + ctx := sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()) + if err := b.syncRooms(ctx); err != nil { + return err + } if err := b.lp.GetClient().SetPresence(event.PresenceOnline); err != nil { return err } @@ -61,6 +71,39 @@ func (b *Bot) Start() error { return b.lp.GetClient().Sync() } +// Send email to matrix room +func (b *Bot) Send(from, to, subject, body string) error { + roomID, ok := b.rooms[to] + if !ok || roomID == "" { + return errors.New("room not found") + } + + var text strings.Builder + text.WriteString("From: ") + text.WriteString(from) + text.WriteString("\n\n") + text.WriteString("# ") + text.WriteString(subject) + text.WriteString("\n\n") + text.WriteString(format.HTMLToMarkdown(body)) + + content := format.RenderMarkdown(text.String(), true, true) + _, err := b.lp.Send(roomID, content) + return err +} + +// GetMappings returns mapping of mailbox = room +func (b *Bot) GetMappings(ctx context.Context) (map[string]id.RoomID, error) { + if len(b.rooms) == 0 { + err := b.syncRooms(ctx) + if err != nil { + return nil, err + } + } + + return b.rooms, nil +} + // Stop the bot func (b *Bot) Stop() { err := b.lp.GetClient().SetPresence(event.PresenceOffline) diff --git a/bot/data.go b/bot/data.go index 10a2763..060ae95 100644 --- a/bot/data.go +++ b/bot/data.go @@ -9,14 +9,7 @@ import ( const settingskey = "cc.etke.postmoogle.settings" -var migrations = []string{ - ` - CREATE TABLE IF NOT EXISTS settings ( - room_id VARCHAR(255), - mailbox VARCHAR(255) - ) - `, -} +var migrations = []string{} // settings of a room type settings struct { @@ -57,21 +50,7 @@ func (b *Bot) getSettings(ctx context.Context, roomID id.RoomID) (*settings, err defer span.Finish() var config settings - query := "SELECT * FROM settings WHERE room_id = " - switch b.lp.GetStore().GetDialect() { - case "postgres": - query += "$1" - case "sqlite3": - query += "?" - } - row := b.lp.GetDB().QueryRow(query, roomID) - err := row.Scan(&config.Mailbox) - if err == nil { - return &config, nil - } - b.log.Error("cannot find settings in database: %v", err) - - err = b.lp.GetClient().GetRoomAccountData(roomID, settingskey, &config) + err := b.lp.GetClient().GetRoomAccountData(roomID, settingskey, &config) return &config, err } @@ -79,47 +58,5 @@ func (b *Bot) getSettings(ctx context.Context, roomID id.RoomID) (*settings, err func (b *Bot) setSettings(ctx context.Context, roomID id.RoomID, cfg *settings) error { span := sentry.StartSpan(ctx, "http.server", sentry.TransactionName("setSettings")) defer span.Finish() - - tx, err := b.lp.GetDB().Begin() - if err == nil { - var insert string - switch b.lp.GetStore().GetDialect() { - case "postgres": - insert = "INSERT INTO settings VALUES ($1) ON CONFLICT (room_id) DO UPDATE SET mailbox = $1" - case "sqlite3": - insert = "INSERT INTO settings VALUES (?) ON CONFLICT (room_id) DO UPDATE SET mailbox = ?" - } - _, err = tx.Exec(insert, cfg.Mailbox) - if err != nil { - b.log.Error("cannot insert settigs: %v", err) - // nolint // no need to check error here - tx.Rollback() - } - - if err != nil { - err = tx.Commit() - if err != nil { - b.log.Error("cannot commit transaction: %v", err) - } - } - } - return b.lp.GetClient().SetRoomAccountData(roomID, settingskey, cfg) } - -// FindRoomID by mailbox -func (b *Bot) FindRoomID(mailbox string) (id.RoomID, error) { - query := "SELECT room_id FROM settings WHERE mailbox = " - switch b.lp.GetStore().GetDialect() { - case "postgres": - query += "$1" - case "sqlite3": - query += "?" - } - - var roomID string - row := b.lp.GetDB().QueryRow(query, mailbox) - err := row.Scan(&roomID) - - return id.RoomID(roomID), err -} diff --git a/bot/mailbox.go b/bot/mailbox.go index 1295139..d92cf75 100644 --- a/bot/mailbox.go +++ b/bot/mailbox.go @@ -6,8 +6,34 @@ import ( "github.com/getsentry/sentry-go" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" ) +func (b *Bot) syncRooms(ctx context.Context) error { + b.roomsmu.Lock() + defer b.roomsmu.Unlock() + span := sentry.StartSpan(ctx, "http.server", sentry.TransactionName("syncRooms")) + defer span.Finish() + + resp, err := b.lp.GetClient().JoinedRooms() + if err != nil { + return err + } + b.rooms = make(map[string]id.RoomID, len(resp.JoinedRooms)) + for _, roomID := range resp.JoinedRooms { + cfg, serr := b.getSettings(span.Context(), roomID) + if serr != nil { + b.Error(span.Context(), roomID, "cannot get room settings: %v", err) + continue + } + if cfg.Mailbox != "" { + b.rooms[cfg.Mailbox] = roomID + } + } + + return nil +} + func (b *Bot) handleMailbox(ctx context.Context, evt *event.Event, command []string) { if len(command) == 1 { b.getMailbox(ctx, evt) @@ -43,6 +69,15 @@ func (b *Bot) setMailbox(ctx context.Context, evt *event.Event, mailbox string) span := sentry.StartSpan(ctx, "http.server", sentry.TransactionName("setMailbox")) defer span.Finish() + existingID, ok := b.rooms[mailbox] + if ok && existingID != "" && existingID != evt.RoomID { + content := format.RenderMarkdown("Mailbox "+mailbox+"@"+b.domain+" already taken", true, true) + content.MsgType = event.MsgNotice + _, err := b.lp.Send(evt.RoomID, content) + if err != nil { + b.Error(span.Context(), evt.RoomID, "cannot send message: %v", err) + } + } cfg, err := b.getSettings(span.Context(), evt.RoomID) if err != nil { b.log.Warn("cannot get settings: %v", err) @@ -57,6 +92,10 @@ func (b *Bot) setMailbox(ctx context.Context, evt *event.Event, mailbox string) return } + b.roomsmu.Lock() + b.rooms[mailbox] = evt.RoomID + b.roomsmu.Unlock() + content := format.RenderMarkdown("Mailbox of this room set to **"+cfg.Mailbox+"@"+b.domain+"**", true, true) content.MsgType = event.MsgNotice _, err = b.lp.Send(evt.RoomID, content) diff --git a/cmd/cmd.go b/cmd/cmd.go index 0890fae..b4ff451 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -16,7 +16,6 @@ import ( "gitlab.com/etke.cc/postmoogle/bot" "gitlab.com/etke.cc/postmoogle/config" "gitlab.com/etke.cc/postmoogle/smtp" - "maunium.net/go/mautrix/id" ) var ( @@ -40,12 +39,10 @@ func main() { initShutdown(quit) defer recovery() - smtp.NewServer(cfg.Domain, map[string]id.RoomID{}, cfg.Port) - log.Debug("starting matrix bot...") - err := mxb.Start() - if err != nil { + go startBot() + if err := smtp.Start(cfg.Domain, cfg.Port, cfg.LogLevel, mxb); err != nil { //nolint:gocritic - log.Fatal("cannot start the bot: %v", err) + log.Fatal("SMTP server crashed: %v", err) } <-quit @@ -100,6 +97,15 @@ func initShutdown(quit chan struct{}) { }() } +func startBot() { + log.Debug("starting matrix bot...") + err := mxb.Start() + if err != nil { + //nolint:gocritic + log.Fatal("cannot start the bot: %v", err) + } +} + func shutdown() { log.Info("Shutting down...") mxb.Stop() diff --git a/go.mod b/go.mod index c495fb6..7370d07 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitlab.com/etke.cc/postmoogle go 1.18 require ( + github.com/emersion/go-smtp v0.15.0 github.com/getsentry/sentry-go v0.13.0 github.com/lib/pq v1.10.6 github.com/mattn/go-sqlite3 v1.14.14 @@ -14,7 +15,6 @@ require ( require ( github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect - github.com/emersion/go-smtp v0.15.0 // indirect github.com/google/go-cmp v0.5.8 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect diff --git a/smtp/server.go b/smtp/server.go index b954a6b..61dd96b 100644 --- a/smtp/server.go +++ b/smtp/server.go @@ -1,23 +1,26 @@ package smtp import ( - "log" - "os" + "context" "time" "github.com/emersion/go-smtp" - "maunium.net/go/mautrix/id" + "github.com/getsentry/sentry-go" + "gitlab.com/etke.cc/go/logger" ) type backend struct { + log *logger.Logger domain string - rooms map[string]id.RoomID + client Client } func (b *backend) newSession() *session { return &session{ + ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()), + log: b.log, domain: b.domain, - rooms: b.rooms, + client: b.client, } } @@ -29,22 +32,21 @@ func (b *backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, err return b.newSession(), nil } -func NewServer(domain string, mapping map[string]id.RoomID, port string) *smtp.Server { +func Start(domain, port, loglevel string, client Client) error { + log := logger.New("smtp.", loglevel) be := &backend{ + log: log, domain: domain, - rooms: mapping, + client: client, } s := smtp.NewServer(be) s.Addr = ":" + port s.Domain = domain s.AuthDisabled = true - s.ReadTimeout = 360 * time.Second - s.WriteTimeout = 360 * time.Second + s.ReadTimeout = 10 * time.Second + s.WriteTimeout = 10 * time.Second s.MaxMessageBytes = 63 * 1024 - s.MaxRecipients = 50 - s.Debug = os.Stdout - log.Println("Starting SMTP server") - log.Fatal(s.ListenAndServe()) - return s + log.Info("Starting SMTP server on %s:%s", domain, port) + return s.ListenAndServe() } diff --git a/smtp/session.go b/smtp/session.go index fcc10c4..a5856c9 100644 --- a/smtp/session.go +++ b/smtp/session.go @@ -1,43 +1,61 @@ package smtp import ( + "context" "io" - "log" "github.com/emersion/go-smtp" - "maunium.net/go/mautrix/id" + "github.com/getsentry/sentry-go" + "gitlab.com/etke.cc/go/logger" ) type session struct { + log *logger.Logger domain string - rooms map[string]id.RoomID + client Client + + ctx context.Context + to string + from string } func (s *session) Mail(from string, opts smtp.MailOptions) error { - log.Println("mail from", from) + sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from) + s.from = from + s.log.Debug("mail from %s, options: %+v", from, opts) return nil } func (s *session) Rcpt(to string) error { - _, ok := s.rooms[to] + sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to) + mappings, err := s.client.GetMappings(s.ctx) + if err != nil { + s.log.Error("cannot get mappings: %v", err) + return err + } + _, ok := mappings[to] if !ok { + s.log.Debug("mapping for %s not found", to) return smtp.ErrAuthRequired } if Domain(to) != s.domain { + s.log.Debug("wrong domain of %s", to) return smtp.ErrAuthRequired } - log.Println("rcpt to", to) + + s.to = to + s.log.Debug("mail to %s", to) return nil } func (s *session) Data(r io.Reader) error { b, err := io.ReadAll(r) if err != nil { + s.log.Error("cannot read data: %v", err) return err } - log.Println("Data", string(b)) - return nil + return s.client.Send(s.from, s.to, "", string(b)) } func (s *session) Reset() {} diff --git a/smtp/smtp.go b/smtp/smtp.go new file mode 100644 index 0000000..f89e232 --- /dev/null +++ b/smtp/smtp.go @@ -0,0 +1,13 @@ +package smtp + +import ( + "context" + + "maunium.net/go/mautrix/id" +) + +// Client interface to send emails +type Client interface { + GetMappings(context.Context) (map[string]id.RoomID, error) + Send(from, to, subject, body string) error +}