Merge branch 'addmeto.cc' into 'main'

CC/BCC support

See merge request etke.cc/postmoogle!40
This commit is contained in:
Aine
2022-11-19 16:06:29 +00:00
13 changed files with 302 additions and 184 deletions

View File

@@ -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 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 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 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 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) * **!pm nothreads** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)

View File

@@ -10,6 +10,7 @@ import (
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils" "gitlab.com/etke.cc/postmoogle/utils"
) )
@@ -120,6 +121,15 @@ func (b *Bot) initCommands() commandList {
sanitizer: utils.SanitizeBoolString, sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner, 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, key: roomOptionNoSubject,
description: fmt.Sprintf( description: fmt.Sprintf(
@@ -415,7 +425,7 @@ func (b *Bot) runSend(ctx context.Context) {
tos := strings.Split(to, ",") tos := strings.Split(to, ",")
// validate first // validate first
for _, to := range tos { for _, to := range tos {
if !utils.AddressValid(to) { if !email.AddressValid(to) {
b.Error(ctx, evt.RoomID, "email address is not valid") b.Error(ctx, evt.RoomID, "email address is not valid")
return return
} }
@@ -426,10 +436,10 @@ func (b *Bot) runSend(ctx context.Context) {
domain := utils.SanitizeDomain(cfg.Domain()) domain := utils.SanitizeDomain(cfg.Domain())
from := mailbox + "@" + domain from := mailbox + "@" + domain
ID := utils.MessageID(evt.ID, domain) ID := email.MessageID(evt.ID, domain)
for _, to := range tos { for _, to := range tos {
email := utils.NewEmail(ID, "", " "+ID, subject, from, to, body, htmlBody, nil) eml := email.New(ID, "", " "+ID, subject, from, to, to, "", body, htmlBody, nil)
data := email.Compose(b.getBotSettings().DKIMPrivateKey()) data := eml.Compose(b.getBotSettings().DKIMPrivateKey())
if data == "" { if data == "" {
b.SendError(ctx, evt.RoomID, "email body is empty") b.SendError(ctx, evt.RoomID, "email body is empty")
return return
@@ -437,14 +447,14 @@ func (b *Bot) runSend(ctx context.Context) {
queued, err := b.Sendmail(evt.ID, from, to, data) queued, err := b.Sendmail(evt.ID, from, to, data)
if queued { if queued {
b.log.Error("cannot send email: %v", err) 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 continue
} }
if err != nil { if err != nil {
b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err) b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err)
continue continue
} }
b.saveSentMetadata(ctx, false, evt.ID, email, &cfg) b.saveSentMetadata(ctx, false, evt.ID, eml, &cfg)
} }
if len(tos) > 1 { if len(tos) > 1 {
b.SendNotice(ctx, evt.RoomID, "All emails were sent.") b.SendNotice(ctx, evt.RoomID, "All emails were sent.")

View File

@@ -9,6 +9,7 @@ import (
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils" "gitlab.com/etke.cc/postmoogle/utils"
) )
@@ -25,8 +26,10 @@ const (
eventReferencesKey = "cc.etke.postmoogle.references" eventReferencesKey = "cc.etke.postmoogle.references"
eventInReplyToKey = "cc.etke.postmoogle.inReplyTo" eventInReplyToKey = "cc.etke.postmoogle.inReplyTo"
eventSubjectKey = "cc.etke.postmoogle.subject" eventSubjectKey = "cc.etke.postmoogle.subject"
eventRcptToKey = "cc.etke.postmoogle.rcptTo"
eventFromKey = "cc.etke.postmoogle.from" eventFromKey = "cc.etke.postmoogle.from"
eventToKey = "cc.etke.postmoogle.to" eventToKey = "cc.etke.postmoogle.to"
eventCcKey = "cc.etke.postmoogle.cc"
) )
// SetSendmail sets mail sending func to the bot // 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) // 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) cfg, err := b.getRoomSettings(roomID)
if err != nil { if err != nil {
b.log.Error("cannot retrieve room settings: %v", err) 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 // 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)) roomID, ok := b.GetMapping(email.Mailbox(true))
if !ok { if !ok {
return errors.New("room not found") 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") b.Error(ctx, evt.RoomID, "mailbox is not configured, kupo")
return return
} }
domain := utils.SanitizeDomain(cfg.Domain())
b.lock(evt.RoomID.String()) b.lock(evt.RoomID.String())
defer b.unlock(evt.RoomID.String()) defer b.unlock(evt.RoomID.String())
fromMailbox := mailbox + "@" + domain meta := b.getParentEmail(evt, mailbox)
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
if meta.To == "" { if meta.To == "" {
b.Error(ctx, evt.RoomID, "cannot find parent email and continue the thread. Please, start a new email thread") 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 body := content.Body
htmlBody := content.FormattedBody htmlBody := content.FormattedBody
meta.MessageID = utils.MessageID(evt.ID, domain) meta.MessageID = email.MessageID(evt.ID, meta.FromDomain)
meta.References = meta.References + " " + meta.MessageID meta.References = meta.References + " " + meta.MessageID
b.log.Debug("send email reply: %+v", meta) 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) eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil)
data := email.Compose(b.getBotSettings().DKIMPrivateKey()) data := eml.Compose(b.getBotSettings().DKIMPrivateKey())
if data == "" { if data == "" {
b.SendError(ctx, evt.RoomID, "email body is empty") b.SendError(ctx, evt.RoomID, "email body is empty")
return return
@@ -188,7 +184,7 @@ func (b *Bot) SendEmailReply(ctx context.Context) {
queued, err := b.Sendmail(evt.ID, meta.From, meta.To, data) queued, err := b.Sendmail(evt.ID, meta.From, meta.To, data)
if queued { if queued {
b.log.Error("cannot send email: %v", err) 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 return
} }
@@ -197,19 +193,71 @@ func (b *Bot) SendEmailReply(ctx context.Context) {
return return
} }
b.saveSentMetadata(ctx, queued, meta.ThreadID, email, &cfg) b.saveSentMetadata(ctx, queued, meta.ThreadID, eml, &cfg)
} }
type parentEmail struct { type parentEmail struct {
MessageID string MessageID string
ThreadID id.EventID ThreadID id.EventID
From string From string
FromDomain string
To string To string
RcptTo string
CC string
InReplyTo string InReplyTo string
References string References string
Subject 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) { func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) {
content := evt.Content.AsMessage() content := evt.Content.AsMessage()
threadID := utils.EventParent(evt.ID, content) threadID := utils.EventParent(evt.ID, content)
@@ -246,8 +294,8 @@ func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) {
return threadID, decrypted return threadID, decrypted
} }
func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail { func (b *Bot) getParentEmail(evt *event.Event, newFromMailbox string) *parentEmail {
var parent parentEmail parent := &parentEmail{}
threadID, parentEvt := b.getParentEvent(evt) threadID, parentEvt := b.getParentEvent(evt)
parent.ThreadID = threadID parent.ThreadID = threadID
if parentEvt == nil { if parentEvt == nil {
@@ -257,11 +305,14 @@ func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail {
return parent return parent
} }
parent.MessageID = utils.MessageID(parentEvt.ID, domain)
parent.From = utils.EventField[string](&parentEvt.Content, eventFromKey) parent.From = utils.EventField[string](&parentEvt.Content, eventFromKey)
parent.To = utils.EventField[string](&parentEvt.Content, eventToKey) 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.InReplyTo = utils.EventField[string](&parentEvt.Content, eventMessageIDkey)
parent.References = utils.EventField[string](&parentEvt.Content, eventReferencesKey) parent.References = utils.EventField[string](&parentEvt.Content, eventReferencesKey)
parent.fixtofrom(newFromMailbox, b.domains)
parent.MessageID = email.MessageID(parentEvt.ID, parent.FromDomain)
if parent.InReplyTo == "" { if parent.InReplyTo == "" {
parent.InReplyTo = parent.MessageID 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 // 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 // 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) { func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, eml *email.Email, cfg *roomSettings) {
text := "Email has been sent to " + email.To text := "Email has been sent to " + eml.RcptTo
if queued { if queued {
text = "Email to " + email.To + " has been queued" text = "Email to " + eml.RcptTo + " has been queued"
} }
evt := eventFromContext(ctx) evt := eventFromContext(ctx)
content := email.Content(threadID, cfg.ContentOptions()) content := eml.Content(threadID, cfg.ContentOptions())
notice := format.RenderMarkdown(text, true, true) notice := format.RenderMarkdown(text, true, true)
msgContent, ok := content.Parsed.(*event.MessageEventContent) msgContent, ok := content.Parsed.(*event.MessageEventContent)
if !ok { if !ok {
@@ -305,8 +356,8 @@ func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.Eve
return return
} }
domain := utils.SanitizeDomain(cfg.Domain()) domain := utils.SanitizeDomain(cfg.Domain())
b.setThreadID(evt.RoomID, utils.MessageID(evt.ID, domain), threadID) b.setThreadID(evt.RoomID, email.MessageID(evt.ID, domain), threadID)
b.setThreadID(evt.RoomID, utils.MessageID(msgID, domain), threadID) b.setThreadID(evt.RoomID, email.MessageID(msgID, domain), threadID)
b.setLastEventID(evt.RoomID, threadID, msgID) b.setLastEventID(evt.RoomID, threadID, msgID)
} }

View File

@@ -5,6 +5,7 @@ import (
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils" "gitlab.com/etke.cc/postmoogle/utils"
) )
@@ -17,6 +18,7 @@ const (
roomOptionMailbox = "mailbox" roomOptionMailbox = "mailbox"
roomOptionDomain = "domain" roomOptionDomain = "domain"
roomOptionNoSend = "nosend" roomOptionNoSend = "nosend"
roomOptionNoCC = "nocc"
roomOptionNoSender = "nosender" roomOptionNoSender = "nosender"
roomOptionNoRecipient = "norecipient" roomOptionNoRecipient = "norecipient"
roomOptionNoSubject = "nosubject" roomOptionNoSubject = "nosubject"
@@ -61,6 +63,10 @@ func (s roomSettings) NoSend() bool {
return utils.Bool(s.Get(roomOptionNoSend)) return utils.Bool(s.Get(roomOptionNoSend))
} }
func (s roomSettings) NoCC() bool {
return utils.Bool(s.Get(roomOptionNoCC))
}
func (s roomSettings) NoSender() bool { func (s roomSettings) NoSender() bool {
return utils.Bool(s.Get(roomOptionNoSender)) return utils.Bool(s.Get(roomOptionNoSender))
} }
@@ -143,8 +149,9 @@ func (s roomSettings) migrateSpamlistSettings() {
} }
// ContentOptions converts room display settings to content options // ContentOptions converts room display settings to content options
func (s roomSettings) ContentOptions() *utils.ContentOptions { func (s roomSettings) ContentOptions() *email.ContentOptions {
return &utils.ContentOptions{ return &email.ContentOptions{
CC: !s.NoCC(),
HTML: !s.NoHTML(), HTML: !s.NoHTML(),
Sender: !s.NoSender(), Sender: !s.NoSender(),
Recipient: !s.NoRecipient(), Recipient: !s.NoRecipient(),
@@ -152,7 +159,9 @@ func (s roomSettings) ContentOptions() *utils.ContentOptions {
Threads: !s.NoThreads(), Threads: !s.NoThreads(),
ToKey: eventToKey, ToKey: eventToKey,
CcKey: eventCcKey,
FromKey: eventFromKey, FromKey: eventFromKey,
RcptToKey: eventRcptToKey,
SubjectKey: eventSubjectKey, SubjectKey: eventSubjectKey,
MessageIDKey: eventMessageIDkey, MessageIDKey: eventMessageIDkey,
InReplyToKey: eventInReplyToKey, InReplyToKey: eventInReplyToKey,

View File

@@ -1,31 +1,20 @@
package utils package email
import ( import (
"crypto" "crypto"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"fmt"
"net/mail"
"regexp"
"strings" "strings"
"time"
"github.com/emersion/go-msgauth/dkim" "github.com/emersion/go-msgauth/dkim"
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format" "maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
) )
var styleRegex = regexp.MustCompile("<style((.|\n|\r)*?)<\\/style>")
// IncomingFilteringOptions for incoming mail
type IncomingFilteringOptions interface {
SpamcheckSMTP() bool
SpamcheckMX() bool
Spamlist() []string
}
// Email object // Email object
type Email struct { type Email struct {
Date string Date string
@@ -34,50 +23,25 @@ type Email struct {
References string References string
From string From string
To string To string
RcptTo string
CC string
Subject string Subject string
Text string Text string
HTML string HTML string
Files []*File Files []*utils.File
} }
// ContentOptions represents settings that specify how an email is to be converted to a Matrix message // New constructs Email object
type ContentOptions struct { func New(messageID, inReplyTo, references, subject, from, to, rcptto, cc, text, html string, files []*utils.File) *Email {
// 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 {
email := &Email{ email := &Email{
Date: time.Now().UTC().Format(time.RFC1123Z), Date: dateNow(),
MessageID: messageID, MessageID: messageID,
InReplyTo: inReplyTo, InReplyTo: inReplyTo,
References: references, References: references,
From: from, From: from,
To: to, To: to,
CC: cc,
RcptTo: rcptto,
Subject: subject, Subject: subject,
Text: text, Text: text,
HTML: html, HTML: html,
@@ -92,12 +56,46 @@ func NewEmail(messageID, inReplyTo, references, subject, from, to, text, html st
return email 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) // Mailbox returns postmoogle's mailbox, parsing it from FROM (if incoming=false) or TO (incoming=true)
func (e *Email) Mailbox(incoming bool) string { func (e *Email) Mailbox(incoming bool) string {
if incoming { 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 // 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(" ➡️ ")
text.WriteString(e.To) 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") text.WriteString("\n\n")
} }
if options.Subject && threadID == "" { 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 := format.RenderMarkdown(text.String(), true, true)
parsed.RelatesTo = RelatesTo(options.Threads, threadID) parsed.RelatesTo = utils.RelatesTo(options.Threads, threadID)
content := event.Content{ content := event.Content{
Raw: map[string]interface{}{ Raw: map[string]interface{}{
@@ -133,8 +135,10 @@ func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Con
options.InReplyToKey: e.InReplyTo, options.InReplyToKey: e.InReplyTo,
options.ReferencesKey: e.References, options.ReferencesKey: e.References,
options.SubjectKey: e.Subject, options.SubjectKey: e.Subject,
options.RcptToKey: e.RcptTo,
options.FromKey: e.From, options.FromKey: e.From,
options.ToKey: e.To, options.ToKey: e.To,
options.CcKey: e.CC,
}, },
Parsed: &parsed, Parsed: &parsed,
} }
@@ -169,13 +173,11 @@ func (e *Email) Compose(privkey string) string {
root, err := mail.Build() root, err := mail.Build()
if err != nil { if err != nil {
log.Error("cannot compose email: %v", err)
return "" return ""
} }
var data strings.Builder var data strings.Builder
err = root.Encode(&data) err = root.Encode(&data)
if err != nil { if err != nil {
log.Error("cannot encode email: %v", err)
return "" return ""
} }

29
email/options.go Normal file
View File

@@ -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
}

33
email/utils.go Normal file
View File

@@ -0,0 +1,33 @@
package email
import (
"fmt"
"net/mail"
"regexp"
"time"
"maunium.net/go/mautrix/id"
)
var styleRegex = regexp.MustCompile("<style((.|\n|\r)*?)<\\/style>")
// 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)
}

2
go.mod
View File

@@ -22,7 +22,6 @@ require (
gitlab.com/etke.cc/go/trysmtp v1.0.0 gitlab.com/etke.cc/go/trysmtp v1.0.0
gitlab.com/etke.cc/go/validator v1.0.4 gitlab.com/etke.cc/go/validator v1.0.4
gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6 gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6
golang.org/x/net v0.2.0
maunium.net/go/mautrix v0.12.3 maunium.net/go/mautrix v0.12.3
) )
@@ -49,6 +48,7 @@ require (
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.5.3 // indirect github.com/yuin/goldmark v1.5.3 // indirect
golang.org/x/crypto v0.3.0 // 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/sys v0.2.0 // indirect
golang.org/x/text v0.4.0 // indirect golang.org/x/text v0.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -11,7 +11,7 @@ import (
"gitlab.com/etke.cc/go/logger" "gitlab.com/etke.cc/go/logger"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils" "gitlab.com/etke.cc/postmoogle/email"
) )
type Config struct { type Config struct {
@@ -45,8 +45,8 @@ type matrixbot interface {
IsBanned(net.Addr) bool IsBanned(net.Addr) bool
Ban(net.Addr) Ban(net.Addr)
GetMapping(string) (id.RoomID, bool) GetMapping(string) (id.RoomID, bool)
GetIFOptions(id.RoomID) utils.IncomingFilteringOptions GetIFOptions(id.RoomID) email.IncomingFilteringOptions
IncomingEmail(context.Context, *utils.Email) error IncomingEmail(context.Context, *email.Email) error
SetSendmail(func(string, string, string) error) SetSendmail(func(string, string, string) error)
GetDKIMprivkey() string GetDKIMprivkey() string
} }

View File

@@ -10,7 +10,7 @@ import (
"gitlab.com/etke.cc/go/logger" "gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/trysmtp" "gitlab.com/etke.cc/go/trysmtp"
"gitlab.com/etke.cc/postmoogle/utils" "gitlab.com/etke.cc/postmoogle/email"
) )
var ( var (
@@ -41,7 +41,7 @@ func (m *mailServer) Login(state *smtp.ConnectionState, username, password strin
return nil, ErrBanned return nil, ErrBanned
} }
if !utils.AddressValid(username) { if !email.AddressValid(username) {
m.log.Debug("address %s is invalid", username) m.log.Debug("address %s is invalid", username)
m.bot.Ban(state.RemoteAddr) m.bot.Ban(state.RemoteAddr)
return nil, ErrBanned return nil, ErrBanned
@@ -60,6 +60,7 @@ func (m *mailServer) Login(state *smtp.ConnectionState, username, password strin
from: username, from: username,
log: m.log, log: m.log,
domains: m.domains, domains: m.domains,
tos: []string{},
}, nil }, nil
} }
@@ -80,6 +81,7 @@ func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session,
log: m.log, log: m.log,
domains: m.domains, domains: m.domains,
addr: state.RemoteAddr, addr: state.RemoteAddr,
tos: []string{},
}, nil }, nil
} }
@@ -112,6 +114,6 @@ func (m *mailServer) SendEmail(from, to, data string) error {
} }
// ReceiveEmail - incoming mail into matrix room // ReceiveEmail - incoming mail into matrix room
func (m *mailServer) ReceiveEmail(ctx context.Context, email *utils.Email) error { func (m *mailServer) ReceiveEmail(ctx context.Context, eml *email.Email) error {
return m.bot.IncomingEmail(ctx, email) return m.bot.IncomingEmail(ctx, eml)
} }

View File

@@ -13,6 +13,7 @@ import (
"gitlab.com/etke.cc/go/validator" "gitlab.com/etke.cc/go/validator"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils" "gitlab.com/etke.cc/postmoogle/utils"
) )
@@ -20,21 +21,21 @@ import (
type incomingSession struct { type incomingSession struct {
log *logger.Logger log *logger.Logger
getRoomID func(string) (id.RoomID, bool) getRoomID func(string) (id.RoomID, bool)
getFilters func(id.RoomID) utils.IncomingFilteringOptions getFilters func(id.RoomID) email.IncomingFilteringOptions
receiveEmail func(context.Context, *utils.Email) error receiveEmail func(context.Context, *email.Email) error
greylisted func(net.Addr) bool greylisted func(net.Addr) bool
ban func(net.Addr) ban func(net.Addr)
domains []string domains []string
ctx context.Context ctx context.Context
addr net.Addr addr net.Addr
to string tos []string
from string from string
} }
func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error { func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from) 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.log.Debug("address %s is invalid", from)
s.ban(s.addr) s.ban(s.addr)
return ErrBanned return ErrBanned
@@ -46,7 +47,7 @@ func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error {
func (s *incomingSession) Rcpt(to string) error { func (s *incomingSession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to) sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.to = to s.tos = append(s.tos, to)
var domainok bool var domainok bool
for _, domain := range s.domains { for _, domain := range s.domains {
if utils.Hostname(to) == domain { if utils.Hostname(to) == domain {
@@ -66,7 +67,7 @@ func (s *incomingSession) Rcpt(to string) error {
} }
validations := s.getFilters(roomID) 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) s.ban(s.addr)
return ErrBanned return ErrBanned
} }
@@ -84,25 +85,20 @@ func (s *incomingSession) Data(r io.Reader) error {
} }
} }
parser := enmime.NewParser() parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r) envelope, err := parser.ReadEnvelope(r)
if err != nil { if err != nil {
return err return err
} }
files := parseAttachments(eml.Attachments, s.log) eml := email.FromEnvelope(s.tos[0], envelope)
for _, to := range s.tos {
email := utils.NewEmail( eml.RcptTo = to
eml.GetHeader("Message-Id"), err := s.receiveEmail(s.ctx, eml)
eml.GetHeader("In-Reply-To"), if err != nil {
eml.GetHeader("References"), return err
eml.GetHeader("Subject"), }
s.from, }
s.to, return nil
eml.Text,
eml.HTML,
files)
return s.receiveEmail(s.ctx, email)
} }
func (s *incomingSession) Reset() {} func (s *incomingSession) Reset() {}
func (s *incomingSession) Logout() error { return nil } func (s *incomingSession) Logout() error { return nil }
@@ -115,13 +111,13 @@ type outgoingSession struct {
domains []string domains []string
ctx context.Context ctx context.Context
to string tos []string
from string from string
} }
func (s *outgoingSession) Mail(from string, opts smtp.MailOptions) error { func (s *outgoingSession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from) sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
if !utils.AddressValid(from) { if !email.AddressValid(from) {
return errors.New("please, provide email address") return errors.New("please, provide email address")
} }
return nil return nil
@@ -129,7 +125,7 @@ func (s *outgoingSession) Mail(from string, opts smtp.MailOptions) error {
func (s *outgoingSession) Rcpt(to string) error { func (s *outgoingSession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to) sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.to = to s.tos = append(s.tos, to)
s.log.Debug("mail to %s", to) s.log.Debug("mail to %s", to)
return nil return nil
@@ -137,30 +133,25 @@ func (s *outgoingSession) Rcpt(to string) error {
func (s *outgoingSession) Data(r io.Reader) error { func (s *outgoingSession) Data(r io.Reader) error {
parser := enmime.NewParser() parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r) envelope, err := parser.ReadEnvelope(r)
if err != nil { if err != nil {
return err 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) return nil
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))
} }
func (s *outgoingSession) Reset() {} func (s *outgoingSession) Reset() {}
func (s *outgoingSession) Logout() error { return nil } 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{ enforce := validator.Enforce{
Email: true, Email: true,
MX: options.SpamcheckMX(), MX: options.SpamcheckMX(),
@@ -170,16 +161,3 @@ func validateEmail(from, to string, log *logger.Logger, options utils.IncomingFi
return v.Email(from) 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
}

41
utils/mail.go Normal file
View File

@@ -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:]
}

View File

@@ -22,44 +22,6 @@ func SetDomains(slice []string) {
domains = slice 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 // SanitizeDomain checks that input domain is available for use
func SanitizeDomain(domain string) string { func SanitizeDomain(domain string) string {
domain = strings.TrimSpace(domain) domain = strings.TrimSpace(domain)