diff --git a/README.md b/README.md index c7ea409..2cc34a2 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,14 @@ It can't be used with arbitrary email providers, but setup your own provider "wi ### 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 -- [ ] Send a message to matrix room with special format to send a new email ## Configuration +### 1. Bot (mandatory) + env vars * **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) +### 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;`. + +
+Example + +```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 +``` + +
+ +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` + +
+Example + +```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 +``` + +
+ ## Usage ### How to start diff --git a/bot/access.go b/bot/access.go index bcb99fc..18291f6 100644 --- a/bot/access.go +++ b/bot/access.go @@ -17,17 +17,24 @@ func parseMXIDpatterns(patterns []string, defaultPattern string) ([]*regexp.Rege return utils.WildcardMXIDsToRegexes(patterns) } -func (b *Bot) allowAnyone(actorID id.UserID, targetRoomID id.RoomID) bool { - return true -} - -func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool { +func (b *Bot) allowUsers(actorID id.UserID) bool { if len(b.allowedUsers) != 0 { if !utils.Match(actorID.String(), b.allowedUsers) { 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) if err != nil { 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 { 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() +} diff --git a/bot/bot.go b/bot/bot.go index d3a695b..07d7b9b 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -13,6 +13,8 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" + + "gitlab.com/etke.cc/postmoogle/utils" ) // Bot represents matrix bot @@ -25,6 +27,7 @@ type Bot struct { rooms sync.Map botcfg cache.Cache[botSettings] cfg cache.Cache[roomSettings] + mta utils.MTA log *logger.Logger lp *linkpearl.Linkpearl 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) if roomID != "" { - b.SendError(ctx, roomID, message) + b.SendError(ctx, roomID, err.Error()) } } diff --git a/bot/command.go b/bot/command.go index ad8418c..892cee1 100644 --- a/bot/command.go +++ b/bot/command.go @@ -14,6 +14,7 @@ import ( const ( commandHelp = "help" commandStop = "stop" + commandSend = "send" commandUsers = botOptionUsers commandDelete = "delete" commandMailboxes = "mailboxes" @@ -51,6 +52,11 @@ func (b *Bot) initCommands() commandList { description: "Disable bridge for the room and clear all configuration", allowed: b.allowOwner, }, + { + key: commandSend, + description: "Send email", + allowed: b.allowSend, + }, {allowed: b.allowOwner}, // delimiter // options commands { @@ -66,6 +72,15 @@ func (b *Bot) initCommands() commandList { allowed: b.allowOwner, }, {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, description: fmt.Sprintf( @@ -146,6 +161,8 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice b.sendHelp(ctx) case commandStop: b.runStop(ctx) + case commandSend: + b.runSend(ctx, commandSlice) case commandUsers: b.runUsers(ctx, commandSlice) case commandDelete: @@ -237,3 +254,30 @@ func (b *Bot) sendHelp(ctx context.Context) { 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") +} diff --git a/bot/email.go b/bot/email.go index 2981136..2342199 100644 --- a/bot/email.go +++ b/bot/email.go @@ -3,7 +3,9 @@ package bot import ( "context" "errors" + "fmt" "strings" + "time" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" @@ -12,13 +14,18 @@ import ( "gitlab.com/etke.cc/postmoogle/utils" ) -// account data key -const acMessagePrefix = "cc.etke.postmoogle.message" +// account data keys +const ( + acMessagePrefix = "cc.etke.postmoogle.message" + acLastEventPrefix = "cc.etke.postmoogle.last" +) // event keys const ( eventMessageIDkey = "cc.etke.postmoogle.messageID" 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 { @@ -46,12 +53,19 @@ func email2content(email *utils.Email, cfg roomSettings, threadID id.EventID) *e Raw: map[string]interface{}{ eventMessageIDkey: email.MessageID, eventInReplyToKey: email.InReplyTo, + eventSubjectKey: email.Subject, + eventFromKey: email.From, }, Parsed: parsed, } 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 func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) { v, ok := b.rooms.Load(mailbox) @@ -67,7 +81,7 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) { } // 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)) if !ok { 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) threadID = eventID } + b.setLastEventID(roomID, threadID, eventID) if !cfg.NoFiles() { 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 } +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) { for _, file := range files { 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) + } + } +} diff --git a/bot/settings_room.go b/bot/settings_room.go index 6e357d9..4423c80 100644 --- a/bot/settings_room.go +++ b/bot/settings_room.go @@ -15,6 +15,7 @@ const acRoomSettingsKey = "cc.etke.postmoogle.settings" const ( roomOptionOwner = "owner" roomOptionMailbox = "mailbox" + roomOptionNoSend = "nosend" roomOptionNoSender = "nosender" roomOptionNoSubject = "nosubject" roomOptionNoHTML = "nohtml" @@ -42,6 +43,10 @@ func (s roomSettings) Owner() string { return s.Get(roomOptionOwner) } +func (s roomSettings) NoSend() bool { + return utils.Bool(s.Get(roomOptionNoSend)) +} + func (s roomSettings) NoSender() bool { return utils.Bool(s.Get(roomOptionNoSender)) } diff --git a/smtp/server.go b/smtp/msa.go similarity index 68% rename from smtp/server.go rename to smtp/msa.go index a06ce77..90d4261 100644 --- a/smtp/server.go +++ b/smtp/msa.go @@ -10,14 +10,15 @@ import ( "gitlab.com/etke.cc/go/logger" ) -type backend struct { +// msa is mail submission agent +type msa struct { log *logger.Logger domain string client Client } -func (b *backend) newSession() *session { - return &session{ +func (b *msa) newSession() *msasession { + return &msasession{ ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()), log: b.log, 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 } -func (b *backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { +func (b *msa) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { return b.newSession(), nil } func Start(domain, port, loglevel string, maxSize int, client Client) error { log := logger.New("smtp.", loglevel) - be := &backend{ + sender := NewMTA(loglevel) + receiver := &msa{ log: log, domain: domain, client: client, } - s := smtp.NewServer(be) + receiver.client.SetMTA(sender) + s := smtp.NewServer(receiver) s.Addr = ":" + port s.Domain = domain - s.AuthDisabled = true s.ReadTimeout = 10 * time.Second s.WriteTimeout = 10 * time.Second s.MaxMessageBytes = maxSize * 1024 * 1024 + s.AllowInsecureAuth = true if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" { s.Debug = os.Stdout } diff --git a/smtp/session.go b/smtp/msasession.go similarity index 81% rename from smtp/session.go rename to smtp/msasession.go index a267a48..76e652f 100644 --- a/smtp/session.go +++ b/smtp/msasession.go @@ -12,7 +12,7 @@ import ( "gitlab.com/etke.cc/postmoogle/utils" ) -type session struct { +type msasession struct { log *logger.Logger domain string client Client @@ -22,14 +22,14 @@ type session struct { 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) s.from = from s.log.Debug("mail from %s, options: %+v", from, opts) return nil } -func (s *session) Rcpt(to string) error { +func (s *msasession) Rcpt(to string) error { sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to) if utils.Hostname(to) != s.domain { @@ -48,7 +48,7 @@ func (s *session) Rcpt(to string) error { 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)) for _, attachment := range parts { for _, err := range attachment.Errors { @@ -61,7 +61,7 @@ func (s *session) parseAttachments(parts []*enmime.Part) []*utils.File { return files } -func (s *session) Data(r io.Reader) error { +func (s *msasession) Data(r io.Reader) error { parser := enmime.NewParser() eml, err := parser.ReadEnvelope(r) if err != nil { @@ -84,11 +84,11 @@ func (s *session) Data(r io.Reader) error { eml.HTML, 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 } diff --git a/smtp/mta.go b/smtp/mta.go new file mode 100644 index 0000000..54de3b6 --- /dev/null +++ b/smtp/mta.go @@ -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") +} diff --git a/smtp/smtp.go b/smtp/smtp.go deleted file mode 100644 index 15edba5..0000000 --- a/smtp/smtp.go +++ /dev/null @@ -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 -} diff --git a/utils/email.go b/utils/email.go index 5e3f24e..bb14e7e 100644 --- a/utils/email.go +++ b/utils/email.go @@ -1,5 +1,10 @@ package utils +// MTA is mail transfer agent +type MTA interface { + Send(from, to, data string) error +} + // Email object type Email struct { MessageID string diff --git a/utils/matrix.go b/utils/matrix.go index 0115996..47e4f03 100644 --- a/utils/matrix.go +++ b/utils/matrix.go @@ -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 func UnwrapError(err error) error { switch err.(type) {