diff --git a/README.md b/README.md index f1bfee6..cfe0f65 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ so you can use it to send emails from your apps and scripts as well. - [x] Send a message to matrix room with special format to send a new email, even to multiple email addresses at once - [x] Reply to matrix thread sends reply into email thread - [x] Email signatures +- [x] Email autoreply / autoresponder for new email threads ## Configuration @@ -121,6 +122,7 @@ If you want to change them - check available options in the help message (`!pm h > The following section is visible to the mailbox owners only +* **`!pm autoreply`** - Get or set autoreply of the room (markdown supported) that will be sent on any new incoming email thread * **`!pm signature`** - Get or set signature of the room (markdown supported) * **`!pm nosend`** - Get or set `nosend` of the room (`true` - disable email sending; `false` - enable email sending) * **`!pm noreplies`** - Get or set `noreplies` of the room (`true` - ignore matrix replies; `false` - parse matrix replies) diff --git a/bot/command.go b/bot/command.go index 69c0367..3f9e321 100644 --- a/bot/command.go +++ b/bot/command.go @@ -99,6 +99,12 @@ func (b *Bot) initCommands() commandList { allowed: b.allowOwner, }, {allowed: b.allowOwner, description: "mailbox options"}, // delimiter + { + key: config.RoomAutoreply, + description: "Get or set autoreply of the room (markdown supported) that will be send for any new incoming email thread", + sanitizer: func(s string) string { return s }, + allowed: b.allowOwner, + }, { key: config.RoomSignature, description: "Get or set signature of the room (markdown supported)", diff --git a/bot/command_owner.go b/bot/command_owner.go index 0e2696b..d72fc26 100644 --- a/bot/command_owner.go +++ b/bot/command_owner.go @@ -119,7 +119,8 @@ func (b *Bot) setOption(ctx context.Context, name, value string) { } } - if name == config.RoomSignature { + if name == config.RoomAutoreply || + name == config.RoomSignature { value = strings.Join(b.parseCommand(evt.Content.AsMessage().Body, false)[1:], " ") } diff --git a/bot/config/room.go b/bot/config/room.go index 0531f28..f99a203 100644 --- a/bot/config/room.go +++ b/bot/config/room.go @@ -14,27 +14,31 @@ type Room map[string]string // option keys const ( - RoomActive = ".active" - RoomOwner = "owner" - RoomMailbox = "mailbox" - RoomDomain = "domain" - RoomNoSend = "nosend" - RoomNoReplies = "noreplies" - RoomNoCC = "nocc" - RoomNoSender = "nosender" - RoomNoRecipient = "norecipient" - RoomNoSubject = "nosubject" - RoomNoHTML = "nohtml" - RoomNoThreads = "nothreads" - RoomNoFiles = "nofiles" - RoomNoInlines = "noinlines" - RoomPassword = "password" - RoomSignature = "signature" + RoomActive = ".active" + RoomOwner = "owner" + RoomMailbox = "mailbox" + RoomDomain = "domain" + RoomPassword = "password" + RoomSignature = "signature" + RoomAutoreply = "autoreply" + + RoomNoCC = "nocc" + RoomNoFiles = "nofiles" + RoomNoHTML = "nohtml" + RoomNoInlines = "noinlines" + RoomNoRecipient = "norecipient" + RoomNoReplies = "noreplies" + RoomNoSend = "nosend" + RoomNoSender = "nosender" + RoomNoSubject = "nosubject" + RoomNoThreads = "nothreads" + RoomSpamcheckDKIM = "spamcheck:dkim" + RoomSpamcheckMX = "spamcheck:mx" RoomSpamcheckSMTP = "spamcheck:smtp" RoomSpamcheckSPF = "spamcheck:spf" - RoomSpamcheckMX = "spamcheck:mx" - RoomSpamlist = "spamlist" + + RoomSpamlist = "spamlist" ) // Get option @@ -71,6 +75,10 @@ func (s Room) Signature() string { return s.Get(RoomSignature) } +func (s Room) Autoreply() string { + return s.Get(RoomAutoreply) +} + func (s Room) NoSend() bool { return utils.Bool(s.Get(RoomNoSend)) } diff --git a/bot/email.go b/bot/email.go index 3401767..b5798f9 100644 --- a/bot/email.go +++ b/bot/email.go @@ -111,6 +111,8 @@ func (b *Bot) GetIFOptions(roomID id.RoomID) email.IncomingFilteringOptions { } // IncomingEmail sends incoming email to matrix room +// +//nolint:gocognit // TODO func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error { roomID, ok := b.GetMapping(email.Mailbox(true)) if !ok { @@ -125,9 +127,11 @@ func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error { defer b.mu.Unlock(roomID.String()) var threadID id.EventID + newThread := true if email.InReplyTo != "" || email.References != "" { threadID = b.getThreadID(roomID, email.InReplyTo, email.References) if threadID != "" { + newThread = false b.setThreadID(roomID, email.MessageID, threadID) } } @@ -138,6 +142,7 @@ func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error { return utils.UnwrapError(serr) } threadID = "" // unknown event edge case - remove existing thread ID to avoid complications + newThread = true } if threadID == "" { threadID = eventID @@ -154,18 +159,103 @@ func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error { b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID) } + if newThread && cfg.Autoreply() != "" { + b.sendAutoreply(roomID, threadID) + } + return nil } +//nolint:gocognit // TODO +func (b *Bot) sendAutoreply(roomID id.RoomID, threadID id.EventID) { + cfg, err := b.cfg.GetRoom(roomID) + if err != nil { + return + } + + text := cfg.Autoreply() + if text == "" { + return + } + + evt := &event.Event{ + ID: threadID + "-autoreply", + RoomID: roomID, + Content: event.Content{ + Parsed: &event.MessageEventContent{ + RelatesTo: &event.RelatesTo{ + Type: event.RelThread, + EventID: threadID, + }, + }, + }, + } + + meta := b.getParentEmail(evt, cfg.Mailbox()) + + if meta.To == "" { + return + } + + if meta.ThreadID == "" { + meta.ThreadID = threadID + } + if meta.Subject == "" { + meta.Subject = "Automatic response" + } + content := format.RenderMarkdown(text, true, true) + signature := format.RenderMarkdown(cfg.Signature(), true, true) + body := content.Body + if signature.Body != "" { + body += "\n\n---\n" + signature.Body + } + var htmlBody string + if !cfg.NoHTML() { + htmlBody = content.FormattedBody + if htmlBody != "" && signature.FormattedBody != "" { + htmlBody += "


