send emails
This commit is contained in:
70
README.md
70
README.md
@@ -20,12 +20,14 @@ It can't be used with arbitrary email providers, but setup your own provider "wi
|
|||||||
|
|
||||||
### Send
|
### Send
|
||||||
|
|
||||||
- [ ] SMTP client
|
- [x] SMTP client
|
||||||
|
- [x] Send a message to matrix room with special format to send a new email
|
||||||
- [ ] Reply to matrix thread sends reply into email thread
|
- [ ] Reply to matrix thread sends reply into email thread
|
||||||
- [ ] Send a message to matrix room with special format to send a new email
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
### 1. Bot (mandatory)
|
||||||
|
|
||||||
env vars
|
env vars
|
||||||
|
|
||||||
* **POSTMOOGLE_HOMESERVER** - homeserver url, eg: `https://matrix.example.com`
|
* **POSTMOOGLE_HOMESERVER** - homeserver url, eg: `https://matrix.example.com`
|
||||||
@@ -50,6 +52,70 @@ You can find default values in [config/defaults.go](config/defaults.go)
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### 2. DNS (optional)
|
||||||
|
|
||||||
|
The following configuration needed only if you want to send emails using postmoogle
|
||||||
|
|
||||||
|
First, add new DMARC DNS record of `TXT` type for subdomain `_dmarc` with a proper policy, the easiest one is: `v=DMARC1; p=quarantine;`.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Example</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ dig txt _dmarc.DOMAIN
|
||||||
|
|
||||||
|
; <<>> DiG 9.18.6 <<>> txt _dmarc.DOMAIN
|
||||||
|
;; global options: +cmd
|
||||||
|
;; Got answer:
|
||||||
|
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 57306
|
||||||
|
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
|
||||||
|
|
||||||
|
;; OPT PSEUDOSECTION:
|
||||||
|
; EDNS: version: 0, flags:; udp: 1232
|
||||||
|
;; QUESTION SECTION:
|
||||||
|
;_dmarc.DOMAIN. IN TXT
|
||||||
|
|
||||||
|
;; ANSWER SECTION:
|
||||||
|
_dmarc.DOMAIN. 1799 IN TXT "v=DMARC1; p=quarantine;"
|
||||||
|
|
||||||
|
;; Query time: 46 msec
|
||||||
|
;; SERVER: 1.1.1.1#53(1.1.1.1) (UDP)
|
||||||
|
;; WHEN: Sun Sep 04 21:31:30 EEST 2022
|
||||||
|
;; MSG SIZE rcvd: 79
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
Second, add new SPF DNS record of `TXT` type for your domain that will be used with postmoogle, with format: `v=spf1 ip4:SERVER_IP -all`
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Example</summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ dig txt DOMAIN
|
||||||
|
|
||||||
|
; <<>> DiG 9.18.6 <<>> txt DOMAIN
|
||||||
|
;; global options: +cmd
|
||||||
|
;; Got answer:
|
||||||
|
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 24796
|
||||||
|
;; flags: qr rd ra; QUERY: 1, ANSWER: 4, AUTHORITY: 0, ADDITIONAL: 1
|
||||||
|
|
||||||
|
;; OPT PSEUDOSECTION:
|
||||||
|
; EDNS: version: 0, flags:; udp: 1232
|
||||||
|
;; QUESTION SECTION:
|
||||||
|
;DOMAIN. IN TXT
|
||||||
|
|
||||||
|
;; ANSWER SECTION:
|
||||||
|
DOMAIN. 1799 IN TXT "v=spf1 ip4:111.111.111.111 -all"
|
||||||
|
|
||||||
|
;; Query time: 36 msec
|
||||||
|
;; SERVER: 1.1.1.1#53(1.1.1.1) (UDP)
|
||||||
|
;; WHEN: Sun Sep 04 21:35:04 EEST 2022
|
||||||
|
;; MSG SIZE rcvd: 255
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### How to start
|
### How to start
|
||||||
|
|||||||
@@ -17,17 +17,24 @@ func parseMXIDpatterns(patterns []string, defaultPattern string) ([]*regexp.Rege
|
|||||||
return utils.WildcardMXIDsToRegexes(patterns)
|
return utils.WildcardMXIDsToRegexes(patterns)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) allowAnyone(actorID id.UserID, targetRoomID id.RoomID) bool {
|
func (b *Bot) allowUsers(actorID id.UserID) bool {
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
|
|
||||||
if len(b.allowedUsers) != 0 {
|
if len(b.allowedUsers) != 0 {
|
||||||
if !utils.Match(actorID.String(), b.allowedUsers) {
|
if !utils.Match(actorID.String(), b.allowedUsers) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) allowAnyone(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||||
|
if !b.allowUsers(actorID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
cfg, err := b.getRoomSettings(targetRoomID)
|
cfg, err := b.getRoomSettings(targetRoomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Error(context.Background(), targetRoomID, "failed to retrieve settings: %v", err)
|
b.Error(context.Background(), targetRoomID, "failed to retrieve settings: %v", err)
|
||||||
@@ -45,3 +52,17 @@ func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
|
|||||||
func (b *Bot) allowAdmin(actorID id.UserID, targetRoomID id.RoomID) bool {
|
func (b *Bot) allowAdmin(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||||
return utils.Match(actorID.String(), b.allowedAdmins)
|
return utils.Match(actorID.String(), b.allowedAdmins)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||||
|
if !b.allowUsers(actorID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := b.getRoomSettings(targetRoomID)
|
||||||
|
if err != nil {
|
||||||
|
b.Error(context.Background(), targetRoomID, "failed to retrieve settings: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !cfg.NoSend()
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bot represents matrix bot
|
// Bot represents matrix bot
|
||||||
@@ -25,6 +27,7 @@ type Bot struct {
|
|||||||
rooms sync.Map
|
rooms sync.Map
|
||||||
botcfg cache.Cache[botSettings]
|
botcfg cache.Cache[botSettings]
|
||||||
cfg cache.Cache[roomSettings]
|
cfg cache.Cache[roomSettings]
|
||||||
|
mta utils.MTA
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
lp *linkpearl.Linkpearl
|
lp *linkpearl.Linkpearl
|
||||||
mu map[id.RoomID]*sync.Mutex
|
mu map[id.RoomID]*sync.Mutex
|
||||||
@@ -77,7 +80,7 @@ func (b *Bot) Error(ctx context.Context, roomID id.RoomID, message string, args
|
|||||||
|
|
||||||
sentry.GetHubFromContext(ctx).CaptureException(err)
|
sentry.GetHubFromContext(ctx).CaptureException(err)
|
||||||
if roomID != "" {
|
if roomID != "" {
|
||||||
b.SendError(ctx, roomID, message)
|
b.SendError(ctx, roomID, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
commandHelp = "help"
|
commandHelp = "help"
|
||||||
commandStop = "stop"
|
commandStop = "stop"
|
||||||
|
commandSend = "send"
|
||||||
commandUsers = botOptionUsers
|
commandUsers = botOptionUsers
|
||||||
commandDelete = "delete"
|
commandDelete = "delete"
|
||||||
commandMailboxes = "mailboxes"
|
commandMailboxes = "mailboxes"
|
||||||
@@ -51,6 +52,11 @@ func (b *Bot) initCommands() commandList {
|
|||||||
description: "Disable bridge for the room and clear all configuration",
|
description: "Disable bridge for the room and clear all configuration",
|
||||||
allowed: b.allowOwner,
|
allowed: b.allowOwner,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: commandSend,
|
||||||
|
description: "Send email",
|
||||||
|
allowed: b.allowSend,
|
||||||
|
},
|
||||||
{allowed: b.allowOwner}, // delimiter
|
{allowed: b.allowOwner}, // delimiter
|
||||||
// options commands
|
// options commands
|
||||||
{
|
{
|
||||||
@@ -66,6 +72,15 @@ func (b *Bot) initCommands() commandList {
|
|||||||
allowed: b.allowOwner,
|
allowed: b.allowOwner,
|
||||||
},
|
},
|
||||||
{allowed: b.allowOwner}, // delimiter
|
{allowed: b.allowOwner}, // delimiter
|
||||||
|
{
|
||||||
|
key: roomOptionNoSend,
|
||||||
|
description: fmt.Sprintf(
|
||||||
|
"Get or set `%s` of the room (`true` - enable email sending; `false` - disable email sending)",
|
||||||
|
roomOptionNoSend,
|
||||||
|
),
|
||||||
|
sanitizer: utils.SanitizeBoolString,
|
||||||
|
allowed: b.allowOwner,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: roomOptionNoSender,
|
key: roomOptionNoSender,
|
||||||
description: fmt.Sprintf(
|
description: fmt.Sprintf(
|
||||||
@@ -146,6 +161,8 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
|
|||||||
b.sendHelp(ctx)
|
b.sendHelp(ctx)
|
||||||
case commandStop:
|
case commandStop:
|
||||||
b.runStop(ctx)
|
b.runStop(ctx)
|
||||||
|
case commandSend:
|
||||||
|
b.runSend(ctx, commandSlice)
|
||||||
case commandUsers:
|
case commandUsers:
|
||||||
b.runUsers(ctx, commandSlice)
|
b.runUsers(ctx, commandSlice)
|
||||||
case commandDelete:
|
case commandDelete:
|
||||||
@@ -237,3 +254,30 @@ func (b *Bot) sendHelp(ctx context.Context) {
|
|||||||
|
|
||||||
b.SendNotice(ctx, evt.RoomID, msg.String())
|
b.SendNotice(ctx, evt.RoomID, msg.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bot) runSend(ctx context.Context, commandSlice []string) {
|
||||||
|
evt := eventFromContext(ctx)
|
||||||
|
if !b.allowSend(evt.Sender, evt.RoomID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(commandSlice) < 3 {
|
||||||
|
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Usage:\n```\n%s send EMAIL\nSubject\nBody\n```", b.prefix))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message := strings.Join(commandSlice, " ")
|
||||||
|
lines := strings.Split(message, "\n")
|
||||||
|
commandSlice = strings.Split(lines[0], " ")
|
||||||
|
to := commandSlice[1]
|
||||||
|
subject := lines[1]
|
||||||
|
body := strings.Join(lines[2:], "\n")
|
||||||
|
|
||||||
|
b.log.Debug("to=%s subject=%s body=%s", to, subject, body)
|
||||||
|
err := b.Send2Email(ctx, to, subject, body)
|
||||||
|
if err != nil {
|
||||||
|
b.Error(ctx, evt.RoomID, "cannot send email: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.SendNotice(ctx, evt.RoomID, "Email has been sent")
|
||||||
|
}
|
||||||
|
|||||||
166
bot/email.go
166
bot/email.go
@@ -3,7 +3,9 @@ package bot
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"maunium.net/go/mautrix/event"
|
"maunium.net/go/mautrix/event"
|
||||||
"maunium.net/go/mautrix/format"
|
"maunium.net/go/mautrix/format"
|
||||||
@@ -12,13 +14,18 @@ import (
|
|||||||
"gitlab.com/etke.cc/postmoogle/utils"
|
"gitlab.com/etke.cc/postmoogle/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// account data key
|
// account data keys
|
||||||
const acMessagePrefix = "cc.etke.postmoogle.message"
|
const (
|
||||||
|
acMessagePrefix = "cc.etke.postmoogle.message"
|
||||||
|
acLastEventPrefix = "cc.etke.postmoogle.last"
|
||||||
|
)
|
||||||
|
|
||||||
// event keys
|
// event keys
|
||||||
const (
|
const (
|
||||||
eventMessageIDkey = "cc.etke.postmoogle.messageID"
|
eventMessageIDkey = "cc.etke.postmoogle.messageID"
|
||||||
eventInReplyToKey = "cc.etke.postmoogle.inReplyTo"
|
eventInReplyToKey = "cc.etke.postmoogle.inReplyTo"
|
||||||
|
eventSubjectKey = "cc.etke.postmoogle.subject"
|
||||||
|
eventFromKey = "cc.etke.postmoogle.from"
|
||||||
)
|
)
|
||||||
|
|
||||||
func email2content(email *utils.Email, cfg roomSettings, threadID id.EventID) *event.Content {
|
func email2content(email *utils.Email, cfg roomSettings, threadID id.EventID) *event.Content {
|
||||||
@@ -46,12 +53,19 @@ func email2content(email *utils.Email, cfg roomSettings, threadID id.EventID) *e
|
|||||||
Raw: map[string]interface{}{
|
Raw: map[string]interface{}{
|
||||||
eventMessageIDkey: email.MessageID,
|
eventMessageIDkey: email.MessageID,
|
||||||
eventInReplyToKey: email.InReplyTo,
|
eventInReplyToKey: email.InReplyTo,
|
||||||
|
eventSubjectKey: email.Subject,
|
||||||
|
eventFromKey: email.From,
|
||||||
},
|
},
|
||||||
Parsed: parsed,
|
Parsed: parsed,
|
||||||
}
|
}
|
||||||
return &content
|
return &content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSMTPAuth sets dynamic login and password to auth against built-in smtp server
|
||||||
|
func (b *Bot) SetMTA(mta utils.MTA) {
|
||||||
|
b.mta = mta
|
||||||
|
}
|
||||||
|
|
||||||
// GetMapping returns mapping of mailbox = room
|
// GetMapping returns mapping of mailbox = room
|
||||||
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
|
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
|
||||||
v, ok := b.rooms.Load(mailbox)
|
v, ok := b.rooms.Load(mailbox)
|
||||||
@@ -67,7 +81,7 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send email to matrix room
|
// Send email to matrix room
|
||||||
func (b *Bot) Send(ctx context.Context, email *utils.Email) error {
|
func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email) error {
|
||||||
roomID, ok := b.GetMapping(utils.Mailbox(email.To))
|
roomID, ok := b.GetMapping(utils.Mailbox(email.To))
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("room not found")
|
return errors.New("room not found")
|
||||||
@@ -98,6 +112,7 @@ func (b *Bot) Send(ctx context.Context, email *utils.Email) error {
|
|||||||
b.setThreadID(roomID, email.MessageID, eventID)
|
b.setThreadID(roomID, email.MessageID, eventID)
|
||||||
threadID = eventID
|
threadID = eventID
|
||||||
}
|
}
|
||||||
|
b.setLastEventID(roomID, threadID, eventID)
|
||||||
|
|
||||||
if !cfg.NoFiles() {
|
if !cfg.NoFiles() {
|
||||||
b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID)
|
b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID)
|
||||||
@@ -105,6 +120,123 @@ func (b *Bot) Send(ctx context.Context, email *utils.Email) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bot) getBody(content *event.MessageEventContent) string {
|
||||||
|
if content.FormattedBody != "" {
|
||||||
|
return content.FormattedBody
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.Body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) getSubject(content *event.MessageEventContent) string {
|
||||||
|
if content.Body == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.SplitN(content.Body, "\n", 1)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) getParentEmail(evt *event.Event) (string, string, string) {
|
||||||
|
content := evt.Content.AsMessage()
|
||||||
|
parentID := utils.EventParent(evt.ID, content)
|
||||||
|
if parentID == evt.ID {
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
parentID = b.getLastEventID(evt.RoomID, parentID)
|
||||||
|
parentEvt, err := b.lp.GetClient().GetEvent(evt.RoomID, parentID)
|
||||||
|
if err != nil {
|
||||||
|
b.log.Error("cannot get parent event: %v", err)
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
if parentEvt.Content.Parsed == nil {
|
||||||
|
perr := parentEvt.Content.ParseRaw(event.EventMessage)
|
||||||
|
if perr != nil {
|
||||||
|
b.log.Error("cannot parse event content: %v", perr)
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
to := utils.EventField[string](&parentEvt.Content, eventFromKey)
|
||||||
|
inReplyTo := utils.EventField[string](&parentEvt.Content, eventMessageIDkey)
|
||||||
|
if inReplyTo == "" {
|
||||||
|
inReplyTo = parentID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := utils.EventField[string](&parentEvt.Content, eventSubjectKey)
|
||||||
|
if subject != "" {
|
||||||
|
subject = "Re: " + subject
|
||||||
|
} else {
|
||||||
|
subject = strings.SplitN(content.Body, "\n", 1)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return to, inReplyTo, subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send2Email sends message to email
|
||||||
|
func (b *Bot) Send2Email(ctx context.Context, to, subject, body string) error {
|
||||||
|
var inReplyTo string
|
||||||
|
evt := eventFromContext(ctx)
|
||||||
|
cfg, err := b.getRoomSettings(evt.RoomID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mailbox := cfg.Mailbox()
|
||||||
|
if mailbox == "" {
|
||||||
|
return fmt.Errorf("mailbox not configured, kupo")
|
||||||
|
}
|
||||||
|
from := mailbox + "@" + b.domain
|
||||||
|
pTo, pInReplyTo, pSubject := b.getParentEmail(evt)
|
||||||
|
inReplyTo = pInReplyTo
|
||||||
|
if pTo != "" && to == "" {
|
||||||
|
to = pTo
|
||||||
|
}
|
||||||
|
if pSubject != "" && subject == "" {
|
||||||
|
subject = pSubject
|
||||||
|
}
|
||||||
|
|
||||||
|
content := evt.Content.AsMessage()
|
||||||
|
if subject == "" {
|
||||||
|
subject = b.getSubject(content)
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
body = b.getBody(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg strings.Builder
|
||||||
|
msg.WriteString("From: ")
|
||||||
|
msg.WriteString(from)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
msg.WriteString("To: ")
|
||||||
|
msg.WriteString(to)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
msg.WriteString("Message-Id: ")
|
||||||
|
msg.WriteString(evt.ID.String()[1:] + "@" + b.domain)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
msg.WriteString("Date: ")
|
||||||
|
msg.WriteString(time.Now().UTC().Format(time.RFC1123Z))
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
if inReplyTo != "" {
|
||||||
|
msg.WriteString("In-Reply-To: ")
|
||||||
|
msg.WriteString(inReplyTo)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.WriteString("Subject: ")
|
||||||
|
msg.WriteString(subject)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
msg.WriteString(body)
|
||||||
|
msg.WriteString("\r\n")
|
||||||
|
|
||||||
|
return b.mta.Send(from, to, msg.String())
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) {
|
func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) {
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
req := file.Convert()
|
req := file.Convert()
|
||||||
@@ -152,3 +284,31 @@ func (b *Bot) setThreadID(roomID id.RoomID, messageID string, eventID id.EventID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bot) getLastEventID(roomID id.RoomID, threadID id.EventID) id.EventID {
|
||||||
|
key := acLastEventPrefix + "." + threadID.String()
|
||||||
|
data := map[string]id.EventID{}
|
||||||
|
err := b.lp.GetClient().GetRoomAccountData(roomID, key, &data)
|
||||||
|
if err != nil {
|
||||||
|
if !strings.Contains(err.Error(), "M_NOT_FOUND") {
|
||||||
|
b.log.Error("cannot retrieve account data %s: %v", key, err)
|
||||||
|
return threadID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data["eventID"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) setLastEventID(roomID id.RoomID, threadID id.EventID, eventID id.EventID) {
|
||||||
|
key := acLastEventPrefix + "." + threadID.String()
|
||||||
|
data := map[string]id.EventID{
|
||||||
|
"eventID": eventID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := b.lp.GetClient().SetRoomAccountData(roomID, key, data)
|
||||||
|
if err != nil {
|
||||||
|
if !strings.Contains(err.Error(), "M_NOT_FOUND") {
|
||||||
|
b.log.Error("cannot save account data %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const acRoomSettingsKey = "cc.etke.postmoogle.settings"
|
|||||||
const (
|
const (
|
||||||
roomOptionOwner = "owner"
|
roomOptionOwner = "owner"
|
||||||
roomOptionMailbox = "mailbox"
|
roomOptionMailbox = "mailbox"
|
||||||
|
roomOptionNoSend = "nosend"
|
||||||
roomOptionNoSender = "nosender"
|
roomOptionNoSender = "nosender"
|
||||||
roomOptionNoSubject = "nosubject"
|
roomOptionNoSubject = "nosubject"
|
||||||
roomOptionNoHTML = "nohtml"
|
roomOptionNoHTML = "nohtml"
|
||||||
@@ -42,6 +43,10 @@ func (s roomSettings) Owner() string {
|
|||||||
return s.Get(roomOptionOwner)
|
return s.Get(roomOptionOwner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s roomSettings) NoSend() bool {
|
||||||
|
return utils.Bool(s.Get(roomOptionNoSend))
|
||||||
|
}
|
||||||
|
|
||||||
func (s roomSettings) NoSender() bool {
|
func (s roomSettings) NoSender() bool {
|
||||||
return utils.Bool(s.Get(roomOptionNoSender))
|
return utils.Bool(s.Get(roomOptionNoSender))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ import (
|
|||||||
"gitlab.com/etke.cc/go/logger"
|
"gitlab.com/etke.cc/go/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type backend struct {
|
// msa is mail submission agent
|
||||||
|
type msa struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
domain string
|
domain string
|
||||||
client Client
|
client Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) newSession() *session {
|
func (b *msa) newSession() *msasession {
|
||||||
return &session{
|
return &msasession{
|
||||||
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
|
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
|
||||||
log: b.log,
|
log: b.log,
|
||||||
domain: b.domain,
|
domain: b.domain,
|
||||||
@@ -25,28 +26,30 @@ func (b *backend) newSession() *session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
func (b *msa) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||||
return nil, smtp.ErrAuthUnsupported
|
return nil, smtp.ErrAuthUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
func (b *msa) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||||
return b.newSession(), nil
|
return b.newSession(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(domain, port, loglevel string, maxSize int, client Client) error {
|
func Start(domain, port, loglevel string, maxSize int, client Client) error {
|
||||||
log := logger.New("smtp.", loglevel)
|
log := logger.New("smtp.", loglevel)
|
||||||
be := &backend{
|
sender := NewMTA(loglevel)
|
||||||
|
receiver := &msa{
|
||||||
log: log,
|
log: log,
|
||||||
domain: domain,
|
domain: domain,
|
||||||
client: client,
|
client: client,
|
||||||
}
|
}
|
||||||
s := smtp.NewServer(be)
|
receiver.client.SetMTA(sender)
|
||||||
|
s := smtp.NewServer(receiver)
|
||||||
s.Addr = ":" + port
|
s.Addr = ":" + port
|
||||||
s.Domain = domain
|
s.Domain = domain
|
||||||
s.AuthDisabled = true
|
|
||||||
s.ReadTimeout = 10 * time.Second
|
s.ReadTimeout = 10 * time.Second
|
||||||
s.WriteTimeout = 10 * time.Second
|
s.WriteTimeout = 10 * time.Second
|
||||||
s.MaxMessageBytes = maxSize * 1024 * 1024
|
s.MaxMessageBytes = maxSize * 1024 * 1024
|
||||||
|
s.AllowInsecureAuth = true
|
||||||
if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
|
if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
|
||||||
s.Debug = os.Stdout
|
s.Debug = os.Stdout
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"gitlab.com/etke.cc/postmoogle/utils"
|
"gitlab.com/etke.cc/postmoogle/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type session struct {
|
type msasession struct {
|
||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
domain string
|
domain string
|
||||||
client Client
|
client Client
|
||||||
@@ -22,14 +22,14 @@ type session struct {
|
|||||||
from string
|
from string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *session) Mail(from string, opts smtp.MailOptions) error {
|
func (s *msasession) Mail(from string, opts smtp.MailOptions) error {
|
||||||
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
|
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
|
||||||
s.from = from
|
s.from = from
|
||||||
s.log.Debug("mail from %s, options: %+v", from, opts)
|
s.log.Debug("mail from %s, options: %+v", from, opts)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *session) Rcpt(to string) error {
|
func (s *msasession) Rcpt(to string) error {
|
||||||
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
|
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
|
||||||
|
|
||||||
if utils.Hostname(to) != s.domain {
|
if utils.Hostname(to) != s.domain {
|
||||||
@@ -48,7 +48,7 @@ func (s *session) Rcpt(to string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *session) parseAttachments(parts []*enmime.Part) []*utils.File {
|
func (s *msasession) parseAttachments(parts []*enmime.Part) []*utils.File {
|
||||||
files := make([]*utils.File, 0, len(parts))
|
files := make([]*utils.File, 0, len(parts))
|
||||||
for _, attachment := range parts {
|
for _, attachment := range parts {
|
||||||
for _, err := range attachment.Errors {
|
for _, err := range attachment.Errors {
|
||||||
@@ -61,7 +61,7 @@ func (s *session) parseAttachments(parts []*enmime.Part) []*utils.File {
|
|||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *session) Data(r io.Reader) error {
|
func (s *msasession) Data(r io.Reader) error {
|
||||||
parser := enmime.NewParser()
|
parser := enmime.NewParser()
|
||||||
eml, err := parser.ReadEnvelope(r)
|
eml, err := parser.ReadEnvelope(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,11 +84,11 @@ func (s *session) Data(r io.Reader) error {
|
|||||||
eml.HTML,
|
eml.HTML,
|
||||||
files)
|
files)
|
||||||
|
|
||||||
return s.client.Send(s.ctx, email)
|
return s.client.Send2Matrix(s.ctx, email)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *session) Reset() {}
|
func (s *msasession) Reset() {}
|
||||||
|
|
||||||
func (s *session) Logout() error {
|
func (s *msasession) Logout() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
116
smtp/mta.go
Normal file
116
smtp/mta.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitlab.com/etke.cc/go/logger"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"gitlab.com/etke.cc/postmoogle/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client interface to send emails into matrix
|
||||||
|
type Client interface {
|
||||||
|
GetMapping(string) (id.RoomID, bool)
|
||||||
|
Send2Matrix(ctx context.Context, email *utils.Email) error
|
||||||
|
SetMTA(mta utils.MTA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mta is Mail Transfer Agent
|
||||||
|
type mta struct {
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMTA(loglevel string) utils.MTA {
|
||||||
|
return &mta{
|
||||||
|
log: logger.New("smtp/mta.", loglevel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mta) Send(from, to, data string) error {
|
||||||
|
m.log.Debug("Sending email from %s to %s", from, to)
|
||||||
|
conn, err := m.connect(from, to)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Error("cannot connect to SMTP server of %s: %v", to, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
err = conn.Mail(from)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Error("cannot call MAIL command: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = conn.Rcpt(to)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Error("cannot send RCPT command: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var w io.WriteCloser
|
||||||
|
w, err = conn.Data()
|
||||||
|
if err != nil {
|
||||||
|
m.log.Error("cannot send DATA command: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
m.log.Debug("sending DATA:\n%s", data)
|
||||||
|
_, err = strings.NewReader(data).WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Debug("cannot write DATA: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.log.Debug("email has been sent")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mta) tryServer(localname, mxhost string) *smtp.Client {
|
||||||
|
m.log.Debug("trying SMTP connection to %s", mxhost)
|
||||||
|
conn, err := smtp.Dial(mxhost + ":smtp")
|
||||||
|
if err != nil {
|
||||||
|
m.log.Warn("cannot connect to the %s: %v", mxhost, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err = conn.Hello(localname)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Warn("cannot call HELLO command of the %s: %v", mxhost, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ok, _ := conn.Extension("STARTTLS"); ok {
|
||||||
|
m.log.Debug("%s supports STARTTLS", mxhost)
|
||||||
|
config := &tls.Config{ServerName: mxhost}
|
||||||
|
err = conn.StartTLS(config)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Warn("STARTTLS connection to the %s failed: %v", mxhost, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mta) connect(from, to string) (*smtp.Client, error) {
|
||||||
|
localname := strings.SplitN(from, "@", 2)[1]
|
||||||
|
hostname := strings.SplitN(to, "@", 2)[1]
|
||||||
|
|
||||||
|
m.log.Debug("performing MX lookup of %s", hostname)
|
||||||
|
mxs, err := net.LookupMX(hostname)
|
||||||
|
if err != nil {
|
||||||
|
m.log.Error("cannot perform MX lookup: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mx := range mxs {
|
||||||
|
client := m.tryServer(localname, mx.Host)
|
||||||
|
if client != nil {
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("target SMTP server not found")
|
||||||
|
}
|
||||||
15
smtp/smtp.go
15
smtp/smtp.go
@@ -1,15 +0,0 @@
|
|||||||
package smtp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"maunium.net/go/mautrix/id"
|
|
||||||
|
|
||||||
"gitlab.com/etke.cc/postmoogle/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client interface to send emails
|
|
||||||
type Client interface {
|
|
||||||
GetMapping(string) (id.RoomID, bool)
|
|
||||||
Send(ctx context.Context, email *utils.Email) error
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
|
// MTA is mail transfer agent
|
||||||
|
type MTA interface {
|
||||||
|
Send(from, to, data string) error
|
||||||
|
}
|
||||||
|
|
||||||
// Email object
|
// Email object
|
||||||
type Email struct {
|
type Email struct {
|
||||||
MessageID string
|
MessageID string
|
||||||
|
|||||||
@@ -26,6 +26,45 @@ func RelatesTo(noThreads bool, parentID id.EventID) *event.RelatesTo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventParent returns parent event - either thread ID or reply-to ID
|
||||||
|
func EventParent(currentID id.EventID, content *event.MessageEventContent) id.EventID {
|
||||||
|
if content == nil {
|
||||||
|
return currentID
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.GetRelatesTo() == nil {
|
||||||
|
return currentID
|
||||||
|
}
|
||||||
|
|
||||||
|
threadParent := content.RelatesTo.GetThreadParent()
|
||||||
|
if threadParent != "" {
|
||||||
|
return threadParent
|
||||||
|
}
|
||||||
|
|
||||||
|
replyParent := content.RelatesTo.GetReplyTo()
|
||||||
|
if replyParent != "" {
|
||||||
|
return replyParent
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventField returns field value from raw event content
|
||||||
|
func EventField[T comparable](content *event.Content, field string) T {
|
||||||
|
var zero T
|
||||||
|
raw := content.Raw[field]
|
||||||
|
if raw == nil {
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
|
||||||
|
v, ok := raw.(T)
|
||||||
|
if !ok {
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
// UnwrapError tries to unwrap a error into something meaningful, like mautrix.HTTPError or mautrix.RespError
|
// UnwrapError tries to unwrap a error into something meaningful, like mautrix.HTTPError or mautrix.RespError
|
||||||
func UnwrapError(err error) error {
|
func UnwrapError(err error) error {
|
||||||
switch err.(type) {
|
switch err.(type) {
|
||||||
|
|||||||
Reference in New Issue
Block a user