diff --git a/bot/command.go b/bot/command.go index aebfcc9..8233ef3 100644 --- a/bot/command.go +++ b/bot/command.go @@ -10,6 +10,7 @@ import ( "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" + "gitlab.com/etke.cc/postmoogle/email" "gitlab.com/etke.cc/postmoogle/utils" ) @@ -415,7 +416,7 @@ func (b *Bot) runSend(ctx context.Context) { tos := strings.Split(to, ",") // validate first for _, to := range tos { - if !utils.AddressValid(to) { + if !email.AddressValid(to) { b.Error(ctx, evt.RoomID, "email address is not valid") return } @@ -426,10 +427,10 @@ func (b *Bot) runSend(ctx context.Context) { domain := utils.SanitizeDomain(cfg.Domain()) from := mailbox + "@" + domain - ID := utils.MessageID(evt.ID, domain) + ID := email.MessageID(evt.ID, domain) for _, to := range tos { - email := utils.NewEmail(ID, "", " "+ID, subject, from, to, body, htmlBody, nil) - data := email.Compose(b.getBotSettings().DKIMPrivateKey()) + eml := email.New(ID, "", " "+ID, subject, from, to, body, htmlBody, nil) + data := eml.Compose(b.getBotSettings().DKIMPrivateKey()) if data == "" { b.SendError(ctx, evt.RoomID, "email body is empty") return @@ -437,14 +438,14 @@ func (b *Bot) runSend(ctx context.Context) { queued, err := b.Sendmail(evt.ID, from, to, data) if queued { b.log.Error("cannot send email: %v", err) - b.saveSentMetadata(ctx, queued, evt.ID, email, &cfg) + b.saveSentMetadata(ctx, queued, evt.ID, eml, &cfg) continue } if err != nil { b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err) continue } - b.saveSentMetadata(ctx, false, evt.ID, email, &cfg) + b.saveSentMetadata(ctx, false, evt.ID, eml, &cfg) } if len(tos) > 1 { b.SendNotice(ctx, evt.RoomID, "All emails were sent.") diff --git a/bot/email.go b/bot/email.go index d3e4d21..ce9fec5 100644 --- a/bot/email.go +++ b/bot/email.go @@ -9,6 +9,7 @@ import ( "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" + "gitlab.com/etke.cc/postmoogle/email" "gitlab.com/etke.cc/postmoogle/utils" ) @@ -85,7 +86,7 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) { } // GetIFOptions returns incoming email filtering options (room settings) -func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions { +func (b *Bot) GetIFOptions(roomID id.RoomID) email.IncomingFilteringOptions { cfg, err := b.getRoomSettings(roomID) if err != nil { b.log.Error("cannot retrieve room settings: %v", err) @@ -96,7 +97,7 @@ func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions { } // IncomingEmail sends incoming email to matrix room -func (b *Bot) IncomingEmail(ctx context.Context, email *utils.Email) error { +func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error { roomID, ok := b.GetMapping(email.Mailbox(true)) if !ok { return errors.New("room not found") @@ -177,11 +178,11 @@ func (b *Bot) SendEmailReply(ctx context.Context) { body := content.Body htmlBody := content.FormattedBody - meta.MessageID = utils.MessageID(evt.ID, domain) + meta.MessageID = email.MessageID(evt.ID, domain) meta.References = meta.References + " " + meta.MessageID b.log.Debug("send email reply: %+v", meta) - email := utils.NewEmail(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, body, htmlBody, nil) - data := email.Compose(b.getBotSettings().DKIMPrivateKey()) + eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, body, htmlBody, nil) + data := eml.Compose(b.getBotSettings().DKIMPrivateKey()) if data == "" { b.SendError(ctx, evt.RoomID, "email body is empty") return @@ -190,7 +191,7 @@ func (b *Bot) SendEmailReply(ctx context.Context) { queued, err := b.Sendmail(evt.ID, meta.From, meta.To, data) if queued { b.log.Error("cannot send email: %v", err) - b.saveSentMetadata(ctx, queued, meta.ThreadID, email, &cfg) + b.saveSentMetadata(ctx, queued, meta.ThreadID, eml, &cfg) return } @@ -199,7 +200,7 @@ func (b *Bot) SendEmailReply(ctx context.Context) { return } - b.saveSentMetadata(ctx, queued, meta.ThreadID, email, &cfg) + b.saveSentMetadata(ctx, queued, meta.ThreadID, eml, &cfg) } type parentEmail struct { @@ -259,7 +260,7 @@ func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail { return parent } - parent.MessageID = utils.MessageID(parentEvt.ID, domain) + parent.MessageID = email.MessageID(parentEvt.ID, domain) parent.From = utils.EventField[string](&parentEvt.Content, eventFromKey) parent.To = utils.EventField[string](&parentEvt.Content, eventToKey) parent.InReplyTo = utils.EventField[string](&parentEvt.Content, eventMessageIDkey) @@ -283,14 +284,14 @@ func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail { // saveSentMetadata used to save metadata from !pm sent and thread reply events to a separate notice message // because that metadata is needed to determine email thread relations -func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, email *utils.Email, cfg *roomSettings) { - text := "Email has been sent to " + email.To +func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, eml *email.Email, cfg *roomSettings) { + text := "Email has been sent to " + eml.RcptTo if queued { - text = "Email to " + email.To + " has been queued" + text = "Email to " + eml.RcptTo + " has been queued" } evt := eventFromContext(ctx) - content := email.Content(threadID, cfg.ContentOptions()) + content := eml.Content(threadID, cfg.ContentOptions()) notice := format.RenderMarkdown(text, true, true) msgContent, ok := content.Parsed.(*event.MessageEventContent) if !ok { @@ -307,8 +308,8 @@ func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.Eve return } domain := utils.SanitizeDomain(cfg.Domain()) - b.setThreadID(evt.RoomID, utils.MessageID(evt.ID, domain), threadID) - b.setThreadID(evt.RoomID, utils.MessageID(msgID, domain), threadID) + b.setThreadID(evt.RoomID, email.MessageID(evt.ID, domain), threadID) + b.setThreadID(evt.RoomID, email.MessageID(msgID, domain), threadID) b.setLastEventID(evt.RoomID, threadID, msgID) } diff --git a/bot/settings_room.go b/bot/settings_room.go index 91edda9..54359c7 100644 --- a/bot/settings_room.go +++ b/bot/settings_room.go @@ -5,6 +5,7 @@ import ( "maunium.net/go/mautrix/id" + "gitlab.com/etke.cc/postmoogle/email" "gitlab.com/etke.cc/postmoogle/utils" ) @@ -143,8 +144,8 @@ func (s roomSettings) migrateSpamlistSettings() { } // ContentOptions converts room display settings to content options -func (s roomSettings) ContentOptions() *utils.ContentOptions { - return &utils.ContentOptions{ +func (s roomSettings) ContentOptions() *email.ContentOptions { + return &email.ContentOptions{ HTML: !s.NoHTML(), Sender: !s.NoSender(), Recipient: !s.NoRecipient(), diff --git a/utils/email.go b/email/email.go similarity index 60% rename from utils/email.go rename to email/email.go index dda0025..b54ecbd 100644 --- a/utils/email.go +++ b/email/email.go @@ -1,31 +1,20 @@ -package utils +package email import ( "crypto" "crypto/x509" "encoding/pem" - "fmt" - "net/mail" - "regexp" "strings" - "time" "github.com/emersion/go-msgauth/dkim" "github.com/jhillyerd/enmime" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" + + "gitlab.com/etke.cc/postmoogle/utils" ) -var styleRegex = regexp.MustCompile("") - -// IncomingFilteringOptions for incoming mail -type IncomingFilteringOptions interface { - SpamcheckSMTP() bool - SpamcheckMX() bool - Spamlist() []string -} - // Email object type Email struct { Date string @@ -39,54 +28,13 @@ type Email struct { Subject string Text string HTML string - Files []*File + Files []*utils.File } -// ContentOptions represents settings that specify how an email is to be converted to a Matrix message -type ContentOptions struct { - // On/Off - Sender bool - Recipient bool - Subject bool - HTML bool - Threads bool - - // Keys - MessageIDKey string - InReplyToKey string - ReferencesKey string - SubjectKey string - FromKey string - ToKey string - CcKey string - RcptToKey string -} - -// AddressValid checks if email address is valid -func AddressValid(email string) bool { - _, err := mail.ParseAddress(email) - return err == nil -} - -// EmailDate returns Date in RFC1123 with numeric timezone -func EmailDate(original ...time.Time) string { - now := time.Now().UTC() - if len(original) > 0 && !original[0].IsZero() { - now = original[0] - } - - return now.Format(time.RFC1123Z) -} - -// MessageID generates email Message-Id from matrix event ID -func MessageID(eventID id.EventID, domain string) string { - return fmt.Sprintf("<%s@%s>", eventID, domain) -} - -// NewEmail constructs Email object -func NewEmail(messageID, inReplyTo, references, subject, from, to, text, html string, files []*File) *Email { +// New constructs Email object +func New(messageID, inReplyTo, references, subject, from, to, text, html string, files []*utils.File) *Email { email := &Email{ - Date: EmailDate(), + Date: dateNow(), MessageID: messageID, InReplyTo: inReplyTo, References: references, @@ -107,35 +55,33 @@ func NewEmail(messageID, inReplyTo, references, subject, from, to, text, html st return email } -func FromEnvelope(rcptto string, eml *enmime.Envelope) *Email { - datetime, _ := eml.Date() //nolint:errcheck // handled in EmailDate() - date := EmailDate(datetime) +// FromEnvelope constructs Email object from envelope +func FromEnvelope(rcptto string, envelope *enmime.Envelope) *Email { + datetime, _ := envelope.Date() //nolint:errcheck // handled in dateNow() + date := dateNow(datetime) var html string - if eml.HTML != "" { - html = styleRegex.ReplaceAllString(eml.HTML, "") + if envelope.HTML != "" { + html = styleRegex.ReplaceAllString(envelope.HTML, "") } - files := make([]*File, 0, len(eml.Attachments)) - for _, attachment := range eml.Attachments { - for _, err := range attachment.Errors { - log.Warn("attachment error: %v", err) - } - file := NewFile(attachment.FileName, attachment.Content) + files := make([]*utils.File, 0, len(envelope.Attachments)) + for _, attachment := range envelope.Attachments { + file := utils.NewFile(attachment.FileName, attachment.Content) files = append(files, file) } email := &Email{ Date: date, - MessageID: eml.GetHeader("Message-Id"), - InReplyTo: eml.GetHeader("In-Reply-To"), - References: eml.GetHeader("References"), - From: eml.GetHeader("From"), - To: eml.GetHeader("To"), + MessageID: envelope.GetHeader("Message-Id"), + InReplyTo: envelope.GetHeader("In-Reply-To"), + References: envelope.GetHeader("References"), + From: envelope.GetHeader("From"), + To: envelope.GetHeader("To"), RcptTo: rcptto, - CC: eml.GetHeader("Cc"), - Subject: eml.GetHeader("Subject"), - Text: eml.Text, + CC: envelope.GetHeader("Cc"), + Subject: envelope.GetHeader("Subject"), + Text: envelope.Text, HTML: html, Files: files, } @@ -146,9 +92,9 @@ func FromEnvelope(rcptto string, eml *enmime.Envelope) *Email { // Mailbox returns postmoogle's mailbox, parsing it from FROM (if incoming=false) or TO (incoming=true) func (e *Email) Mailbox(incoming bool) string { if incoming { - return Mailbox(e.RcptTo) + return utils.Mailbox(e.RcptTo) } - return Mailbox(e.From) + return utils.Mailbox(e.From) } // Content converts the email object to a Matrix event content @@ -176,7 +122,7 @@ func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Con } parsed := format.RenderMarkdown(text.String(), true, true) - parsed.RelatesTo = RelatesTo(options.Threads, threadID) + parsed.RelatesTo = utils.RelatesTo(options.Threads, threadID) content := event.Content{ Raw: map[string]interface{}{ @@ -222,13 +168,11 @@ func (e *Email) Compose(privkey string) string { root, err := mail.Build() if err != nil { - log.Error("cannot compose email: %v", err) return "" } var data strings.Builder err = root.Encode(&data) if err != nil { - log.Error("cannot encode email: %v", err) return "" } diff --git a/email/options.go b/email/options.go new file mode 100644 index 0000000..d7744a0 --- /dev/null +++ b/email/options.go @@ -0,0 +1,28 @@ +package email + +// IncomingFilteringOptions for incoming mail +type IncomingFilteringOptions interface { + SpamcheckSMTP() bool + SpamcheckMX() bool + Spamlist() []string +} + +// ContentOptions represents settings that specify how an email is to be converted to a Matrix message +type ContentOptions struct { + // On/Off + Sender bool + Recipient bool + Subject bool + HTML bool + Threads bool + + // Keys + MessageIDKey string + InReplyToKey string + ReferencesKey string + SubjectKey string + FromKey string + ToKey string + CcKey string + RcptToKey string +} diff --git a/email/utils.go b/email/utils.go new file mode 100644 index 0000000..3d844c6 --- /dev/null +++ b/email/utils.go @@ -0,0 +1,33 @@ +package email + +import ( + "fmt" + "net/mail" + "regexp" + "time" + + "maunium.net/go/mautrix/id" +) + +var styleRegex = regexp.MustCompile("") + +// AddressValid checks if email address is valid +func AddressValid(email string) bool { + _, err := mail.ParseAddress(email) + return err == nil +} + +// MessageID generates email Message-Id from matrix event ID +func MessageID(eventID id.EventID, domain string) string { + return fmt.Sprintf("<%s@%s>", eventID, domain) +} + +// dateNow returns Date in RFC1123 with numeric timezone +func dateNow(original ...time.Time) string { + now := time.Now().UTC() + if len(original) > 0 && !original[0].IsZero() { + now = original[0] + } + + return now.Format(time.RFC1123Z) +} diff --git a/go.mod b/go.mod index 30a43c0..7d309dc 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( gitlab.com/etke.cc/go/trysmtp v1.0.0 gitlab.com/etke.cc/go/validator v1.0.4 gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6 - golang.org/x/net v0.2.0 maunium.net/go/mautrix v0.12.3 ) @@ -49,6 +48,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/yuin/goldmark v1.5.3 // indirect golang.org/x/crypto v0.3.0 // indirect + golang.org/x/net v0.2.0 // indirect golang.org/x/sys v0.2.0 // indirect golang.org/x/text v0.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/smtp/manager.go b/smtp/manager.go index 7b3ca36..4fafc4c 100644 --- a/smtp/manager.go +++ b/smtp/manager.go @@ -11,7 +11,7 @@ import ( "gitlab.com/etke.cc/go/logger" "maunium.net/go/mautrix/id" - "gitlab.com/etke.cc/postmoogle/utils" + "gitlab.com/etke.cc/postmoogle/email" ) type Config struct { @@ -45,8 +45,8 @@ type matrixbot interface { IsBanned(net.Addr) bool Ban(net.Addr) GetMapping(string) (id.RoomID, bool) - GetIFOptions(id.RoomID) utils.IncomingFilteringOptions - IncomingEmail(context.Context, *utils.Email) error + GetIFOptions(id.RoomID) email.IncomingFilteringOptions + IncomingEmail(context.Context, *email.Email) error SetSendmail(func(string, string, string) error) GetDKIMprivkey() string } diff --git a/smtp/server.go b/smtp/server.go index cefdd9a..c96e85f 100644 --- a/smtp/server.go +++ b/smtp/server.go @@ -10,7 +10,7 @@ import ( "gitlab.com/etke.cc/go/logger" "gitlab.com/etke.cc/go/trysmtp" - "gitlab.com/etke.cc/postmoogle/utils" + "gitlab.com/etke.cc/postmoogle/email" ) var ( @@ -41,7 +41,7 @@ func (m *mailServer) Login(state *smtp.ConnectionState, username, password strin return nil, ErrBanned } - if !utils.AddressValid(username) { + if !email.AddressValid(username) { m.log.Debug("address %s is invalid", username) m.bot.Ban(state.RemoteAddr) return nil, ErrBanned @@ -114,6 +114,6 @@ func (m *mailServer) SendEmail(from, to, data string) error { } // ReceiveEmail - incoming mail into matrix room -func (m *mailServer) ReceiveEmail(ctx context.Context, email *utils.Email) error { - return m.bot.IncomingEmail(ctx, email) +func (m *mailServer) ReceiveEmail(ctx context.Context, eml *email.Email) error { + return m.bot.IncomingEmail(ctx, eml) } diff --git a/smtp/session.go b/smtp/session.go index 661ae6e..3802198 100644 --- a/smtp/session.go +++ b/smtp/session.go @@ -13,6 +13,7 @@ import ( "gitlab.com/etke.cc/go/validator" "maunium.net/go/mautrix/id" + "gitlab.com/etke.cc/postmoogle/email" "gitlab.com/etke.cc/postmoogle/utils" ) @@ -20,8 +21,8 @@ import ( type incomingSession struct { log *logger.Logger getRoomID func(string) (id.RoomID, bool) - getFilters func(id.RoomID) utils.IncomingFilteringOptions - receiveEmail func(context.Context, *utils.Email) error + getFilters func(id.RoomID) email.IncomingFilteringOptions + receiveEmail func(context.Context, *email.Email) error greylisted func(net.Addr) bool ban func(net.Addr) domains []string @@ -34,7 +35,7 @@ type incomingSession struct { func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error { sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from) - if !utils.AddressValid(from) { + if !email.AddressValid(from) { s.log.Debug("address %s is invalid", from) s.ban(s.addr) return ErrBanned @@ -84,15 +85,15 @@ func (s *incomingSession) Data(r io.Reader) error { } } parser := enmime.NewParser() - eml, err := parser.ReadEnvelope(r) + envelope, err := parser.ReadEnvelope(r) if err != nil { return err } - email := utils.FromEnvelope(s.tos[0], eml) + eml := email.FromEnvelope(s.tos[0], envelope) for _, to := range s.tos { - email.RcptTo = to - err := s.receiveEmail(s.ctx, email) + eml.RcptTo = to + err := s.receiveEmail(s.ctx, eml) if err != nil { return err } @@ -116,7 +117,7 @@ type outgoingSession struct { func (s *outgoingSession) Mail(from string, opts smtp.MailOptions) error { sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from) - if !utils.AddressValid(from) { + if !email.AddressValid(from) { return errors.New("please, provide email address") } return nil @@ -132,14 +133,14 @@ func (s *outgoingSession) Rcpt(to string) error { func (s *outgoingSession) Data(r io.Reader) error { parser := enmime.NewParser() - eml, err := parser.ReadEnvelope(r) + envelope, err := parser.ReadEnvelope(r) if err != nil { return err } - email := utils.FromEnvelope(s.tos[0], eml) + eml := email.FromEnvelope(s.tos[0], envelope) for _, to := range s.tos { - email.RcptTo = to - err := s.sendmail(email.From, to, email.Compose(s.privkey)) + eml.RcptTo = to + err := s.sendmail(eml.From, to, eml.Compose(s.privkey)) if err != nil { return err } @@ -150,7 +151,7 @@ func (s *outgoingSession) Data(r io.Reader) error { func (s *outgoingSession) Reset() {} func (s *outgoingSession) Logout() error { return nil } -func validateEmail(from, to string, log *logger.Logger, options utils.IncomingFilteringOptions) bool { +func validateEmail(from, to string, log *logger.Logger, options email.IncomingFilteringOptions) bool { enforce := validator.Enforce{ Email: true, MX: options.SpamcheckMX(), diff --git a/utils/mail.go b/utils/mail.go new file mode 100644 index 0000000..3be1b25 --- /dev/null +++ b/utils/mail.go @@ -0,0 +1,41 @@ +package utils + +import "strings" + +// Mailbox returns mailbox part from email address +func Mailbox(email string) string { + index := strings.LastIndex(email, "@") + if index == -1 { + return email + } + return email[:index] +} + +// EmailsList returns human-readable list of mailbox's emails for all available domains +func EmailsList(mailbox string, domain string) string { + var msg strings.Builder + domain = SanitizeDomain(domain) + msg.WriteString(mailbox) + msg.WriteString("@") + msg.WriteString(domain) + + count := len(domains) - 1 + for i, aliasDomain := range domains { + if i < count { + msg.WriteString(", ") + } + if aliasDomain == domain { + continue + } + msg.WriteString(mailbox) + msg.WriteString("@") + msg.WriteString(aliasDomain) + } + + return msg.String() +} + +// Hostname returns hostname part from email address +func Hostname(email string) string { + return email[strings.LastIndex(email, "@")+1:] +} diff --git a/utils/utils.go b/utils/utils.go index e937cf0..fd7af2d 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -22,44 +22,6 @@ func SetDomains(slice []string) { domains = slice } -// Mailbox returns mailbox part from email address -func Mailbox(email string) string { - index := strings.LastIndex(email, "@") - if index == -1 { - return email - } - return email[:index] -} - -// EmailsList returns human-readable list of mailbox's emails for all available domains -func EmailsList(mailbox string, domain string) string { - var msg strings.Builder - domain = SanitizeDomain(domain) - msg.WriteString(mailbox) - msg.WriteString("@") - msg.WriteString(domain) - - count := len(domains) - 1 - for i, aliasDomain := range domains { - if i < count { - msg.WriteString(", ") - } - if aliasDomain == domain { - continue - } - msg.WriteString(mailbox) - msg.WriteString("@") - msg.WriteString(aliasDomain) - } - - return msg.String() -} - -// Hostname returns hostname part from email address -func Hostname(email string) string { - return email[strings.LastIndex(email, "@")+1:] -} - // SanitizeDomain checks that input domain is available for use func SanitizeDomain(domain string) string { domain = strings.TrimSpace(domain)