diff --git a/README.md b/README.md index 4e66550..f46bd9c 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ If you want to change them - check available options in the help message (`!pm h * **!pm nosender** - Get or set `nosender` of the room (`true` - hide email sender; `false` - show email sender) * **!pm norecipient** - Get or set `norecipient` of the room (`true` - hide recipient; `false` - show recipient) +* **!pm nocc** - Get or set `nocc` of the room (`true` - hide CC; `false` - show CC) * **!pm nosubject** - Get or set `nosubject` of the room (`true` - hide email subject; `false` - show email subject) * **!pm nohtml** - Get or set `nohtml` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails) * **!pm nothreads** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads) diff --git a/bot/command.go b/bot/command.go index aebfcc9..cf3ce73 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" ) @@ -120,6 +121,15 @@ func (b *Bot) initCommands() commandList { sanitizer: utils.SanitizeBoolString, allowed: b.allowOwner, }, + { + key: roomOptionNoCC, + description: fmt.Sprintf( + "Get or set `%s` of the room (`true` - hide CC; `false` - show CC)", + roomOptionNoCC, + ), + sanitizer: utils.SanitizeBoolString, + allowed: b.allowOwner, + }, { key: roomOptionNoSubject, description: fmt.Sprintf( @@ -415,7 +425,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 +436,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, to, "", body, htmlBody, nil) + data := eml.Compose(b.getBotSettings().DKIMPrivateKey()) if data == "" { b.SendError(ctx, evt.RoomID, "email body is empty") return @@ -437,14 +447,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 8cb5a2e..7aca141 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" ) @@ -25,8 +26,10 @@ const ( eventReferencesKey = "cc.etke.postmoogle.references" eventInReplyToKey = "cc.etke.postmoogle.inReplyTo" eventSubjectKey = "cc.etke.postmoogle.subject" + eventRcptToKey = "cc.etke.postmoogle.rcptTo" eventFromKey = "cc.etke.postmoogle.from" eventToKey = "cc.etke.postmoogle.to" + eventCcKey = "cc.etke.postmoogle.cc" ) // SetSendmail sets mail sending func to the bot @@ -83,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) @@ -94,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") @@ -147,18 +150,11 @@ func (b *Bot) SendEmailReply(ctx context.Context) { b.Error(ctx, evt.RoomID, "mailbox is not configured, kupo") return } - domain := utils.SanitizeDomain(cfg.Domain()) b.lock(evt.RoomID.String()) defer b.unlock(evt.RoomID.String()) - fromMailbox := mailbox + "@" + domain - meta := b.getParentEmail(evt, domain) - // when email was sent from matrix and reply was sent from matrix again - if fromMailbox != meta.From { - meta.To = meta.From - } - meta.From = fromMailbox + meta := b.getParentEmail(evt, mailbox) if meta.To == "" { b.Error(ctx, evt.RoomID, "cannot find parent email and continue the thread. Please, start a new email thread") @@ -175,11 +171,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, meta.FromDomain) 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, meta.RcptTo, meta.CC, body, htmlBody, nil) + data := eml.Compose(b.getBotSettings().DKIMPrivateKey()) if data == "" { b.SendError(ctx, evt.RoomID, "email body is empty") return @@ -188,7 +184,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 } @@ -197,19 +193,71 @@ 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 { MessageID string ThreadID id.EventID From string + FromDomain string To string + RcptTo string + CC string InReplyTo string References string Subject string } +// fixtofrom attempts to "fix" or rather reverse the To, From and CC headers +// of parent email by using parent email as metadata source for a new email +// that will be sent from postmoogle. +// To do so, we need to reverse From and To headers, but Cc should be adjusted as well, +// thus that hacky workaround below: +func (e *parentEmail) fixtofrom(newSenderMailbox string, domains []string) { + newSenders := make(map[string]string, len(domains)) + for _, domain := range domains { + sender := newSenderMailbox + "@" + domain + newSenders[sender] = sender + } + + // try to determine previous email of the room mailbox + // by matching RCPT TO, To and From fields + // why? Because of possible multi-domain setup and we won't leak information + var previousSender string + rcptToSender, ok := newSenders[e.RcptTo] + if ok { + previousSender = rcptToSender + } + toSender, ok := newSenders[e.To] + if ok { + previousSender = toSender + } + fromSender, ok := newSenders[e.From] + if ok { + previousSender = fromSender + } + + // Message-Id should not leak information either + e.FromDomain = utils.SanitizeDomain(utils.Hostname(previousSender)) + + originalFrom := e.From + // reverse From if needed + if fromSender == "" { + e.From = previousSender + } + // reverse To if needed + if toSender != "" { + e.To = originalFrom + } + // replace previous recipient of the email which is sender now with the original From + for newSender := range newSenders { + if strings.Contains(e.CC, newSender) { + e.CC = strings.ReplaceAll(e.CC, newSender, originalFrom) + } + } +} + func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) { content := evt.Content.AsMessage() threadID := utils.EventParent(evt.ID, content) @@ -246,8 +294,8 @@ func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) { return threadID, decrypted } -func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail { - var parent parentEmail +func (b *Bot) getParentEmail(evt *event.Event, newFromMailbox string) *parentEmail { + parent := &parentEmail{} threadID, parentEvt := b.getParentEvent(evt) parent.ThreadID = threadID if parentEvt == nil { @@ -257,11 +305,14 @@ func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail { return parent } - parent.MessageID = utils.MessageID(parentEvt.ID, domain) parent.From = utils.EventField[string](&parentEvt.Content, eventFromKey) parent.To = utils.EventField[string](&parentEvt.Content, eventToKey) + parent.CC = utils.EventField[string](&parentEvt.Content, eventCcKey) + parent.RcptTo = utils.EventField[string](&parentEvt.Content, eventRcptToKey) parent.InReplyTo = utils.EventField[string](&parentEvt.Content, eventMessageIDkey) parent.References = utils.EventField[string](&parentEvt.Content, eventReferencesKey) + parent.fixtofrom(newFromMailbox, b.domains) + parent.MessageID = email.MessageID(parentEvt.ID, parent.FromDomain) if parent.InReplyTo == "" { parent.InReplyTo = parent.MessageID } @@ -281,14 +332,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 { @@ -305,8 +356,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 f46c97a..bd62091 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" ) @@ -17,6 +18,7 @@ const ( roomOptionMailbox = "mailbox" roomOptionDomain = "domain" roomOptionNoSend = "nosend" + roomOptionNoCC = "nocc" roomOptionNoSender = "nosender" roomOptionNoRecipient = "norecipient" roomOptionNoSubject = "nosubject" @@ -61,6 +63,10 @@ func (s roomSettings) NoSend() bool { return utils.Bool(s.Get(roomOptionNoSend)) } +func (s roomSettings) NoCC() bool { + return utils.Bool(s.Get(roomOptionNoCC)) +} + func (s roomSettings) NoSender() bool { return utils.Bool(s.Get(roomOptionNoSender)) } @@ -143,8 +149,9 @@ 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{ + CC: !s.NoCC(), HTML: !s.NoHTML(), Sender: !s.NoSender(), Recipient: !s.NoRecipient(), @@ -152,7 +159,9 @@ func (s roomSettings) ContentOptions() *utils.ContentOptions { Threads: !s.NoThreads(), ToKey: eventToKey, + CcKey: eventCcKey, FromKey: eventFromKey, + RcptToKey: eventRcptToKey, SubjectKey: eventSubjectKey, MessageIDKey: eventMessageIDkey, InReplyToKey: eventInReplyToKey, diff --git a/utils/email.go b/email/email.go similarity index 67% rename from utils/email.go rename to email/email.go index 34b03e3..4d503ae 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 @@ -34,50 +23,25 @@ type Email struct { References string From string To string + RcptTo string + CC string 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 -} - -// 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) -} - -// 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, rcptto, cc, text, html string, files []*utils.File) *Email { email := &Email{ - Date: time.Now().UTC().Format(time.RFC1123Z), + Date: dateNow(), MessageID: messageID, InReplyTo: inReplyTo, References: references, From: from, To: to, + CC: cc, + RcptTo: rcptto, Subject: subject, Text: text, HTML: html, @@ -92,12 +56,46 @@ func NewEmail(messageID, inReplyTo, references, subject, from, to, text, html st return email } +// 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 envelope.HTML != "" { + html = styleRegex.ReplaceAllString(envelope.HTML, "") + } + + 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: 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: envelope.GetHeader("Cc"), + Subject: envelope.GetHeader("Subject"), + Text: envelope.Text, + HTML: html, + Files: files, + } + + return 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.To) + return utils.Mailbox(e.RcptTo) } - return Mailbox(e.From) + return utils.Mailbox(e.From) } // Content converts the email object to a Matrix event content @@ -110,7 +108,11 @@ func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Con text.WriteString(" ➡️ ") text.WriteString(e.To) } - if options.Sender || options.Recipient { + if options.CC && e.CC != "" { + text.WriteString("\ncc: ") + text.WriteString(e.CC) + } + if options.Sender || options.Recipient || options.CC { text.WriteString("\n\n") } if options.Subject && threadID == "" { @@ -125,7 +127,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{}{ @@ -133,8 +135,10 @@ func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Con options.InReplyToKey: e.InReplyTo, options.ReferencesKey: e.References, options.SubjectKey: e.Subject, + options.RcptToKey: e.RcptTo, options.FromKey: e.From, options.ToKey: e.To, + options.CcKey: e.CC, }, Parsed: &parsed, } @@ -169,13 +173,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..522ece8 --- /dev/null +++ b/email/options.go @@ -0,0 +1,29 @@ +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 + CC bool + 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 3301b87..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 @@ -60,6 +60,7 @@ func (m *mailServer) Login(state *smtp.ConnectionState, username, password strin from: username, log: m.log, domains: m.domains, + tos: []string{}, }, nil } @@ -80,6 +81,7 @@ func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, log: m.log, domains: m.domains, addr: state.RemoteAddr, + tos: []string{}, }, nil } @@ -112,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 ca75024..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,21 +21,21 @@ 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 ctx context.Context addr net.Addr - to string + tos []string from string } 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 @@ -46,7 +47,7 @@ func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error { func (s *incomingSession) Rcpt(to string) error { sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to) - s.to = to + s.tos = append(s.tos, to) var domainok bool for _, domain := range s.domains { if utils.Hostname(to) == domain { @@ -66,7 +67,7 @@ func (s *incomingSession) Rcpt(to string) error { } validations := s.getFilters(roomID) - if !validateEmail(s.from, s.to, s.log, validations) { + if !validateEmail(s.from, to, s.log, validations) { s.ban(s.addr) return ErrBanned } @@ -84,25 +85,20 @@ 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 } - files := parseAttachments(eml.Attachments, s.log) - - email := utils.NewEmail( - eml.GetHeader("Message-Id"), - eml.GetHeader("In-Reply-To"), - eml.GetHeader("References"), - eml.GetHeader("Subject"), - s.from, - s.to, - eml.Text, - eml.HTML, - files) - - return s.receiveEmail(s.ctx, email) + eml := email.FromEnvelope(s.tos[0], envelope) + for _, to := range s.tos { + eml.RcptTo = to + err := s.receiveEmail(s.ctx, eml) + if err != nil { + return err + } + } + return nil } func (s *incomingSession) Reset() {} func (s *incomingSession) Logout() error { return nil } @@ -115,13 +111,13 @@ type outgoingSession struct { domains []string ctx context.Context - to string + tos []string from string } 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 @@ -129,7 +125,7 @@ func (s *outgoingSession) Mail(from string, opts smtp.MailOptions) error { func (s *outgoingSession) Rcpt(to string) error { sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to) - s.to = to + s.tos = append(s.tos, to) s.log.Debug("mail to %s", to) return nil @@ -137,30 +133,25 @@ 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 } + 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)) + if err != nil { + return err + } + } - files := parseAttachments(eml.Attachments, s.log) - - email := utils.NewEmail( - eml.GetHeader("Message-Id"), - eml.GetHeader("In-Reply-To"), - eml.GetHeader("References"), - eml.GetHeader("Subject"), - s.from, - s.to, - eml.Text, - eml.HTML, - files) - - return s.sendmail(email.From, email.To, email.Compose(s.privkey)) + return nil } 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(), @@ -170,16 +161,3 @@ func validateEmail(from, to string, log *logger.Logger, options utils.IncomingFi return v.Email(from) } - -func parseAttachments(parts []*enmime.Part, log *logger.Logger) []*utils.File { - files := make([]*utils.File, 0, len(parts)) - for _, attachment := range parts { - for _, err := range attachment.Errors { - log.Warn("attachment error: %v", err) - } - file := utils.NewFile(attachment.FileName, attachment.Content) - files = append(files, file) - } - - return files -} 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)