" + signature.FormattedBody + } + } + + meta.MessageID = email.MessageID(evt.ID, meta.FromDomain) + meta.References = meta.References + " " + meta.MessageID + b.log.Info().Any("meta", meta).Msg("sending automatic reply") + eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil, nil) + data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey()) + if data == "" { + return + } + + var queued bool + ctx := newContext(evt) + recipients := meta.Recipients + for _, to := range recipients { + queued, err = b.Sendmail(evt.ID, meta.From, to, data) + if queued { + b.log.Info().Err(err).Str("from", meta.From).Str("to", to).Msg("email has been queued") + b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg, "Autoreply has been sent (queued)") + continue + } + + if err != nil { + b.Error(ctx, evt.RoomID, "cannot send email: %v", err) + continue + } + } + + b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg, "Autoreply has been sent") +} + +func (b *Bot) canReply(sender id.UserID, roomID id.RoomID) bool { + return b.allowSend(sender, roomID) && b.allowReply(sender, roomID) +} + // SendEmailReply sends replies from matrix thread to email thread // //nolint:gocognit // TODO func (b *Bot) SendEmailReply(ctx context.Context) { evt := eventFromContext(ctx) - if !b.allowSend(evt.Sender, evt.RoomID) { - return - } - if !b.allowReply(evt.Sender, evt.RoomID) { + if !b.canReply(evt.Sender, evt.RoomID) { return } cfg, err := b.cfg.GetRoom(evt.RoomID) @@ -399,12 +489,15 @@ func (b *Bot) getParentEmail(evt *event.Event, newFromMailbox string) *parentEma // 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, recipients []string, eml *email.Email, cfg config.Room) { +func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, recipients []string, eml *email.Email, cfg config.Room, textOverride ...string) { addrs := strings.Join(recipients, ", ") text := "Email has been sent to " + addrs if queued { text = "Email to " + addrs + " has been queued" } + if len(textOverride) > 0 { + text = textOverride[0] + } evt := eventFromContext(ctx) content := eml.Content(threadID, cfg.ContentOptions())