103 Commits

Author SHA1 Message Date
Aine
84af8f8e13 adjust base image 2022-10-24 21:12:30 +03:00
Aine
01e3dfc6cf adjust docker image 2022-10-24 21:10:40 +03:00
Aine
b8c9d28324 experiment: disable ARMv7 builds 2022-10-24 19:16:16 +03:00
Aine
45ff7597ed send emails to multiple addresses at once 2022-10-24 16:50:00 +03:00
Aine
a1feaff350 fix mappings check on !pm mailbox 2022-10-24 08:34:24 +03:00
Aine
a4ade439a6 fix false-positive error message 2022-10-19 13:43:09 +03:00
Aine
841f5dfcfa Merge branch 'spamlist-wildcards' into 'main'
spamlist wildcards

See merge request etke.cc/postmoogle!35
2022-10-17 16:31:41 +00:00
Aine
4dd09dacb4 move migration to the postmoogle inital room sync 2022-10-17 19:01:21 +03:00
Aine
42cb5221a1 spamlist wildcards 2022-10-16 20:14:54 +03:00
Aine
1bc4d1188c update deps 2022-10-16 19:56:25 +03:00
Aine
590182c272 do not return error on empty account data, fix room account data cache key, update deps, fixes #37 2022-10-12 13:53:30 +03:00
Aine
b79fcceb3d Merge branch 'security' into 'main'
Security

See merge request etke.cc/postmoogle!34
2022-10-10 06:56:41 +00:00
Aine
8c2ed1b496 Merge branch 'main' into security 2022-10-10 09:49:36 +03:00
Aine
6f4da59387 feedback, typos, renaming 2022-10-10 09:41:22 +03:00
Aine
7a438bd761 increase linter timeout 2022-10-08 23:12:41 +03:00
Aine
cae3ea04d0 update deps, fixes #37 2022-10-08 22:26:45 +03:00
Aine
4ec51b64eb fix possible nil 2022-10-08 18:22:31 +03:00
Aine
c6049a7451 hotfix panic, fixes #36 2022-10-08 18:20:41 +03:00
Aine
d575552237 update readme 2022-10-08 12:01:03 +03:00
Aine
1dd996e430 rename security options 2022-10-08 11:58:14 +03:00
Aine
0767e7d0c3 security and spam options descriptions 2022-10-08 11:29:10 +03:00
Aine
99e509ea3a Email validations 2022-10-08 00:11:48 +03:00
Aine
6f8e850103 expose security and spam options 2022-10-07 23:24:59 +03:00
Aine
70ef60c934 add 'norecipient' room option, closes #35 2022-10-07 23:07:57 +03:00
Aine
6598e884c4 update roadmap 2022-10-04 21:53:31 +03:00
Aine
d6b6a5dc44 add catch-all mailbox, closes #25 2022-10-04 21:45:52 +03:00
Aine
4c6b7c2c1a set Content-Transfer-Encoding header, fixes #32 2022-10-04 21:10:56 +03:00
Aine
267f5cb949 move MTA SMTP connection to external lib 2022-10-04 12:16:59 +03:00
Aine
f3c5c47e76 room and user account data encryption 2022-10-02 20:15:46 +03:00
Aine
8c2a383421 update deps 2022-10-02 16:15:09 +03:00
Aine
ed5765b42a move seding files and work with account data to the linkpearl level 2022-10-02 13:51:58 +03:00
Aine
f585e6ba06 fix !pm send parsing 2022-09-23 18:16:39 +03:00
Aine
f3873132a7 Merge branch 'expose-mta' into 'main'
use postmoogle as general purpose SMTP server and allow other apps or scripts to send emails through it

See merge request etke.cc/postmoogle!33
2022-09-23 08:31:50 +00:00
Aine
c56c740c1d add password option messages 2022-09-23 11:28:15 +03:00
Aine
4bf0f0dee3 switch to password hashes 2022-09-23 11:17:34 +03:00
Aine
ce53d85806 Merge branch 'main' into expose-mta 2022-09-23 10:44:00 +03:00
Aine
236b23470d add comment 2022-09-23 10:42:17 +03:00
Aine
e368d26fc1 check full email in AllowAuth 2022-09-23 10:37:08 +03:00
Slavi Pantaleev
9129f8e38c Apply 1 suggestion(s) to 1 file(s) 2022-09-23 07:35:35 +00:00
Aine
bd2237d717 fix typo 2022-09-23 10:34:25 +03:00
Aine
3f5a1cd915 rename local to incoming 2022-09-23 10:33:25 +03:00
Aine
d50b79a801 switch email address validation to mail.ParseAddress 2022-09-23 10:29:37 +03:00
Aine
5a19ffad08 securely compare passwords, add notice about message removal 2022-09-23 10:19:25 +03:00
Aine
7473ed9450 send emails in unicode, fixes #31 2022-09-22 22:23:47 +03:00
Aine
90927247fd fix nosend description 2022-09-22 21:40:31 +03:00
Aine
1dc552686d reflect smtp auth changes in radme 2022-09-22 18:26:56 +03:00
Aine
070a6ffc76 use postmoogle as general purpose SMTP server and allow other apps or scripts to send emails through it 2022-09-22 18:21:17 +03:00
Aine
c9c871287d add read receipts and typing notifications, closes #30 2022-09-21 11:18:25 +03:00
Aine
16c577eeb2 update deps 2022-09-21 11:10:41 +03:00
Aine
97aacbf143 export MXID patterns parsing to external lib 2022-09-14 13:46:56 +03:00
Aine
14c0ebf1f1 fix attachments msg type parser 2022-09-13 18:35:19 +03:00
Aine
af3e23f630 enable starttls, fixes #29 2022-09-13 18:28:36 +03:00
Aine
691bf31dff fix access denied on !pm send 2022-09-13 17:29:49 +03:00
Aine
76bffd931c correctly handle attachments mimetype and set proper msgtype, fixes #27 2022-09-13 17:21:00 +03:00
Aine
7e92c023c8 do not parse inlines, fixes #28 2022-09-13 16:57:17 +03:00
Slavi Pantaleev
a73735d849 Merge branch 'listentls' into 'main'
Secure SMTP listener

See merge request etke.cc/postmoogle!31
2022-09-08 14:30:47 +00:00
Aine
08aa23b397 fix Message-Id, fixes #20 2022-09-08 14:08:17 +03:00
Aine
613767a86d update POSTMOOGLE_TLS_REQUIRED comment 2022-09-08 11:49:59 +03:00
Aine
eb88b74ff7 remove close() on error 2022-09-08 11:44:49 +03:00
Aine
9a121b6ed5 try to send emails over TLS first 2022-09-08 09:52:58 +03:00
Slavi Pantaleev
d434edd930 Merge branch 'dkimreset' into 'main'
!pm dkim reset; switch DKIM algo to RSA

See merge request etke.cc/postmoogle!32
2022-09-08 06:48:52 +00:00
Aine
daeb72aa58 add TL;DR DNS config 2022-09-08 09:36:33 +03:00
Aine
a608fffea8 !pm dkim reset; switch DKIM algo to RSA 2022-09-08 09:31:09 +03:00
Aine
31a1399736 fix dkim signature (domain), fixes #21 2022-09-08 00:11:10 +03:00
Aine
40f2ec9492 fix command parsing 2022-09-07 23:51:37 +03:00
Aine
59ed33638b Secure SMTP listener 2022-09-07 21:29:52 +03:00
Aine
715ec1ef2a fix #19 2022-09-07 20:24:49 +03:00
Slavi Pantaleev
47767e2ab2 Merge branch 'send' into 'main'
send emails

See merge request etke.cc/postmoogle!30
2022-09-07 06:47:41 +00:00
Slavi Pantaleev
8823867ba5 Apply 1 suggestion(s) to 1 file(s) 2022-09-06 19:55:46 +00:00
Slavi Pantaleev
c4e136674a Apply 1 suggestion(s) to 1 file(s) 2022-09-06 19:55:38 +00:00
Slavi Pantaleev
d4b6c7bd1f Apply 1 suggestion(s) to 1 file(s) 2022-09-06 19:55:27 +00:00
Aine
d5676ecc07 replace DOMAIN to example.com in readme 2022-09-06 22:55:08 +03:00
Slavi Pantaleev
eacdbe587b Apply 1 suggestion(s) to 1 file(s) 2022-09-06 19:53:00 +00:00
Aine
78210e6487 update utils.RelatesTo and utils.EventParent comments 2022-09-06 22:52:40 +03:00
Slavi Pantaleev
ca758f8825 Apply 1 suggestion(s) to 1 file(s) 2022-09-06 19:49:30 +00:00
Slavi Pantaleev
321d1da79f Apply 1 suggestion(s) to 1 file(s) 2022-09-06 19:49:13 +00:00
Slavi Pantaleev
bac3447db2 Apply 1 suggestion(s) to 1 file(s) 2022-09-06 19:49:02 +00:00
Aine
86890c1f89 refactor email.Compose() 2022-09-06 22:48:37 +03:00
Slavi Pantaleev
4c96e6a11f Apply 1 suggestion(s) to 1 file(s) 2022-09-06 19:44:05 +00:00
Aine
4d01579505 move email sending to b.runSend() 2022-09-06 22:43:04 +03:00
Aine
db135c0cb1 deconstruct getSubject and getBody 2022-09-06 22:34:21 +03:00
Aine
bbb6bec35f update SetMTA comment 2022-09-06 22:21:23 +03:00
Aine
5945ddc8a0 rename internal thigs of smtp/ 2022-09-06 22:16:28 +03:00
Aine
2b5095b0b2 add note about interface 2022-09-06 22:03:10 +03:00
Aine
af1b664274 cache empty settings 2022-09-06 22:02:21 +03:00
Aine
17c8d06a33 disable insecure auth 2022-09-06 18:51:46 +03:00
Aine
085cdf5dbf refactor email2content 2022-09-06 18:39:35 +03:00
Aine
1f896d1b26 add note about MX record 2022-09-06 16:46:14 +03:00
Aine
7d435f7ba8 move email composing to utils 2022-09-05 20:38:58 +03:00
Aine
2427d41ae3 move parsing of !pm send to utils, update !pm send instructions 2022-09-05 20:10:07 +03:00
Aine
e4c425fb2e update readme 2022-09-05 18:00:09 +03:00
Aine
41f3ad947e fix readme 2022-09-05 17:02:45 +03:00
Aine
12a2d4c6f9 dkim 2022-09-05 17:02:00 +03:00
Aine
fda0d62087 send emails 2022-09-04 22:09:53 +03:00
Aine
a92b4c64ae Merge branch 'only-follow-invites-for-allowed' into 'main'
Only auto-join rooms when invited by allowed users

Closes #17

See merge request etke.cc/postmoogle!28
2022-08-31 12:59:04 +00:00
Slavi Pantaleev
61cc9b21c5 Only auto-join rooms when invited by allowed users
Previously, anyone (even across federation) could invite you to a
room and the bot would join. It may not have provided a useful purpose,
but it still joined all rooms it was invited to.

We now only join rooms when we're invited by a person who is actually
allowed to use the bot.

Fixes https://gitlab.com/etke.cc/postmoogle/-/issues/17
2022-08-31 15:54:56 +03:00
Aine
a3c81ab232 Merge branch 'better-introduction' into 'main'
Improve introduction and getters usability

See merge request etke.cc/postmoogle!26
2022-08-31 07:40:25 +00:00
Aine
ab95fe5d2d refactor to fmt.Sprintf() 2022-08-31 10:39:30 +03:00
Aine
104e948b9c remove migrations 2022-08-31 10:33:13 +03:00
Aine
67f504f888 Merge branch 'fix-double-domain-again' into 'main'
Do not append domain twice when updating mailbox (again)

See merge request etke.cc/postmoogle!27
2022-08-31 07:24:40 +00:00
Aine
bf970fc699 remove duplications in utils.Mailbox 2022-08-31 10:24:17 +03:00
Slavi Pantaleev
4b1ce195b4 Do not append domain twice when updating mailbox (again)
Fixes:

> `mailbox` of this room set to `test@domain@domain`

Previously fixed in 97a4d6c7bc, but it seems like we unintentionally
reintroduced this bug again at some point after that.
2022-08-31 09:16:16 +03:00
Slavi Pantaleev
60d3fbbba5 Improve introduction and getters usability
When someone first joins a room, they see some commands (`mailbox`,
`owner`, ..) and they know they are getters and setters, but they have
no good example as to how to use them.

Is it `!pm mailbox SOMETHING` or `!pm mailbox=SOMETHING` or something
else?

It's better if the introduction text gives you the full command you need
to get started (e.g. `!pm mailbox SOME_MAILBOX`), instead of a partial
command that you don't know how to use (e.g. `!pm mailbox` - this is
merely a getter and will not set your mailbox to `SOME_MAILBOX`).

Starting from this, I thought it would be a good idea to make all
option getters tell you how the commands are to be used. If you send
`!pm mailbox` and it tells you "not yet set", it should also tell you
how to actually set it (e.g. `!pm mailbox VALUE`).
2022-08-31 09:08:49 +03:00
39 changed files with 1685 additions and 714 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/local.db
/local.db-journal
/cover.out
/e2e/main.go

View File

@@ -4,13 +4,13 @@ stages:
lint:
stage: test
image: registry.gitlab.com/etke.cc/base
image: registry.gitlab.com/etke.cc/base/build
script:
- make lint
unit:
stage: test
image: registry.gitlab.com/etke.cc/base
image: registry.gitlab.com/etke.cc/base/build
script:
- make test

View File

@@ -1,6 +1,6 @@
run:
concurrency: 4
timeout: 5m
timeout: 30m
issues-exit-code: 1
tests: true
build-tags: []

View File

@@ -1,20 +1,16 @@
FROM registry.gitlab.com/etke.cc/base AS builder
FROM registry.gitlab.com/etke.cc/base/build AS builder
WORKDIR /postmoogle
COPY . .
RUN make build
FROM alpine:latest
FROM registry.gitlab.com/etke.cc/base/app
ENV POSTMOOGLE_DB_DSN /data/postmoogle.db
RUN apk --no-cache add ca-certificates tzdata olm && \
adduser -D -g '' postmoogle && \
mkdir /data && chown -R postmoogle /data
COPY --from=builder /postmoogle/postmoogle /bin/postmoogle
USER postmoogle
USER app
ENTRYPOINT ["/bin/postmoogle"]

View File

@@ -50,4 +50,4 @@ login:
# docker build
docker:
docker buildx create --use
docker buildx build --platform linux/arm/v7,linux/arm64/v8,linux/amd64 --push -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} .
docker buildx build --platform linux/arm64/v8,linux/amd64 --push -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} .

198
README.md
View File

@@ -4,40 +4,59 @@
An Email to Matrix bridge. 1 room = 1 mailbox.
Postmoogle is an actual SMTP server that allows you to receive emails on your matrix server.
It can't be used with arbitrary email providers, but setup your own provider "with matrix interface" instead.
Postmoogle is an actual SMTP server that allows you to send and receive emails on your matrix server.
It can't be used with arbitrary email providers, because it acts as an actual email provider itself,
so you can use it to send emails from your apps and scripts as well.
## Roadmap
### Receive
- [x] SMTP server
- [x] SMTP server (plaintext and SSL)
- [x] Matrix bot
- [x] Configuration in room's account data
- [x] Receive emails to matrix rooms
- [x] Receive attachments
- [x] Catch-all mailbox
- [x] Map email threads to matrix threads
#### deep dive
> features in that section considered as "nice to have", but not a priority
- [ ] DKIM verification
- [ ] SPF verification
- [ ] DMARC verification
- [ ] Blocklists
### Send
- [ ] SMTP client
- [x] SMTP client
- [x] SMTP server (you can use Postmoogle as general purpose SMTP server to send emails from your scripts or apps)
- [x] Send a message to matrix room with special format to send a new email, even to multiple email addresses at once
- [ ] 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`
* **POSTMOOGLE_LOGIN** - user login/localpart, eg: `moogle`
* **POSTMOOGLE_PASSWORD** - user password
* **POSTMOOGLE_DOMAIN** - SMTP domain to listen for new emails
* **POSTMOOGLE_PORT** - SMTP port to listen for new emails
<details>
<summary>other optional config parameters</summary>
* **POSTMOOGLE_NOENCRYPTION** - disable encryption support
* **POSTMOOGLE_PORT** - SMTP port to listen for new emails
* **POSTMOOGLE_TLS_PORT** - secure SMTP port to listen for new emails. Requires valid cert and key as well
* **POSTMOOGLE_TLS_CERT** - path to your SSL certificate (chain)
* **POSTMOOGLE_TLS_KEY** - path to your SSL certificate's private key
* **POSTMOOGLE_TLS_REQUIRED** - require TLS connection, **even** on the non-TLS port (`POSTMOOGLE_PORT`). TLS connections are always required on the TLS port (`POSTMOOGLE_TLS_PORT`) regardless of this setting.
* **POSTMOOGLE_DATA_SECRET** - secure key (password) to encrypt account data, must be 16, 24, or 32 bytes long
* **POSTMOOGLE_NOENCRYPTION** - disable matrix encryption (libolm) support
* **POSTMOOGLE_STATUSMSG** - presence status message
* **POSTMOOGLE_SENTRY_DSN** - sentry DSN
* **POSTMOOGLE_LOGLEVEL** - log level
@@ -45,12 +64,163 @@ env vars
* **POSTMOOGLE_DB_DIALECT** - database dialect (postgres, sqlite3)
* **POSTMOOGLE_MAXSIZE** - max email size (including attachments) in megabytes
* **POSTMOOGLE_ADMINS** - a space-separated list of admin users. See `POSTMOOGLE_USERS` for syntax examples
* <s>**POSTMOOGLE_USERS**</s> - deprecated and ignored, use `!pm users` instead
You can find default values in [config/defaults.go](config/defaults.go)
</details>
### 2. DNS (optional)
The following configuration is needed only if you want to send outgoing emails via Postmoogle (it's not necessary if you only want to receive emails).
<details>
<summary>TL;DR</summary>
1. Configure DMARC record
2. Configure SPF record
3. Configure MX record
4. Configure DKIM record (use `!pm dkim`)
</details>
**First**, add a new DMARC DNS record of the `TXT` type for subdomain `_dmarc` with a proper policy. The simplest policy you can use is: `v=DMARC1; p=quarantine;`.
<details>
<summary>Example</summary>
```bash
$ dig txt _dmarc.example.com
; <<>> DiG 9.18.6 <<>> txt _dmarc.example.com
;; 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.example.com. IN TXT
;; ANSWER SECTION:
_dmarc.example.com. 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 a new SPF DNS record of the `TXT` type for your domain that will be used with Postmoogle, with format: `v=spf1 ip4:SERVER_IP -all` (replace `SERVER_IP` with your server's IP address)
<details>
<summary>Example</summary>
```bash
$ dig txt example.com
; <<>> DiG 9.18.6 <<>> txt example.com
;; 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:
;example.com. IN TXT
;; ANSWER SECTION:
example.com. 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>
**Third**, add a new MX DNS record of the `MX` type for your domain that will be used with postmoogle. It should point to the same (sub-)domain.
Looks odd, but some mail servers will refuse to interact with your mail server (and Postmoogle is already a mail server) without MX records.
<details>
<summary>Example</summary>
```bash
dig MX example.com
; <<>> DiG 9.18.6 <<>> MX example.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 12688
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;example.com. IN MX
;; ANSWER SECTION:
example.com. 1799 IN MX 10 example.com.
;; Query time: 40 msec
;; SERVER: 1.1.1.1#53(1.1.1.1) (UDP)
;; WHEN: Tue Sep 06 16:44:47 EEST 2022
;; MSG SIZE rcvd: 59
```
</details>
**Fourth** (and the last one), add new DKIM DNS record of `TXT` type for subdomain `postmoogle._domainkey` that will be used with postmoogle.
You can get that signature using the `!pm dkim` command:
<details>
<summary>!pm dkim</summary>
DKIM signature is: `v=DKIM1; k=rsa; p=MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAxJVqmBHhK9FY93q1o3WEaP2GKMh/3LNMyvi1uSjOKxIyfWv685KxX1EUrbHakQRCTtUM7efKEsXsXBh+DQru2TE32yFpL9afA5BbHj3KePGFY8KJ2m0sQxbQcvn2KjJC0IQ15mk0rninPhtphU/2zLsd6e7Rl1m3L+9Osk320GbfDgSKjRPcSiwVMbLJpSOP0H0F3cIu+c1fHZHfmWy0O+us42C3HTLTlD779LTnQnKlAOQD/+DYYqz6TGGxEwUG2BRQ8O8w7/wXEkg/6a/MxNtPnc59g29CpqRsDkuYiR3UIpqzLDoqHlaoKNbYy34R+4aIjfNpmZyR5kIumws+3MJtJt9UhBTMloqd8lZDPaPmX2NEDqbcSTkHMQrphk+EWSCc7OvbKRaXZ0SyJLpLjxRwKrpeO0JAI0ZpnAFS11uBEe9GSS8uzIIFNYVD1vHloAFKvUJEhyuVyz9/SyqTnArN3ZTiC5cqD1MB86q5QPrKqZfp1dAnv7xAJThL0AP/AgMBAAE=`.
You need to add it to your DNS records (if not already):
Add new DNS record with type = `TXT`, key (subdomain/from): `postmoogle._domainkey` and value (to):
```
v=DKIM1; k=rsa; p=MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAxJVqmBHhK9FY93q1o3WEaP2GKMh/3LNMyvi1uSjOKxIyfWv685KxX1EUrbHakQRCTtUM7efKEsXsXBh+DQru2TE32yFpL9afA5BbHj3KePGFY8KJ2m0sQxbQcvn2KjJC0IQ15mk0rninPhtphU/2zLsd6e7Rl1m3L+9Osk320GbfDgSKjRPcSiwVMbLJpSOP0H0F3cIu+c1fHZHfmWy0O+us42C3HTLTlD779LTnQnKlAOQD/+DYYqz6TGGxEwUG2BRQ8O8w7/wXEkg/6a/MxNtPnc59g29CpqRsDkuYiR3UIpqzLDoqHlaoKNbYy34R+4aIjfNpmZyR5kIumws+3MJtJt9UhBTMloqd8lZDPaPmX2NEDqbcSTkHMQrphk+EWSCc7OvbKRaXZ0SyJLpLjxRwKrpeO0JAI0ZpnAFS11uBEe9GSS8uzIIFNYVD1vHloAFKvUJEhyuVyz9/SyqTnArN3ZTiC5cqD1MB86q5QPrKqZfp1dAnv7xAJThL0AP/AgMBAAE=
```
Without that record other email servers may reject your emails as spam, kupo.
</details>
<details>
<summary>Example</summary>
```bash
$ dig TXT postmoogle._domainkey.example.com
; <<>> DiG 9.18.6 <<>> TXT postmoogle._domainkey.example.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 59014
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;postmoogle._domainkey.example.com. IN TXT
;; ANSWER SECTION:
postmoogle._domainkey.example.com. 600 IN TXT "v=DKIM1; k=rsa; p=MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAxJVqmBHhK9FY93q1o3WEaP2GKMh/3LNMyvi1uSjOKxIyfWv685KxX1EUrbHakQRCTtUM7efKEsXsXBh+DQru2TE32yFpL9afA5BbHj3KePGFY8KJ2m0sQxbQcvn2KjJC0IQ15mk0rninPhtphU/2zLsd6e7Rl1m3L+9Osk320GbfDgSKjRPcSiwVMbLJpSOP0H0F3cIu+c1fHZHfmWy0O+us42C3HTLTlD779LTnQnKlAOQD/+DYYqz6TGGxEwUG2BRQ8O8w7/wXEkg/6a/MxNtPnc59g29CpqRsDkuYiR3UIpqzLDoqHlaoKNbYy34R+4aIjfNpmZyR5kIumws+3MJtJt9UhBTMloqd8lZDPaPmX2NEDqbcSTkHMQrphk+EWSCc7OvbKRaXZ0SyJLpLjxRwKrpeO0JAI0ZpnAFS11uBEe9GSS8uzIIFNYVD1vHloAFKvUJEhyuVyz9/SyqTnArN3ZTiC5cqD1MB86q5QPrKqZfp1dAnv7xAJThL0AP/AgMBAAE="
;; Query time: 90 msec
;; SERVER: 1.1.1.1#53(1.1.1.1) (UDP)
;; WHEN: Mon Sep 05 16:16:21 EEST 2022
;; MSG SIZE rcvd: 525
```
</details>
## Usage
### How to start
@@ -71,10 +241,12 @@ If you want to change them - check available options in the help message (`!pm h
* **!pm mailbox** - Get or set mailbox of the room
* **!pm owner** - Get or set owner of the room
* **!pm password** - Get or set SMTP password of the room's mailbox
---
* **!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 nosubject** - Get or set `nosubject` of the room (`true` - hide email subject; `false` - show email subject)
* **!pm nohtml** - Get or set `nohtml` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)
* **!pm nothreads** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)
@@ -82,8 +254,16 @@ If you want to change them - check available options in the help message (`!pm h
---
* **!pm mailboxes** - Show the list of all mailboxes
* **!pm spamcheck:mx** - only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)
* **!pm spamcheck:smtp** - only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)
* **!pm spamlist** - Get or set `spamlist` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,noreply@*`
---
* **!pm dkim** - Get DKIM signature
* **!pm catch-all** - Configure catch-all mailbox
* **!pm users** - Get or set allowed users patterns
* **!pm mailboxes** - Show the list of all mailboxes
* **!pm delete** &lt;mailbox&gt; - Delete specific mailbox
</details>

View File

@@ -3,7 +3,11 @@ package bot
import (
"context"
"regexp"
"strings"
"github.com/getsentry/sentry-go"
"github.com/raja/argon2pw"
"gitlab.com/etke.cc/go/mxidwc"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
@@ -14,7 +18,17 @@ func parseMXIDpatterns(patterns []string, defaultPattern string) ([]*regexp.Rege
patterns = []string{defaultPattern}
}
return utils.WildcardMXIDsToRegexes(patterns)
return mxidwc.ParsePatterns(patterns)
}
func (b *Bot) allowUsers(actorID id.UserID) bool {
if len(b.allowedUsers) != 0 {
if !mxidwc.Match(actorID.String(), b.allowedUsers) {
return false
}
}
return true
}
func (b *Bot) allowAnyone(actorID id.UserID, targetRoomID id.RoomID) bool {
@@ -22,15 +36,12 @@ func (b *Bot) allowAnyone(actorID id.UserID, targetRoomID id.RoomID) bool {
}
func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
if len(b.allowedUsers) != 0 {
if !utils.Match(actorID.String(), b.allowedUsers) {
return false
}
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)
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
return false
}
@@ -43,5 +54,42 @@ 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)
return mxidwc.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(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
return false
}
return !cfg.NoSend()
}
// AllowAuth check if SMTP login (email) and password are valid
func (b *Bot) AllowAuth(email, password string) bool {
if !strings.HasSuffix(email, "@"+b.domain) {
return false
}
roomID, ok := b.GetMapping(utils.Mailbox(email))
if !ok {
return false
}
cfg, err := b.getRoomSettings(roomID)
if err != nil {
b.log.Error("failed to retrieve settings: %v", err)
return false
}
allow, err := argon2pw.CompareHashWithPassword(cfg.Password(), password)
if err != nil {
b.log.Warn("Password for %s is not valid: %v", email, err)
}
return allow
}

View File

@@ -6,13 +6,14 @@ import (
"regexp"
"sync"
"git.sr.ht/~xn/cache/v2"
"github.com/getsentry/sentry-go"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/linkpearl"
"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
@@ -23,8 +24,7 @@ type Bot struct {
allowedAdmins []*regexp.Regexp
commands commandList
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
@@ -37,20 +37,17 @@ func New(
log *logger.Logger,
prefix string,
domain string,
envUsers []string,
admins []string,
) (*Bot, error) {
b := &Bot{
prefix: prefix,
domain: domain,
rooms: sync.Map{},
botcfg: cache.NewLRU[botSettings](1),
cfg: cache.NewLRU[roomSettings](1000),
log: log,
lp: lp,
mu: map[id.RoomID]*sync.Mutex{},
}
users, err := b.initBotUsers(envUsers)
users, err := b.initBotUsers()
if err != nil {
return nil, err
}
@@ -66,7 +63,7 @@ func New(
}
b.allowedAdmins = allowedAdmins
b.commands = b.buildCommandList()
b.commands = b.initCommands()
return b, nil
}
@@ -76,9 +73,11 @@ func (b *Bot) Error(ctx context.Context, roomID id.RoomID, message string, args
b.log.Error(message, args...)
err := fmt.Errorf(message, args...)
sentry.GetHubFromContext(ctx).CaptureException(err)
if hub := sentry.GetHubFromContext(ctx); hub != nil {
sentry.GetHubFromContext(ctx).CaptureException(err)
}
if roomID != "" {
b.SendError(ctx, roomID, message)
b.SendError(ctx, roomID, err.Error())
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@@ -14,6 +15,9 @@ import (
const (
commandHelp = "help"
commandStop = "stop"
commandSend = "send"
commandDKIM = "dkim"
commandCatchAll = botOptionCatchAll
commandUsers = botOptionUsers
commandDelete = "delete"
commandMailboxes = "mailboxes"
@@ -38,7 +42,7 @@ func (c commandList) get(key string) *command {
return nil
}
func (b *Bot) buildCommandList() commandList {
func (b *Bot) initCommands() commandList {
return commandList{
// special commands
{
@@ -51,6 +55,11 @@ func (b *Bot) buildCommandList() 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
{
@@ -65,7 +74,21 @@ func (b *Bot) buildCommandList() commandList {
sanitizer: func(s string) string { return s },
allowed: b.allowOwner,
},
{
key: roomOptionPassword,
description: "Get or set SMTP password of the room's mailbox",
allowed: b.allowOwner,
},
{allowed: b.allowOwner}, // delimiter
{
key: roomOptionNoSend,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - disable email sending; `false` - enable email sending)",
roomOptionNoSend,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoSender,
description: fmt.Sprintf(
@@ -75,6 +98,15 @@ func (b *Bot) buildCommandList() commandList {
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoRecipient,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide recipient; `false` - show recipient)",
roomOptionNoRecipient,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoSubject,
description: fmt.Sprintf(
@@ -111,12 +143,44 @@ func (b *Bot) buildCommandList() commandList {
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{allowed: b.allowOwner}, // delimiter
{
key: roomOptionSpamcheckMX,
description: "only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)",
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionSpamcheckSMTP,
description: "only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)",
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionSpamlist,
description: fmt.Sprintf(
"Get or set `%s` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,spam@*`",
roomOptionSpamlist,
),
sanitizer: utils.SanitizeStringSlice,
allowed: b.allowOwner,
},
{allowed: b.allowAdmin}, // delimiter
{
key: botOptionUsers,
description: "Get or set allowed users",
allowed: b.allowAdmin,
},
{
key: commandDKIM,
description: "Get DKIM signature",
allowed: b.allowAdmin,
},
{
key: commandCatchAll,
description: "Get or set catch-all mailbox",
allowed: b.allowAdmin,
},
{
key: commandMailboxes,
description: "Show the list of all mailboxes",
@@ -135,6 +199,11 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
if cmd == nil {
return
}
_, err := b.lp.GetClient().UserTyping(evt.RoomID, true, 30*time.Second)
if err != nil {
b.log.Error("cannot send typing notification: %v", err)
}
defer b.lp.GetClient().UserTyping(evt.RoomID, false, 30*time.Second) //nolint:errcheck
if !cmd.allowed(evt.Sender, evt.RoomID) {
b.SendNotice(ctx, evt.RoomID, "not allowed to do that, kupo")
@@ -146,8 +215,14 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
b.sendHelp(ctx)
case commandStop:
b.runStop(ctx)
case commandSend:
b.runSend(ctx)
case commandDKIM:
b.runDKIM(ctx, commandSlice)
case commandUsers:
b.runUsers(ctx, commandSlice)
case commandCatchAll:
b.runCatchAll(ctx, commandSlice)
case commandDelete:
b.runDelete(ctx, commandSlice)
case commandMailboxes:
@@ -157,7 +232,7 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
}
}
func (b *Bot) parseCommand(message string) []string {
func (b *Bot) parseCommand(message string, toLower bool) []string {
if message == "" {
return nil
}
@@ -167,8 +242,11 @@ func (b *Bot) parseCommand(message string) []string {
return nil
}
message = strings.ToLower(strings.TrimSpace(strings.Replace(message, b.prefix, "", 1)))
return strings.Split(message, " ")
message = strings.Replace(message, b.prefix, "", 1)
if toLower {
message = strings.ToLower(message)
}
return strings.Split(strings.TrimSpace(message), " ")
}
func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
@@ -181,7 +259,7 @@ func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
msg.WriteString(b.prefix)
msg.WriteString(" ")
msg.WriteString(roomOptionMailbox)
msg.WriteString("` command.\n")
msg.WriteString(" SOME_INBOX` command.\n")
msg.WriteString("You will then be able to send emails to `SOME_INBOX@")
msg.WriteString(b.domain)
@@ -237,3 +315,63 @@ func (b *Bot) sendHelp(ctx context.Context) {
b.SendNotice(ctx, evt.RoomID, msg.String())
}
func (b *Bot) runSend(ctx context.Context) {
evt := eventFromContext(ctx)
if !b.allowSend(evt.Sender, evt.RoomID) {
return
}
commandSlice := b.parseCommand(evt.Content.AsMessage().Body, false)
to, subject, body, err := utils.ParseSend(commandSlice)
if err == utils.ErrInvalidArgs {
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf(
"Usage:\n"+
"```\n"+
"%s send someone@example.com\n"+
"Subject goes here on a line of its own\n"+
"Email content goes here\n"+
"on as many lines\n"+
"as you want.\n"+
"```",
b.prefix))
return
}
cfg, err := b.getRoomSettings(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve room settings: %v", err)
return
}
mailbox := cfg.Mailbox()
if mailbox == "" {
b.SendNotice(ctx, evt.RoomID, "mailbox is not configured, kupo")
return
}
tos := strings.Split(to, ",")
// validate first
for _, to := range tos {
if !utils.AddressValid(to) {
b.Error(ctx, evt.RoomID, "email address is not valid")
return
}
}
from := mailbox + "@" + b.domain
ID := fmt.Sprintf("<%s@%s>", evt.ID, b.domain)
for _, to := range tos {
data := utils.
NewEmail(ID, "", subject, from, to, body, "", nil).
Compose(b.getBotSettings().DKIMPrivateKey())
err = b.mta.Send(from, to, data)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err)
} else {
b.SendNotice(ctx, evt.RoomID, "Email has been sent to "+to)
}
}
if len(tos) > 1 {
b.SendNotice(ctx, evt.RoomID, "All emails were sent.")
}
}

View File

@@ -6,6 +6,7 @@ import (
"sort"
"strings"
"gitlab.com/etke.cc/go/secgen"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
@@ -130,3 +131,76 @@ func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
b.allowedUsers = allowedUsers
b.SendNotice(ctx, evt.RoomID, "allowed users updated")
}
func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
if len(commandSlice) > 1 && commandSlice[1] == "reset" {
cfg.Set(botOptionDKIMPrivateKey, "")
cfg.Set(botOptionDKIMSignature, "")
}
signature := cfg.DKIMSignature()
if signature == "" {
var private string
var derr error
signature, private, derr = secgen.DKIM()
if derr != nil {
b.Error(ctx, evt.RoomID, "cannot generate DKIM signature: %v", derr)
return
}
cfg.Set(botOptionDKIMSignature, signature)
cfg.Set(botOptionDKIMPrivateKey, private)
err := b.setBotSettings(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
return
}
}
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf(
"DKIM signature is: `%s`.\n"+
"You need to add it to your DNS records (if not already):\n"+
"Add new DNS record with type = `TXT`, key (subdomain/from): `postmoogle._domainkey` and value (to):\n ```\n%s\n```\n"+
"Without that record other email servers may reject your emails as spam, kupo.\n"+
"To reset the signature, send `%s dkim reset`",
signature, signature, b.prefix))
}
func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
if cfg.CatchAll() != "" {
msg.WriteString(cfg.CatchAll())
} else {
msg.WriteString("not set")
}
msg.WriteString("`\n\n")
msg.WriteString("Usage: `")
msg.WriteString(b.prefix)
msg.WriteString(" catch-all MAILBOX`")
msg.WriteString("where mailbox is valid and existing mailbox name\n")
b.SendNotice(ctx, evt.RoomID, msg.String())
return
}
mailbox := utils.Mailbox(commandSlice[1])
_, ok := b.GetMapping(mailbox)
if !ok {
b.SendError(ctx, evt.RoomID, "mailbox does not exist, kupo.")
return
}
cfg.Set(botOptionCatchAll, mailbox)
err := b.setBotSettings(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
return
}
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s@%s`.", mailbox, b.domain))
}

View File

@@ -3,6 +3,8 @@ package bot
import (
"context"
"fmt"
"github.com/raja/argon2pw"
)
func (b *Bot) runStop(ctx context.Context) {
@@ -48,7 +50,10 @@ func (b *Bot) getOption(ctx context.Context, name string) {
value := cfg.Get(name)
if value == "" {
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("`%s` is not set, kupo.", name))
msg := fmt.Sprintf("`%s` is not set, kupo.\n"+
"To set it, send a `%s %s VALUE` command.",
name, b.prefix, name)
b.SendNotice(ctx, evt.RoomID, msg)
return
}
@@ -56,18 +61,29 @@ func (b *Bot) getOption(ctx context.Context, name string) {
value = value + "@" + b.domain
}
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("`%s` of this room is `%s`", name, value))
msg := fmt.Sprintf("`%s` of this room is `%s`\n"+
"To set it to a new value, send a `%s %s VALUE` command.",
name, value, b.prefix, name)
if name == roomOptionPassword {
msg = fmt.Sprintf("There is an SMTP password already set for this room/mailbox. "+
"It's stored in a secure hashed manner, so we can't tell you what the original raw password was. "+
"To find the raw password, try to find your old message which had originally set it, "+
"or just set a new one with `%s %s NEW_PASSWORD`.",
b.prefix, name)
}
b.SendNotice(ctx, evt.RoomID, msg)
}
//nolint:gocognit
func (b *Bot) setOption(ctx context.Context, name, value string) {
cmd := b.commands.get(name)
if cmd != nil {
if cmd != nil && cmd.sanitizer != nil {
value = cmd.sanitizer(value)
}
evt := eventFromContext(ctx)
if name == roomOptionMailbox {
existingID, ok := b.GetMapping(value)
existingID, ok := b.getMapping(value)
if ok && existingID != "" && existingID != evt.RoomID {
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Mailbox `%s@%s` already taken, kupo", value, b.domain))
return
@@ -80,6 +96,14 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
return
}
if name == roomOptionPassword {
value, err = argon2pw.GenerateSaltedHash(value)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to hash password: %v", err)
return
}
}
old := cfg.Get(name)
cfg.Set(name, value)
@@ -98,9 +122,9 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
return
}
if name == roomOptionMailbox {
value = value + "@" + b.domain
msg := fmt.Sprintf("`%s` of this room set to `%s`", name, value)
if name == roomOptionPassword {
msg = "SMTP password has been set"
}
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("`%s` of this room set to `%s`", name, value))
b.SendNotice(ctx, evt.RoomID, msg)
}

View File

@@ -37,12 +37,11 @@ func (b *Bot) syncRooms() error {
return err
}
for _, roomID := range resp.JoinedRooms {
b.migrateSettings(roomID)
cfg, serr := b.getRoomSettings(roomID)
if serr != nil {
b.log.Warn("cannot get %s settings: %v", roomID, err)
continue
}
b.migrateRoomSettings(roomID)
mailbox := cfg.Mailbox()
if mailbox != "" {
b.rooms.Store(mailbox, roomID)

View File

@@ -3,61 +3,40 @@ package bot
import (
"context"
"errors"
"fmt"
"strings"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"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 {
var text strings.Builder
if !cfg.NoSender() {
text.WriteString("From: ")
text.WriteString(email.From)
text.WriteString("\n\n")
}
if !cfg.NoSubject() {
text.WriteString("# ")
text.WriteString(email.Subject)
text.WriteString("\n\n")
}
if email.HTML != "" && !cfg.NoHTML() {
text.WriteString(format.HTMLToMarkdown(email.HTML))
} else {
text.WriteString(email.Text)
}
parsed := format.RenderMarkdown(text.String(), true, true)
parsed.RelatesTo = utils.RelatesTo(cfg.NoThreads(), threadID)
content := event.Content{
Raw: map[string]interface{}{
eventMessageIDkey: email.MessageID,
eventInReplyToKey: email.InReplyTo,
},
Parsed: parsed,
}
return &content
// SetMTA sets mail transfer agent instance to the bot
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) {
func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
v, ok := b.rooms.Load(mailbox)
if !ok {
return "", ok
}
roomID, ok := v.(id.RoomID)
if !ok {
return "", ok
@@ -66,9 +45,34 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
return roomID, ok
}
// GetMapping returns mapping of mailbox = room
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
roomID, ok := b.getMapping(mailbox)
if !ok {
catchAll := b.getBotSettings().CatchAll()
if catchAll == "" {
return roomID, ok
}
return b.getMapping(catchAll)
}
return roomID, ok
}
// GetIFOptions returns incoming email filtering options (room settings)
func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions {
cfg, err := b.getRoomSettings(roomID)
if err != nil {
b.log.Error("cannot retrieve room settings: %v", err)
return roomSettings{}
}
return cfg
}
// Send email to matrix room
func (b *Bot) Send(ctx context.Context, email *utils.Email) error {
roomID, ok := b.GetMapping(utils.Mailbox(email.To))
func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) error {
roomID, ok := b.GetMapping(email.Mailbox(incoming))
if !ok {
return errors.New("room not found")
}
@@ -80,6 +84,10 @@ func (b *Bot) Send(ctx context.Context, email *utils.Email) error {
b.Error(ctx, roomID, "cannot get settings: %v", err)
}
if !incoming && cfg.NoSend() {
return errors.New("that mailbox is receive-only")
}
var threadID id.EventID
if email.InReplyTo != "" && !cfg.NoThreads() {
threadID = b.getThreadID(roomID, email.InReplyTo)
@@ -87,8 +95,7 @@ func (b *Bot) Send(ctx context.Context, email *utils.Email) error {
b.setThreadID(roomID, email.MessageID, threadID)
}
}
content := email2content(email, cfg, threadID)
content := email.Content(threadID, cfg.ContentOptions())
eventID, serr := b.lp.Send(roomID, content)
if serr != nil {
return utils.UnwrapError(serr)
@@ -98,29 +105,103 @@ 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)
}
if !incoming {
email.MessageID = fmt.Sprintf("<%s@%s>", eventID, b.domain)
return b.mta.Send(email.From, email.To, email.Compose(b.getBotSettings().DKIMPrivateKey()))
}
return nil
}
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
// TODO rewrite to thread replies only
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 = strings.SplitN(content.Body, "\n", 1)[0]
}
if body == "" {
if content.FormattedBody != "" {
body = content.FormattedBody
} else {
body = content.Body
}
}
ID := evt.ID.String()[1:] + "@" + b.domain
data := utils.
NewEmail(ID, inReplyTo, subject, from, to, body, "", nil).
Compose(b.getBotSettings().DKIMPrivateKey())
return b.mta.Send(from, to, data)
}
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()
resp, err := b.lp.GetClient().UploadMedia(req)
err := b.lp.SendFile(roomID, req, file.MsgType, utils.RelatesTo(!noThreads, parentID))
if err != nil {
b.Error(ctx, roomID, "cannot upload file %s: %v", req.FileName, err)
continue
}
_, err = b.lp.Send(roomID, &event.MessageEventContent{
MsgType: event.MsgFile,
Body: req.FileName,
URL: resp.ContentURI.CUString(),
RelatesTo: utils.RelatesTo(noThreads, parentID),
})
if err != nil {
b.Error(ctx, roomID, "cannot send uploaded file %s: %v", req.FileName, err)
}
}
}
@@ -152,3 +233,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)
}
}
}

View File

@@ -7,13 +7,18 @@ import (
func (b *Bot) handle(ctx context.Context) {
evt := eventFromContext(ctx)
err := b.lp.GetClient().MarkRead(evt.RoomID, evt.ID)
if err != nil {
b.log.Error("cannot send read receipt: %v", err)
}
content := evt.Content.AsMessage()
if content == nil {
b.Error(ctx, evt.RoomID, "cannot read message")
return
}
message := strings.TrimSpace(content.Body)
cmd := b.parseCommand(message)
cmd := b.parseCommand(message, true)
if cmd == nil {
return
}

View File

@@ -11,7 +11,10 @@ const acBotSettingsKey = "cc.etke.postmoogle.config"
// bot options keys
const (
botOptionUsers = "users"
botOptionUsers = "users"
botOptionCatchAll = "catch-all"
botOptionDKIMSignature = "dkim.pub"
botOptionDKIMPrivateKey = "dkim.pem"
)
type botSettings map[string]string
@@ -40,52 +43,48 @@ func (s botSettings) Users() []string {
return []string{value}
}
func (b *Bot) initBotUsers(envUsers []string) ([]string, error) {
// CatchAll option
func (s botSettings) CatchAll() string {
return s.Get(botOptionCatchAll)
}
// DKIMSignature (DNS TXT record)
func (s botSettings) DKIMSignature() string {
return s.Get(botOptionDKIMSignature)
}
// DKIMPrivateKey keep it secret
func (s botSettings) DKIMPrivateKey() string {
return s.Get(botOptionDKIMPrivateKey)
}
func (b *Bot) initBotUsers() ([]string, error) {
config := b.getBotSettings()
cfgUsers := config.Users()
if len(cfgUsers) > 0 {
// already migrated
return cfgUsers, nil
}
if len(envUsers) == 0 {
_, homeserver, err := b.lp.GetClient().UserID.Parse()
if err != nil {
return nil, err
}
config.Set(botOptionUsers, "@*:"+homeserver)
} else {
// Initialize from environment variable
// TODO: remove this migration later and always initialize to `"@*:"+homeserver`
config.Set(botOptionUsers, strings.Join(envUsers, " "))
}
_, homeserver, err := b.lp.GetClient().UserID.Parse()
if err != nil {
return nil, err
}
config.Set(botOptionUsers, "@*:"+homeserver)
return config.Users(), b.setBotSettings(config)
}
func (b *Bot) getBotSettings() botSettings {
cfg := b.botcfg.Get(acBotSettingsKey)
if cfg != nil {
return cfg
}
config := botSettings{}
err := b.lp.GetClient().GetAccountData(acBotSettingsKey, &config)
config, err := b.lp.GetAccountData(acBotSettingsKey)
if err != nil {
if strings.Contains(err.Error(), "M_NOT_FOUND") {
err = nil
} else {
b.log.Error("cannot get bot settings: %v", utils.UnwrapError(err))
}
b.log.Error("cannot get bot settings: %v", utils.UnwrapError(err))
}
if err == nil {
b.botcfg.Set(acBotSettingsKey, config)
if config == nil {
config = map[string]string{}
}
return config
}
func (b *Bot) setBotSettings(cfg botSettings) error {
b.botcfg.Set(acBotSettingsKey, cfg)
return utils.UnwrapError(b.lp.GetClient().SetAccountData(acBotSettingsKey, cfg))
return utils.UnwrapError(b.lp.SetAccountData(acBotSettingsKey, cfg))
}

View File

@@ -1,7 +1,6 @@
package bot
import (
"strconv"
"strings"
"maunium.net/go/mautrix/id"
@@ -14,24 +13,23 @@ const acRoomSettingsKey = "cc.etke.postmoogle.settings"
// option keys
const (
roomOptionOwner = "owner"
roomOptionMailbox = "mailbox"
roomOptionNoSender = "nosender"
roomOptionNoSubject = "nosubject"
roomOptionNoHTML = "nohtml"
roomOptionNoThreads = "nothreads"
roomOptionNoFiles = "nofiles"
roomOptionOwner = "owner"
roomOptionMailbox = "mailbox"
roomOptionNoSend = "nosend"
roomOptionNoSender = "nosender"
roomOptionNoRecipient = "norecipient"
roomOptionNoSubject = "nosubject"
roomOptionNoHTML = "nohtml"
roomOptionNoThreads = "nothreads"
roomOptionNoFiles = "nofiles"
roomOptionPassword = "password"
roomOptionSpamcheckSMTP = "spamcheck:smtp"
roomOptionSpamcheckMX = "spamcheck:mx"
roomOptionSpamlist = "spamlist"
)
type roomSettings map[string]string
// settingsOld of a room
type settingsOld struct {
Mailbox string
Owner id.UserID
NoSender bool
}
// Get option
func (s roomSettings) Get(key string) string {
return s[strings.ToLower(strings.TrimSpace(key))]
@@ -50,10 +48,22 @@ func (s roomSettings) Owner() string {
return s.Get(roomOptionOwner)
}
func (s roomSettings) Password() string {
return s.Get(roomOptionPassword)
}
func (s roomSettings) NoSend() bool {
return utils.Bool(s.Get(roomOptionNoSend))
}
func (s roomSettings) NoSender() bool {
return utils.Bool(s.Get(roomOptionNoSender))
}
func (s roomSettings) NoRecipient() bool {
return utils.Bool(s.Get(roomOptionNoRecipient))
}
func (s roomSettings) NoSubject() bool {
return utils.Bool(s.Get(roomOptionNoSubject))
}
@@ -70,52 +80,105 @@ func (s roomSettings) NoFiles() bool {
return utils.Bool(s.Get(roomOptionNoFiles))
}
// TODO: remove after migration
func (b *Bot) migrateSettings(roomID id.RoomID) {
var config settingsOld
err := b.lp.GetClient().GetRoomAccountData(roomID, acRoomSettingsKey, &config)
if err != nil {
// any error = no need to migrate
return
func (s roomSettings) SpamcheckSMTP() bool {
return utils.Bool(s.Get(roomOptionSpamcheckSMTP))
}
func (s roomSettings) SpamcheckMX() bool {
return utils.Bool(s.Get(roomOptionSpamcheckMX))
}
func (s roomSettings) Spamlist() []string {
return utils.StringSlice(s.Get(roomOptionSpamlist))
}
func (s roomSettings) migrateSpamlistSettings() {
uniq := map[string]struct{}{}
emails := utils.StringSlice(s.Get("spamlist:emails"))
localparts := utils.StringSlice(s.Get("spamlist:localparts"))
hosts := utils.StringSlice(s.Get("spamlist:hosts"))
list := utils.StringSlice(s.Get(roomOptionSpamlist))
delete(s, "spamlist:emails")
delete(s, "spamlist:localparts")
delete(s, "spamlist:hosts")
for _, email := range emails {
if email == "" {
continue
}
uniq[email] = struct{}{}
}
if config.Mailbox == "" {
return
for _, localpart := range localparts {
if localpart == "" {
continue
}
uniq[localpart+"@*"] = struct{}{}
}
cfg := roomSettings{}
cfg.Set(roomOptionMailbox, config.Mailbox)
cfg.Set(roomOptionOwner, config.Owner.String())
cfg.Set(roomOptionNoSender, strconv.FormatBool(config.NoSender))
err = b.setRoomSettings(roomID, cfg)
if err != nil {
b.log.Error("cannot migrate settings: %v", err)
for _, host := range hosts {
if host == "" {
continue
}
uniq["*@"+host] = struct{}{}
}
for _, item := range list {
if item == "" {
continue
}
uniq[item] = struct{}{}
}
spamlist := make([]string, 0, len(uniq))
for item := range uniq {
spamlist = append(spamlist, item)
}
s.Set(roomOptionSpamlist, strings.Join(spamlist, ","))
}
// ContentOptions converts room display settings to content options
func (s roomSettings) ContentOptions() *utils.ContentOptions {
return &utils.ContentOptions{
HTML: !s.NoHTML(),
Sender: !s.NoSender(),
Recipient: !s.NoRecipient(),
Subject: !s.NoSubject(),
Threads: !s.NoThreads(),
FromKey: eventFromKey,
SubjectKey: eventSubjectKey,
MessageIDKey: eventMessageIDkey,
InReplyToKey: eventInReplyToKey,
}
}
func (b *Bot) getRoomSettings(roomID id.RoomID) (roomSettings, error) {
cfg := b.cfg.Get(roomID.String())
if cfg != nil {
return cfg, nil
}
config := roomSettings{}
err := b.lp.GetClient().GetRoomAccountData(roomID, acRoomSettingsKey, &config)
if err != nil {
if strings.Contains(err.Error(), "M_NOT_FOUND") {
// Suppress `M_NOT_FOUND (HTTP 404): Room account data not found` errors.
// Until some settings are explicitly set, we don't store any.
// In such cases, just return a default (empty) settings object.
err = nil
}
} else {
b.cfg.Set(roomID.String(), config)
config, err := b.lp.GetRoomAccountData(roomID, acRoomSettingsKey)
if config == nil {
config = map[string]string{}
}
return config, utils.UnwrapError(err)
}
func (b *Bot) setRoomSettings(roomID id.RoomID, cfg roomSettings) error {
b.cfg.Set(roomID.String(), cfg)
return utils.UnwrapError(b.lp.GetClient().SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
return utils.UnwrapError(b.lp.SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
}
func (b *Bot) migrateRoomSettings(roomID id.RoomID) {
cfg, err := b.getRoomSettings(roomID)
if err != nil {
b.log.Error("cannot retrieve room settings: %v", err)
return
}
if cfg["spamlist:emails"] == "" && cfg["spamlist:localparts"] == "" && cfg["spamlist:hosts"] == "" {
return
}
cfg.migrateSpamlistSettings()
err = b.setRoomSettings(roomID, cfg)
if err != nil {
b.log.Error("cannot migrate room settings: %v", err)
}
}

View File

@@ -3,11 +3,14 @@ package bot
import (
"context"
"gitlab.com/etke.cc/go/mxidwc"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
)
func (b *Bot) initSync() {
b.lp.SetJoinPermit(b.joinPermit)
b.lp.OnEventType(
event.StateMember,
func(_ mautrix.EventSource, evt *event.Event) {
@@ -26,6 +29,16 @@ func (b *Bot) initSync() {
})
}
// joinPermit is called by linkpearl when processing "invite" events and deciding if rooms should be auto-joined or not
func (b *Bot) joinPermit(evt *event.Event) bool {
if !mxidwc.Match(evt.Sender.String(), b.allowedUsers) {
b.log.Debug("Rejecting room invitation from unallowed user: %s", evt.Sender)
return false
}
return true
}
func (b *Bot) onMembership(evt *event.Event) {
ctx := newContext(evt)

View File

@@ -20,8 +20,9 @@ import (
)
var (
mxb *bot.Bot
log *logger.Logger
mxb *bot.Bot
smtpserv *smtp.Server
log *logger.Logger
)
func main() {
@@ -38,11 +39,13 @@ func main() {
log.Debug("starting internal components...")
initSentry(cfg)
initBot(cfg)
initSMTP(cfg)
initShutdown(quit)
defer recovery()
go startBot(cfg.StatusMsg)
if err := smtp.Start(cfg.Domain, cfg.Port, cfg.LogLevel, cfg.MaxSize, mxb); err != nil {
if err := smtpserv.Start(); err != nil {
//nolint:gocritic
log.Fatal("SMTP server crashed: %v", err)
}
@@ -67,23 +70,24 @@ func initBot(cfg *config.Config) {
}
mxlog := logger.New("matrix.", cfg.LogLevel)
lp, err := linkpearl.New(&lpcfg.Config{
Homeserver: cfg.Homeserver,
Login: cfg.Login,
Password: cfg.Password,
DB: db,
Dialect: cfg.DB.Dialect,
NoEncryption: cfg.NoEncryption,
LPLogger: mxlog,
APILogger: logger.New("api.", cfg.LogLevel),
StoreLogger: logger.New("store.", cfg.LogLevel),
CryptoLogger: logger.New("olm.", cfg.LogLevel),
Homeserver: cfg.Homeserver,
Login: cfg.Login,
Password: cfg.Password,
DB: db,
Dialect: cfg.DB.Dialect,
NoEncryption: cfg.NoEncryption,
AccountDataSecret: cfg.DataSecret,
LPLogger: mxlog,
APILogger: logger.New("api.", cfg.LogLevel),
StoreLogger: logger.New("store.", cfg.LogLevel),
CryptoLogger: logger.New("olm.", cfg.LogLevel),
})
if err != nil {
// nolint // Fatal = panic, not os.Exit()
log.Fatal("cannot initialize matrix bot: %v", err)
}
mxb, err = bot.New(lp, mxlog, cfg.Prefix, cfg.Domain, cfg.Users, cfg.Admins)
mxb, err = bot.New(lp, mxlog, cfg.Prefix, cfg.Domain, cfg.Admins)
if err != nil {
// nolint // Fatal = panic, not os.Exit()
log.Fatal("cannot start matrix bot: %v", err)
@@ -91,6 +95,20 @@ func initBot(cfg *config.Config) {
log.Debug("bot has been created")
}
func initSMTP(cfg *config.Config) {
smtpserv = smtp.NewServer(&smtp.Config{
Domain: cfg.Domain,
Port: cfg.Port,
TLSCert: cfg.TLS.Cert,
TLSKey: cfg.TLS.Key,
TLSPort: cfg.TLS.Port,
TLSRequired: cfg.TLS.Required,
LogLevel: cfg.LogLevel,
MaxSize: cfg.MaxSize,
Bot: mxb,
})
}
func initShutdown(quit chan struct{}) {
listener := make(chan os.Signal, 1)
signal.Notify(listener, os.Interrupt, syscall.SIGABRT, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
@@ -114,6 +132,7 @@ func startBot(statusMsg string) {
func shutdown() {
log.Info("Shutting down...")
smtpserv.Stop()
mxb.Stop()
sentry.Flush(5 * time.Second)

View File

@@ -18,10 +18,16 @@ func New() *Config {
Domain: env.String("domain", defaultConfig.Domain),
Port: env.String("port", defaultConfig.Port),
NoEncryption: env.Bool("noencryption"),
DataSecret: env.String("data.secret", defaultConfig.DataSecret),
MaxSize: env.Int("maxsize", defaultConfig.MaxSize),
StatusMsg: env.String("statusmsg", defaultConfig.StatusMsg),
Users: env.Slice("users"),
Admins: env.Slice("admins"),
TLS: TLS{
Cert: env.String("tls.cert", defaultConfig.TLS.Cert),
Key: env.String("tls.key", defaultConfig.TLS.Key),
Required: env.Bool("tls.required"),
Port: env.String("tls.port", defaultConfig.TLS.Port),
},
Sentry: Sentry{
DSN: env.String("sentry.dsn", defaultConfig.Sentry.DSN),
},

View File

@@ -11,4 +11,7 @@ var defaultConfig = &Config{
DSN: "local.db",
Dialect: "sqlite3",
},
TLS: TLS{
Port: "587",
},
}

View File

@@ -14,6 +14,8 @@ type Config struct {
Port string
// RoomID of the admin room
LogLevel string
// DataSecret is account data secret key (password) to encrypt all account data values
DataSecret string
// NoEncryption disabled encryption support
NoEncryption bool
// Prefix for commands
@@ -22,14 +24,15 @@ type Config struct {
MaxSize int
// StatusMsg of the bot
StatusMsg string
// Users DEPRECATED holds list of allowed users (wildcards supported), e.g.: @*:example.com, @bot.*:example.com, @admin:*. Empty = homeserver only
Users []string
// Admins holds list of admin users (wildcards supported), e.g.: @*:example.com, @bot.*:example.com, @admin:*. Empty = no admins
Admins []string
// DB config
DB DB
// TLS config
TLS TLS
// Sentry config
Sentry Sentry
}
@@ -42,6 +45,14 @@ type DB struct {
Dialect string
}
// TLS config
type TLS struct {
Cert string
Key string
Port string
Required bool
}
// Sentry config
type Sentry struct {
DSN string

31
e2e/cert.pem Normal file
View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFWTCCA0GgAwIBAgIUfzdKDYiYN1Q1oZ6sNbI5TahmQZwwDQYJKoZIhvcNAQEL
BQAwOzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxFzAVBgNVBAoM
DlBvc3Rtb29nbGUgRGV2MCAXDTIyMDkxMzE0Mzc1MFoYDzIwNTEwMTI5MTQzNzUw
WjA7MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEXMBUGA1UECgwO
UG9zdG1vb2dsZSBEZXYwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDa
RwXjxG2MYhx1XETSdCBqqDat4ilhApW9impk3M6piRmeEkwTIENpYks9YVjItE9m
3OnhNDptnIrzGWUIO77xEITcFGwsqkmg3lYOdZHxCfEup9dqFwdY9jeGGQxZkLFw
nWGcaXHW+qtCBD3shmquC2xftKGUnCrX81jrpvHj4JeY03ENbvolTjLyhgQcj8Fh
KXsXBWGKOSoU/FIZjy/lhZp5sEdJz25XeT7ktbPTglZTFKL+CkSVdXnVpb1Hys2m
aiYCFr62g4wZjRdGFYHCva/55LXydFoC+VtjYJ7ifnXkV6c0iSh+vj1k+QrV+/cJ
A/kTPI2rIWFicYt09AFKf1//BjXsRfBF6DF8tj2RmZPxQfDBRvTQkhNm9FkmfNeF
KSNsRiRTb7gILs8y2R4BBMdFMQtw6JIfVueMlBAmKGkyVcSeQFY7+bS/BycxLx/Z
hFN5u3czE+rhS9Nb76ZV9AW1UpsE/8jnWk9B0lVBMz2e8xL0leq4tMDsN+zt+Esr
IYGD1lKO/iLPWPNRIKQrnb3hyS2an7LeG+VdKaYT1/EyN+qY24EF7XoeOXeHJ+tl
kPBtYrIZAMu111IyzI7A36IP6VYB9b0wPnnLBgpJAW3A3jW5W9tuMBDxHATuDHfA
UoVZh7oagPeyIYaAtzJB/7BBfYrrb190VU0FT6SyYQIDAQABo1MwUTAdBgNVHQ4E
FgQU1yjkuZAaJcrH1JMAY6iFqxoPDR8wHwYDVR0jBBgwFoAU1yjkuZAaJcrH1JMA
Y6iFqxoPDR8wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAJkus
0wHrehTe0MKditkK/gb5+YGJqywZdhhwVZdK1JlbVAch/TpMn+3tD4GtToG/pN9h
Id/jaLWl0y5/iytmupdOjcE1yLTlb9McUgBMHNbZ/QNFoTGlaeqIGTFK8QddAtYi
VaGjDqBSKsAA/Ro1k98I7PWN6wb3nfkJO4/cDixErOSz7IBcdF7WiITnlpmdl8R7
Xe2yfW6SVCbhNOsyKRUxz7hYgbak0OE1W4J6DTCdeX8Er7fMAmCjfrBQB8vQLs/Y
5+PJMk6aTq31e7SQoOUe6HCGutnB7UOUjzynkHWeojGv1WwPxT8x53L263FZhdVJ
vMfJNy1yie/qf90H7UG0LaPDF5CsLSV5iQY7pGXS/Qt1KV8OWsJpB2ZYZpQ24lwX
X5KTQEB/YM0oCaLg50FgmuqGWqj2O7l5ey69Qz7+WnsT8NuTC/uYmuTnZqndn7IO
5kWIX1uFWqqLMjirTAJQkecUIJEW324VYD8Ja0ylT8RReyR+ovZyMTS05pjbn5p4
nA5Rni6hGWqj7HYxp4vI5zA8PyTwtn7Ls9zXSva4GIfXQyJd73VM8bZUl7R4lGoN
GjdZArhEo+FPMDtFDYB8i+pBx8zZ0p6EVHWFJsuoiIwY5V9QNew2WusvuXTVSMzk
kwoU0mIWLJVQtTelX1zvQVvs4PZa+iAzkniEjb0=
-----END CERTIFICATE-----

52
e2e/key.pem Normal file
View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDaRwXjxG2MYhx1
XETSdCBqqDat4ilhApW9impk3M6piRmeEkwTIENpYks9YVjItE9m3OnhNDptnIrz
GWUIO77xEITcFGwsqkmg3lYOdZHxCfEup9dqFwdY9jeGGQxZkLFwnWGcaXHW+qtC
BD3shmquC2xftKGUnCrX81jrpvHj4JeY03ENbvolTjLyhgQcj8FhKXsXBWGKOSoU
/FIZjy/lhZp5sEdJz25XeT7ktbPTglZTFKL+CkSVdXnVpb1Hys2maiYCFr62g4wZ
jRdGFYHCva/55LXydFoC+VtjYJ7ifnXkV6c0iSh+vj1k+QrV+/cJA/kTPI2rIWFi
cYt09AFKf1//BjXsRfBF6DF8tj2RmZPxQfDBRvTQkhNm9FkmfNeFKSNsRiRTb7gI
Ls8y2R4BBMdFMQtw6JIfVueMlBAmKGkyVcSeQFY7+bS/BycxLx/ZhFN5u3czE+rh
S9Nb76ZV9AW1UpsE/8jnWk9B0lVBMz2e8xL0leq4tMDsN+zt+EsrIYGD1lKO/iLP
WPNRIKQrnb3hyS2an7LeG+VdKaYT1/EyN+qY24EF7XoeOXeHJ+tlkPBtYrIZAMu1
11IyzI7A36IP6VYB9b0wPnnLBgpJAW3A3jW5W9tuMBDxHATuDHfAUoVZh7oagPey
IYaAtzJB/7BBfYrrb190VU0FT6SyYQIDAQABAoICAFbGgXrpLjvvDS1C/0FDVVfR
g9tg295rLqhpjJ6Igcg4buy+jWvVGbedkhfWyXsQ47ga/we+wbOt1jeK0vWQ+vnO
/WKY4+Bl2luPyFp7NwfCUhWgOC0+9nbRe4VeE+DPexswFgweh1lV0huuoAeV8Hc8
p1rs7oesBgRQA/u7JNLZCKuC86DJi7yk9/Aegyfvpos/+GVQiFRxIV+yZ9ktaXcv
xBe4kr6vLkisNOqrqc/eMv9YZuvjnRxl1YewGi2eXF9aN74A2NSqO1o1ExmTl0Ca
NIl9+S9oPhiMlV5OnRuh9rBOgHSMSoMIklPABiTHxI/a+nxBSHjODm8agLx//Lqr
Deqhl9yOB1IvEODT/4GJVLwGLZCFS+dD390p3a2+NBHAKM922IXexWxJkdxiEwC3
3gicHvfGwU1eNPkbMJqb3QyJto+is9be1F3npLyffVwczJHeH5wLUOPexPNe4aRd
Ag+++tnVf36luX7mzwME35vqOoDmdcrwmxDK3IsnOnMCZZbEiOQ3oqQwWoGK7OiL
6adzoYTypdkdiozJVTz0xLxvZuUdwUydZswUVJeClEgyNk3c5Ln+jIjUwSMf9IqS
HiiZOT9fYxgctMQ4mWeutABV0XvMaq3B2kJuyGdln5firAMDwzmvR4LeS0EW+lKa
OxQQAqfBsqcBmc6G8GV5AoIBAQD1j7FEk2IeOqRaGbtH25VI4BkTw8KTuzK+5iHK
Hjs5F8MPf2IbrjmpHTlANyLJ+IToc5LPDvy4TJRFOQKqge0BiZpS7ggp956y9Oyn
1vtMkzHUrjHyjU9JzDMDe1+OrAGa/YwPWVZnjvBijS0OWmlYrnto5ZsN5XX9yJ2w
7CZZPbXAbC9udtD9nc6fVXL3TB9Go/Wury8uwuAfah6ID9Jz6ta5kvhu1GdH7vKx
zIXL8GS6qYPCPh8fimM02XlfONTEnFA2L6CW2eAKCs2GoF1kW8gWYMWErkGsR/40
hBqNHBPphTuG7sypPpsHkmKE3tz+A0W5K+E6S9qlrMCF40wzAoIBAQDjjmpVoB7/
IWDMsSf71LcxVgjZIM3bkL7misgL61WUk9Zq5Om5gTJd44w5nzdh56dGxEuQi3Br
Du43NnmPiPBgtgvtgWxQx2uDIE2qaj8AQz2vNOMQaTDGZYNE0Wk65yg0Qn/CI/D5
VL+O6n+CbF8duMXIuUbkakFp76BvfJqLAw9/YUp7AJTTtKjQbNum1X0mwbF9eAb7
KIMciUrQ92jZ0aPobXlZ2BXOcbTxF3alxh0NPMCGEy8LV+2X+l3NFLuthGVKEyzL
8vr/dtJykLqzLmW16VW5wOR3mdJe79DnOeW7IAPAPJSpZIdGJgnvAWmjjFbmZoyN
XeBFjxeQDLMbAoIBAAXYXcfcGkHN84uRgTu8plkNvIsT5dXOZu7UW4mMHqzFPAdq
aNNv2j+ESpCUv2c/WyqNVblICgv5Bq5/JOkaHqIivGGs+NTG6CgqXFfCbkjsWAtP
+jBj3LdM/QngLe4fegpObr8OyVe9t0shQSlOTaOmw5lDneU+yQg5dkd315HYPjNO
X/KpMWBYGUsBpbLtRPFRhc+aq+zZBqy4wfFLLx9DP8k7Dl7U/4Be17gTvjuUhVFM
J66/+82sZaAkbRcvKyFi9yXTDGeK2CJlD29c8dwpsyGVPB5kZ3vKWuq1GkyxlmfA
nCIiHQ0KSHZtrZqsE2aYfVhjCwFwPMwkyJBnULECggEBAI8vj+1tIdP8FsL2PmiS
AuvCbTLjF2WvgM/kR9hoLqOdGvCMNh2KFD1L73Jaoyix8WnwHnRHqWdUL8UCPB97
VXYlVwANzjBOK3KJIxW2YQozRV838iDjb9OHEMB+3K45weyQY3+vyfO5xgeRusZA
luFG4P1ayCb2TU7xf4xnZX9PX7MRbyJSzVMhfJAZz2T9NRDsFFkU65+icE3GfhNb
etiEt5SLPi6wb5hFqEuELh5FuQYZUjOLYrDKlTBqVBe1khqvEmU4B5oVvKZ/CBEa
zL/u1AVRW6XUoG45lkwyrsqwfcPvcyzFce3c2dnkGaAQzX4h0pSLtqlFhtd+IJ99
vj0CggEBAJfdlbH2dlJB16DiVKFucnIdX4y+h4uv2ZlsVM7XUunsvnbN+PL7QYqD
zgmVAV9/EKVJGxoFRiGe5rDTXP68PAELGDcetCPQGdYCVKYVlvOjjGDSvZwH+Ca1
9Jig9gmW2ezdGkqqU4cptVdrmN07R+HNEp9ZnaSUVcLywA1jeCkRrsO55s7++VNC
nHDfkEWwpRXMbcdFXnEC6IomOBxtY9LDCmWTJmWXhJqgPr9WDSMB37xlfxNUttap
Ozzugp4UCywam1MgsJB5mueGN4ExfTQ5mnJw8TNmtuxaWeea+Lu7lwQrwwia30nL
G9eNyx0/usGECLor9sfMmhy55arHNv8=
-----END PRIVATE KEY-----

34
go.mod
View File

@@ -2,43 +2,53 @@ module gitlab.com/etke.cc/postmoogle
go 1.18
// replace gitlab.com/etke.cc/linkpearl => ../linkpearl
require (
git.sr.ht/~xn/cache/v2 v2.0.0
github.com/emersion/go-msgauth v0.6.6
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.15.0
github.com/gabriel-vasile/mimetype v1.4.1
github.com/getsentry/sentry-go v0.13.0
github.com/jhillyerd/enmime v0.10.0
github.com/lib/pq v1.10.6
github.com/mattn/go-sqlite3 v1.14.14
github.com/lib/pq v1.10.7
github.com/mattn/go-sqlite3 v1.14.15
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39
gitlab.com/etke.cc/go/env v1.0.0
gitlab.com/etke.cc/go/logger v1.1.0
gitlab.com/etke.cc/linkpearl v0.0.0-20220826133247-10dcaedb8085
golang.org/x/net v0.0.0-20220822230855-b0a4917ee28c
maunium.net/go/mautrix v0.12.0
gitlab.com/etke.cc/go/mxidwc v1.0.0
gitlab.com/etke.cc/go/secgen v1.1.1
gitlab.com/etke.cc/go/trysmtp v1.0.0
gitlab.com/etke.cc/go/validator v1.0.2
gitlab.com/etke.cc/linkpearl v0.0.0-20221012104738-a977907db8b9
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b
maunium.net/go/mautrix v0.12.2
)
require (
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.12 // indirect
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rs/zerolog v1.27.0 // indirect
github.com/rs/zerolog v1.28.0 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.4.12 // indirect
golang.org/x/crypto v0.0.0-20220824171710-5757bc0c5503 // indirect
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
github.com/yuin/goldmark v1.5.2 // indirect
golang.org/x/crypto v0.0.0-20221012134737-56aed061732a // indirect
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43 // indirect
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/maulogger/v2 v2.3.2 // indirect

77
go.sum
View File

@@ -1,15 +1,23 @@
git.sr.ht/~xn/cache/v2 v2.0.0 h1:aYzwGDyVIzjCl2yqcxZjprnu++Q3BmUQeK2agqvcQt8=
git.sr.ht/~xn/cache/v2 v2.0.0/go.mod h1:HIPSMiDudQ483tRDup586e0YZdwMySIZFWXMPwYMuV8=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-message v0.11.2/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-milter v0.3.3/go.mod h1:ablHK0pbLB83kMFBznp/Rj8aV+Kc3jw8cxzzmCNLIOY=
github.com/emersion/go-msgauth v0.6.6 h1:buv5lL8v/3v4RpHnQFS2IPhE3nxSRX+AxnrEJbDbHhA=
github.com/emersion/go-msgauth v0.6.6/go.mod h1:A+/zaz9bzukLM6tRWRgJ3BdrBi+TFKTvQ3fGMFOI9SM=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
@@ -24,12 +32,15 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
github.com/jhillyerd/enmime v0.10.0/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -39,8 +50,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
@@ -48,15 +61,18 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39 h1:2by0+lF6NfaNWhlpsv1DfBQzwbAyYUPIsMWYapek/Sk=
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39/go.mod h1:idX/fPqwjX31YMTF2iIpEpNApV2YbQhSFr4iIhJaqp4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs=
github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -64,31 +80,48 @@ github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.4.12 h1:6hffw6vALvEDqJ19dOJvJKOoAOKe4NDaTqvd2sktGN0=
github.com/yuin/goldmark v1.4.12/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
gitlab.com/etke.cc/go/env v1.0.0 h1:J98BwzOuELnjsVPFvz5wa79L7IoRV9CmrS41xLYXtSw=
gitlab.com/etke.cc/go/env v1.0.0/go.mod h1:e1l4RM5MA1sc0R1w/RBDAESWRwgo5cOG9gx8BKUn2C4=
gitlab.com/etke.cc/go/logger v1.1.0 h1:Yngp/DDLmJ0jJNLvLXrfan5Gi5QV+r7z6kCczTv8t4U=
gitlab.com/etke.cc/go/logger v1.1.0/go.mod h1:8Vw5HFXlZQ5XeqvUs5zan+GnhrQyYtm/xe+yj8H/0zk=
gitlab.com/etke.cc/linkpearl v0.0.0-20220826133247-10dcaedb8085 h1:Qr6o2ERYCEkZAOhvIl4cwgUOHBn4sHq+yReCMjg8kvE=
gitlab.com/etke.cc/linkpearl v0.0.0-20220826133247-10dcaedb8085/go.mod h1:CqwzwxVogKG6gDWTPTen3NyWbTESg42jxoTfXXwDGKQ=
golang.org/x/crypto v0.0.0-20220824171710-5757bc0c5503 h1:vJ2V3lFLg+bBhgroYuRfyN583UzVveQmIXjc8T/y3to=
golang.org/x/crypto v0.0.0-20220824171710-5757bc0c5503/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
gitlab.com/etke.cc/go/mxidwc v1.0.0 h1:6EAlJXvs3nU4RaMegYq6iFlyVvLw7JZYnZmNCGMYQP0=
gitlab.com/etke.cc/go/mxidwc v1.0.0/go.mod h1:E/0kh45SAN9+ntTG0cwkAEKdaPxzvxVmnjwivm9nmz8=
gitlab.com/etke.cc/go/secgen v1.1.1 h1:RmKOki725HIhWJHzPtAc9X4YvBneczndchpMgoDkE8w=
gitlab.com/etke.cc/go/secgen v1.1.1/go.mod h1:3pJqRGeWApzx7qXjABqz2o2SMCNpKSZao/gXVdasqE8=
gitlab.com/etke.cc/go/trysmtp v1.0.0 h1:f/7gSmzohKniVeLSLevI+ZsySYcPUGkT9cRlOTwjOr8=
gitlab.com/etke.cc/go/trysmtp v1.0.0/go.mod h1:KqRuIB2IPElEEbAxXmFyKtm7S5YiuEb4lxwWthccqyE=
gitlab.com/etke.cc/go/validator v1.0.2 h1:7iVHG9sh1Hz6YcNT+tTLDm60B2PVSz6eh9nh6KOx7LI=
gitlab.com/etke.cc/go/validator v1.0.2/go.mod h1:3vdssRG4LwgdTr9IHz9MjGSEO+3/FO9hXPGMuSeweJ8=
gitlab.com/etke.cc/linkpearl v0.0.0-20221012104738-a977907db8b9 h1:CJyYRf4KGmaFJDBJS5NXkt9v5ICi/AHrJIIOinQD/os=
gitlab.com/etke.cc/linkpearl v0.0.0-20221012104738-a977907db8b9/go.mod h1:HkUHUkhbkDueEJVc7h/zBfz2hjhl4xxjQKv9Itrdf9k=
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20221012134737-56aed061732a h1:NmSIgad6KjE6VvHciPZuNRTKxGhlPfD6OA87W/PLkqg=
golang.org/x/crypto v0.0.0-20221012134737-56aed061732a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220822230855-b0a4917ee28c h1:JVAXQ10yGGVbSyoer5VILysz6YKjdNT2bsvlayjqhes=
golang.org/x/net v0.0.0-20220822230855-b0a4917ee28c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b h1:tvrvnPFcdzp294diPnrdZZZ8XUt2Tyj7svb7X52iDuU=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43 h1:OK7RB6t2WQX54srQQYSXMW8dF5C6/8+oA/s5QBmmto4=
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -100,5 +133,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/mautrix v0.12.0 h1:jyT1TkJBIRJ7+OW7NhmMHmnEEBLsQe9ml+FYwSLhlaU=
maunium.net/go/mautrix v0.12.0/go.mod h1:hHvNi5iKVAiI2MAdAeXHtP4g9BvNEX2rsQpSF/x6Kx4=
maunium.net/go/mautrix v0.12.2 h1:HuIDgigR6VY2QUPyZADCwn8UZWYAqi31a77qd1jMPA4=
maunium.net/go/mautrix v0.12.2/go.mod h1:bCw45Qx/m9qsz7eazmbe7Rzq5ZbTPzwRE1UgX2S9DXs=

48
smtp/msa.go Normal file
View File

@@ -0,0 +1,48 @@
package smtp
import (
"context"
"errors"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/postmoogle/utils"
)
// msa is mail submission agent, implements smtp.Backend
type msa struct {
log *logger.Logger
domain string
bot Bot
mta utils.MTA
}
func (m *msa) newSession(from string, incoming bool) *msasession {
return &msasession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
mta: m.mta,
from: from,
incoming: incoming,
log: m.log,
bot: m.bot,
domain: m.domain,
}
}
func (m *msa) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
if !utils.AddressValid(username) {
return nil, errors.New("please, provide an email address")
}
if !m.bot.AllowAuth(username, password) {
return nil, errors.New("email or password is invalid")
}
return m.newSession(username, false), nil
}
func (m *msa) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
return m.newSession("", true), nil
}

121
smtp/msasession.go Normal file
View File

@@ -0,0 +1,121 @@
package smtp
import (
"context"
"errors"
"io"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"github.com/jhillyerd/enmime"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/validator"
"gitlab.com/etke.cc/postmoogle/utils"
)
// msasession represents an SMTP-submission session.
// This can be used in 2 directions:
// - receiving emails from remote servers, in which case: `incoming = true`
// - sending emails from local users, in which case: `incoming = false`
type msasession struct {
log *logger.Logger
bot Bot
mta utils.MTA
domain string
ctx context.Context
incoming bool
to string
from string
}
func (s *msasession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
if !utils.AddressValid(from) {
return errors.New("please, provide email address")
}
if s.incoming {
s.from = from
s.log.Debug("mail from %s, options: %+v", from, opts)
}
return nil
}
func (s *msasession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.to = to
if s.incoming {
if utils.Hostname(to) != s.domain {
s.log.Debug("wrong domain of %s", to)
return smtp.ErrAuthRequired
}
roomID, ok := s.bot.GetMapping(utils.Mailbox(to))
if !ok {
s.log.Debug("mapping for %s not found", to)
return smtp.ErrAuthRequired
}
validations := s.bot.GetIFOptions(roomID)
if !s.validate(validations) {
return smtp.ErrAuthRequired
}
}
s.log.Debug("mail to %s", to)
return nil
}
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 {
s.log.Warn("attachment error: %v", err)
}
file := utils.NewFile(attachment.FileName, attachment.Content)
files = append(files, file)
}
return files
}
func (s *msasession) validate(options utils.IncomingFilteringOptions) bool {
enforce := validator.Enforce{
Email: true,
MX: options.SpamcheckMX(),
SMTP: options.SpamcheckMX(),
}
v := validator.New(options.Spamlist(), enforce, s.to, s.log)
return v.Email(s.from)
}
func (s *msasession) Data(r io.Reader) error {
parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r)
if err != nil {
return err
}
files := s.parseAttachments(eml.Attachments)
email := utils.NewEmail(
eml.GetHeader("Message-Id"),
eml.GetHeader("In-Reply-To"),
eml.GetHeader("Subject"),
s.from,
s.to,
eml.Text,
eml.HTML,
files)
return s.bot.Send2Matrix(s.ctx, email, s.incoming)
}
func (s *msasession) Reset() {}
func (s *msasession) Logout() error {
return nil
}

60
smtp/mta.go Normal file
View File

@@ -0,0 +1,60 @@
package smtp
import (
"context"
"io"
"strings"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/go/trysmtp"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// Bot interface to send emails into matrix
type Bot interface {
AllowAuth(string, string) bool
GetMapping(string) (id.RoomID, bool)
GetIFOptions(id.RoomID) utils.IncomingFilteringOptions
Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) 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 := trysmtp.Connect(from, to)
if err != nil {
m.log.Error("cannot connect to SMTP server of %s: %v", to, err)
return err
}
defer conn.Close()
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
}

View File

@@ -1,56 +1,128 @@
package smtp
import (
"context"
"crypto/tls"
"net"
"os"
"time"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"gitlab.com/etke.cc/go/logger"
)
type backend struct {
log *logger.Logger
domain string
client Client
type Config struct {
Domain string
Port string
TLSCert string
TLSKey string
TLSPort string
TLSRequired bool
LogLevel string
MaxSize int
Bot Bot
}
func (b *backend) newSession() *session {
return &session{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
log: b.log,
domain: b.domain,
client: b.client,
}
type Server struct {
log *logger.Logger
msa *smtp.Server
errs chan error
port string
tlsPort string
tlsCfg *tls.Config
}
func (b *backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
return nil, smtp.ErrAuthUnsupported
}
func (b *backend) 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{
// NewServer creates new SMTP server
func NewServer(cfg *Config) *Server {
log := logger.New("smtp/msa.", cfg.LogLevel)
sender := NewMTA(cfg.LogLevel)
receiver := &msa{
log: log,
domain: domain,
client: client,
mta: sender,
bot: cfg.Bot,
domain: cfg.Domain,
}
s := smtp.NewServer(be)
s.Addr = ":" + port
s.Domain = domain
s.AuthDisabled = true
receiver.bot.SetMTA(sender)
s := smtp.NewServer(receiver)
s.Domain = cfg.Domain
s.ReadTimeout = 10 * time.Second
s.WriteTimeout = 10 * time.Second
s.MaxMessageBytes = maxSize * 1024 * 1024
s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024
s.AllowInsecureAuth = !cfg.TLSRequired
s.EnableREQUIRETLS = cfg.TLSRequired
s.EnableSMTPUTF8 = true
if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
s.Debug = os.Stdout
}
log.Info("Starting SMTP server on %s:%s", domain, port)
return s.ListenAndServe()
server := &Server{
msa: s,
log: log,
port: cfg.Port,
tlsPort: cfg.TLSPort,
}
server.loadTLSConfig(cfg.TLSCert, cfg.TLSKey)
return server
}
// Start SMTP server
func (s *Server) Start() error {
s.errs = make(chan error, 1)
go s.listen(s.port, nil)
if s.tlsCfg != nil {
go s.listen(s.tlsPort, s.tlsCfg)
}
return <-s.errs
}
// Stop SMTP server
func (s *Server) Stop() {
err := s.msa.Close()
if err != nil {
s.log.Error("cannot stop SMTP server properly: %v", err)
}
s.log.Info("SMTP server has been stopped")
}
func (s *Server) listen(port string, tlsCfg *tls.Config) {
var l net.Listener
var err error
if tlsCfg != nil {
l, err = tls.Listen("tcp", ":"+port, tlsCfg)
} else {
l, err = net.Listen("tcp", ":"+port)
}
if err != nil {
s.log.Error("cannot start listener on %s: %v", port, err)
s.errs <- err
return
}
s.log.Info("Starting SMTP server on port %s", port)
err = s.msa.Serve(l)
if err != nil {
s.log.Error("cannot start SMTP server on %s: %v", port, err)
s.errs <- err
close(s.errs)
}
}
func (s *Server) loadTLSConfig(cert, key string) {
if cert == "" || key == "" {
s.log.Warn("SSL certificate is not provided")
return
}
tlsCert, err := tls.LoadX509KeyPair(cert, key)
if err != nil {
s.log.Error("cannot load SSL certificate: %v", err)
return
}
s.tlsCfg = &tls.Config{Certificates: []tls.Certificate{tlsCert}}
s.msa.TLSConfig = s.tlsCfg
}

View File

@@ -1,94 +0,0 @@
package smtp
import (
"context"
"io"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"github.com/jhillyerd/enmime"
"gitlab.com/etke.cc/go/logger"
"gitlab.com/etke.cc/postmoogle/utils"
)
type session struct {
log *logger.Logger
domain string
client Client
ctx context.Context
to string
from string
}
func (s *session) 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 {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
if utils.Hostname(to) != s.domain {
s.log.Debug("wrong domain of %s", to)
return smtp.ErrAuthRequired
}
_, ok := s.client.GetMapping(utils.Mailbox(to))
if !ok {
s.log.Debug("mapping for %s not found", to)
return smtp.ErrAuthRequired
}
s.to = to
s.log.Debug("mail to %s", to)
return nil
}
func (s *session) parseAttachments(parts []*enmime.Part) []*utils.File {
files := make([]*utils.File, 0, len(parts))
for _, attachment := range parts {
for _, err := range attachment.Errors {
s.log.Warn("attachment error: %v", err)
}
file := utils.NewFile(attachment.FileName, attachment.ContentType, attachment.Content)
files = append(files, file)
}
return files
}
func (s *session) Data(r io.Reader) error {
parser := enmime.NewParser()
eml, err := parser.ReadEnvelope(r)
if err != nil {
return err
}
attachments := s.parseAttachments(eml.Attachments)
inlines := s.parseAttachments(eml.Inlines)
files := make([]*utils.File, 0, len(attachments)+len(inlines))
files = append(files, attachments...)
files = append(files, inlines...)
email := utils.NewEmail(
eml.GetHeader("Message-Id"),
eml.GetHeader("In-Reply-To"),
eml.GetHeader("Subject"),
s.from,
s.to,
eml.Text,
eml.HTML,
files)
return s.client.Send(s.ctx, email)
}
func (s *session) Reset() {}
func (s *session) Logout() error {
return nil
}

View File

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

25
utils/command.go Normal file
View File

@@ -0,0 +1,25 @@
package utils
import (
"fmt"
"strings"
)
// ErrInvalidArgs returned when a command's arguments are invalid
var ErrInvalidArgs = fmt.Errorf("invalid arguments")
// ParseSend parses "!pm send" command, returns to, subject, body, err
func ParseSend(commandSlice []string) (string, string, string, error) {
message := strings.Join(commandSlice, " ")
lines := strings.Split(message, "\n")
if len(lines) < 3 {
return "", "", "", ErrInvalidArgs
}
commandSlice = strings.Split(lines[0], " ")
to := commandSlice[1]
subject := lines[1]
body := strings.Join(lines[2:], "\n")
return to, subject, body, nil
}

View File

@@ -1,7 +1,34 @@
package utils
import (
"crypto"
"crypto/x509"
"encoding/pem"
"net/mail"
"strings"
"time"
"github.com/emersion/go-msgauth/dkim"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
// MTA is mail transfer agent
type MTA interface {
Send(from, to, data string) error
}
// IncomingFilteringOptions for incoming mail
type IncomingFilteringOptions interface {
SpamcheckSMTP() bool
SpamcheckMX() bool
Spamlist() []string
}
// Email object
type Email struct {
Date string
MessageID string
InReplyTo string
From string
@@ -12,9 +39,32 @@ type Email struct {
Files []*File
}
// ContentOptions represents settings that specify how an email is to be converted to a Matrix message
type ContentOptions struct {
// On/Off
Sender bool
Recipient bool
Subject bool
HTML bool
Threads bool
// Keys
MessageIDKey string
InReplyToKey string
SubjectKey string
FromKey string
}
// AddressValid checks if email address is valid
func AddressValid(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}
// NewEmail constructs Email object
func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files []*File) *Email {
email := &Email{
Date: time.Now().UTC().Format(time.RFC1123Z),
MessageID: messageID,
InReplyTo: inReplyTo,
From: from,
@@ -35,3 +85,125 @@ func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files
return email
}
// Mailbox returns postmoogle's mailbox, parsing it from FROM (if incoming=false) or TO (incoming=true)
func (e *Email) Mailbox(incoming bool) string {
if incoming {
return Mailbox(e.To)
}
return Mailbox(e.From)
}
// Content converts the email object to a Matrix event content
func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Content {
var text strings.Builder
if options.Sender {
text.WriteString("From: ")
text.WriteString(e.From)
text.WriteString("\n")
}
if options.Recipient {
text.WriteString("To: ")
text.WriteString(e.To)
text.WriteString("\n")
}
if options.Subject {
text.WriteString("# ")
text.WriteString(e.Subject)
text.WriteString("\n\n")
}
if e.HTML != "" && options.HTML {
text.WriteString(format.HTMLToMarkdown(e.HTML))
} else {
text.WriteString(e.Text)
}
parsed := format.RenderMarkdown(text.String(), true, true)
parsed.RelatesTo = RelatesTo(options.Threads, threadID)
content := event.Content{
Raw: map[string]interface{}{
options.MessageIDKey: e.MessageID,
options.InReplyToKey: e.InReplyTo,
options.SubjectKey: e.Subject,
options.FromKey: e.From,
},
Parsed: parsed,
}
return &content
}
// Compose converts the email object to a string (to be used for delivery via SMTP) and possibly DKIM-signs it
func (e *Email) Compose(privkey string) string {
var data strings.Builder
domain := strings.SplitN(e.From, "@", 2)[1]
data.WriteString("Content-Type: text/plain; charset=\"UTF-8\"")
data.WriteString("\r\n")
data.WriteString("Content-Transfer-Encoding: 8BIT")
data.WriteString("\r\n")
data.WriteString("From: ")
data.WriteString(e.From)
data.WriteString("\r\n")
data.WriteString("To: ")
data.WriteString(e.To)
data.WriteString("\r\n")
data.WriteString("Message-Id: ")
data.WriteString(e.MessageID)
data.WriteString("\r\n")
data.WriteString("Date: ")
data.WriteString(e.Date)
data.WriteString("\r\n")
if e.InReplyTo != "" {
data.WriteString("In-Reply-To: ")
data.WriteString(e.InReplyTo)
data.WriteString("\r\n")
}
data.WriteString("Subject: ")
data.WriteString(e.Subject)
data.WriteString("\r\n")
data.WriteString("\r\n")
data.WriteString(e.Text)
data.WriteString("\r\n")
return e.sign(domain, privkey, data)
}
func (e *Email) sign(domain, privkey string, data strings.Builder) string {
if privkey == "" {
return data.String()
}
pemblock, _ := pem.Decode([]byte(privkey))
if pemblock == nil {
return data.String()
}
parsedkey, err := x509.ParsePKCS8PrivateKey(pemblock.Bytes)
if err != nil {
return data.String()
}
signer := parsedkey.(crypto.Signer)
options := &dkim.SignOptions{
Domain: domain,
Selector: "postmoogle",
Signer: signer,
}
var msg strings.Builder
err = dkim.Sign(&msg, strings.NewReader(data.String()), options)
if err != nil {
return data.String()
}
return msg.String()
}

View File

@@ -2,30 +2,37 @@ package utils
import (
"bytes"
"strings"
"github.com/gabriel-vasile/mimetype"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
)
type File struct {
Name string
Type string
MsgType event.MessageType
Length int
Content []byte
}
func NewFile(name, contentType string, content []byte) *File {
func NewFile(name string, content []byte) *File {
file := &File{
Name: name,
Type: contentType,
Content: content,
}
file.Length = len(content)
mtype := mimetype.Detect(content)
file.Type = mtype.String()
file.MsgType = mimeMsgType(file.Type)
return file
}
func (f *File) Convert() mautrix.ReqUploadMedia {
return mautrix.ReqUploadMedia{
func (f *File) Convert() *mautrix.ReqUploadMedia {
return &mautrix.ReqUploadMedia{
ContentBytes: f.Content,
Content: bytes.NewReader(f.Content),
ContentLength: int64(f.Length),
@@ -33,3 +40,23 @@ func (f *File) Convert() mautrix.ReqUploadMedia {
FileName: f.Name,
}
}
func mimeMsgType(mime string) event.MessageType {
if mime == "" {
return event.MsgFile
}
if !strings.Contains(mime, "/") {
return event.MsgFile
}
msection := strings.Split(mime, "/")[0]
switch msection {
case "image":
return event.MsgImage
case "video":
return event.MsgVideo
case "audio":
return event.MsgAudio
default:
return event.MsgFile
}
}

View File

@@ -6,26 +6,65 @@ import (
"maunium.net/go/mautrix/id"
)
// RelatesTo block of matrix event content
func RelatesTo(noThreads bool, parentID id.EventID) *event.RelatesTo {
// RelatesTo returns relation object of a matrix event (either threads or reply-to)
func RelatesTo(threads bool, parentID id.EventID) *event.RelatesTo {
if parentID == "" {
return nil
}
if noThreads {
if threads {
return &event.RelatesTo{
InReplyTo: &event.InReplyTo{
EventID: parentID,
},
Type: event.RelThread,
EventID: parentID,
}
}
return &event.RelatesTo{
Type: event.RelThread,
EventID: parentID,
InReplyTo: &event.InReplyTo{
EventID: parentID,
},
}
}
// EventParent returns parent event ID (either from thread or from reply-to relation)
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) {

View File

@@ -1,104 +0,0 @@
package utils
import (
"fmt"
"regexp"
"strings"
)
// WildcardMXIDsToRegexes converts a list of wildcard patterns to a list of regular expressions
func WildcardMXIDsToRegexes(wildCardPatterns []string) ([]*regexp.Regexp, error) {
regexPatterns := make([]*regexp.Regexp, len(wildCardPatterns))
for idx, wildCardPattern := range wildCardPatterns {
regex, err := parseMXIDWildcard(wildCardPattern)
if err != nil {
return nil, fmt.Errorf("failed to parse allowed user rule `%s`: %s", wildCardPattern, err)
}
regexPatterns[idx] = regex
}
return regexPatterns, nil
}
// Match tells if the given user id is allowed to use the bot, according to the given whitelist
func Match(userID string, allowed []*regexp.Regexp) bool {
for _, regex := range allowed {
if regex.MatchString(userID) {
return true
}
}
return false
}
// parseMXIDWildcard parses a user whitelisting wildcard rule and returns a regular expression which corresponds to it
//
// Example conversion: `@bot.*.something:*.example.com` -> `^bot\.([^:@]*)\.something:([^:@]*)\.example.com$`
// Example of recognized wildcard patterns: `@someone:example.com`, `@*:example.com`, `@bot.*:example.com`, `@someone:*`, `@someone:*.example.com`
//
// The `*` wildcard character is normally interpretted as "a number of literal characters or an empty string".
// Our implementation below matches this (yielding `([^:@])*`), which could provide a slightly suboptimal regex in these cases:
// - `@*:example.com` -> `^@([^:@])*:example\.com$`, although `^@([^:@])+:example\.com$` would be preferable
// - `@someone:*` -> `@someone:([^:@])*$`, although `@someone:([^:@])+$` would be preferable
// When it's a bare wildcard (`*`, instead of `*.example.com`) we likely prefer to yield a regex that matches **at least one character**.
// This probably doesn't matter because mxids that we'll match against are all valid and fully complete.
func parseMXIDWildcard(wildCardRule string) (*regexp.Regexp, error) {
if !strings.HasPrefix(wildCardRule, "@") {
return nil, fmt.Errorf("rules need to be fully-qualified, starting with a @")
}
remainingRule := wildCardRule[1:]
if strings.Contains(remainingRule, "@") {
return nil, fmt.Errorf("rules cannot contain more than one @")
}
parts := strings.Split(remainingRule, ":")
if len(parts) != 2 {
return nil, fmt.Errorf("expected exactly 2 parts in the rule, separated by `:`")
}
localPart := parts[0]
localPartPattern, err := getRegexPatternForPart(localPart)
if err != nil {
return nil, fmt.Errorf("failed to convert local part `%s` to regex: %s", localPart, err)
}
domainPart := parts[1]
domainPartPattern, err := getRegexPatternForPart(domainPart)
if err != nil {
return nil, fmt.Errorf("failed to convert domain part `%s` to regex: %s", domainPart, err)
}
finalPattern := fmt.Sprintf("^@%s:%s$", localPartPattern, domainPartPattern)
regex, err := regexp.Compile(finalPattern)
if err != nil {
return nil, fmt.Errorf("failed to compile regex `%s`: %s", finalPattern, err)
}
return regex, nil
}
func getRegexPatternForPart(part string) (string, error) {
if part == "" {
return "", fmt.Errorf("rejecting empty part")
}
var pattern strings.Builder
for _, rune := range part {
if rune == '*' {
// We match everything except for `:` and `@`, because that would be an invalid MXID anyway.
//
// If the whole part is `*` (only) instead of merely containing `*` within it,
// we may also consider replacing it with `([^:@]+)` (+, instead of *).
// See parseMXIDWildcard for notes about this.
pattern.WriteString("([^:@]*)")
continue
}
pattern.WriteString(regexp.QuoteMeta(string(rune)))
}
return pattern.String(), nil
}

View File

@@ -1,221 +0,0 @@
package utils
import "testing"
func TestRuleToRegex(t *testing.T) {
type testDataDefinition struct {
name string
checkedValue string
expectedResult string
expectedError bool
}
tests := []testDataDefinition{
{
name: "simple pattern without wildcards succeeds",
checkedValue: "@someone:example.com",
expectedResult: `^@someone:example\.com$`,
expectedError: false,
},
{
name: "pattern with wildcard as the whole local part succeeds",
checkedValue: "@*:example.com",
expectedResult: `^@([^:@]*):example\.com$`,
expectedError: false,
},
{
name: "pattern with wildcard within the local part succeeds",
checkedValue: "@bot.*.something:example.com",
expectedResult: `^@bot\.([^:@]*)\.something:example\.com$`,
expectedError: false,
},
{
name: "pattern with wildcard as the whole domain part succeeds",
checkedValue: "@someone:*",
expectedResult: `^@someone:([^:@]*)$`,
expectedError: false,
},
{
name: "pattern with wildcard within the domain part succeeds",
checkedValue: "@someone:*.organization.com",
expectedResult: `^@someone:([^:@]*)\.organization\.com$`,
expectedError: false,
},
{
name: "pattern with wildcard in both parts succeeds",
checkedValue: "@*:*",
expectedResult: `^@([^:@]*):([^:@]*)$`,
expectedError: false,
},
{
name: "pattern that does not appear fully-qualified fails",
checkedValue: "someone:example.com",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern that does not appear fully-qualified fails",
checkedValue: "@someone",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern with empty domain part fails",
checkedValue: "@someone:",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern with empty local part fails",
checkedValue: "@:example.com",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern with multiple @ fails",
checkedValue: "@someone@someone:example.com",
expectedResult: ``,
expectedError: true,
},
{
name: "pattern with multiple : fails",
checkedValue: "@someone:someone:example.com",
expectedResult: ``,
expectedError: true,
},
}
for _, testData := range tests {
func(testData testDataDefinition) {
t.Run(testData.name, func(t *testing.T) {
actualResult, err := parseMXIDWildcard(testData.checkedValue)
if testData.expectedError {
if err != nil {
return
}
t.Errorf("expected an error, but did not get one")
}
if err != nil {
t.Errorf("did not expect an error, but got one: %s", err)
}
if actualResult.String() == testData.expectedResult {
return
}
t.Errorf(
"Expected `%s` to yield `%s`, not `%s`",
testData.checkedValue,
testData.expectedResult,
actualResult.String(),
)
})
}(testData)
}
}
func TestMatch(t *testing.T) {
type testDataDefinition struct {
name string
checkedValue string
allowedUsers []string
expectedResult bool
}
tests := []testDataDefinition{
{
name: "Empty allowed users allows no one",
checkedValue: "@someone:example.com",
allowedUsers: []string{},
expectedResult: false,
},
{
name: "Direct full mxid match is allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@someone:example.com"},
expectedResult: true,
},
{
name: "Direct full mxid match later on is allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@another:example.com", "@someone:example.com"},
expectedResult: true,
},
{
name: "No mxid match is not allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@another:example.com"},
expectedResult: false,
},
{
name: "mxid localpart only wildcard match is allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@*:example.com"},
expectedResult: true,
},
{
name: "mxid localpart with wildcard match is allowed",
checkedValue: "@bot.abc:example.com",
allowedUsers: []string{"@bot.*:example.com"},
expectedResult: true,
},
{
name: "mxid localpart with wildcard match is not allowed when it does not match",
checkedValue: "@bot.abc:example.com",
allowedUsers: []string{"@employee.*:example.com"},
expectedResult: false,
},
{
name: "mxid localpart wildcard for another domain is not allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@*:another.com"},
expectedResult: false,
},
{
name: "mxid domainpart with only wildcard match is allowed",
checkedValue: "@someone:example.com",
allowedUsers: []string{"@someone:*"},
expectedResult: true,
},
{
name: "mxid domainpart with wildcard match is allowed",
checkedValue: "@someone:example.organization.com",
allowedUsers: []string{"@someone:*.organization.com"},
expectedResult: true,
},
{
name: "mxid domainpart with wildcard match is not allowed when it does not match",
checkedValue: "@someone:example.another.com",
allowedUsers: []string{"@someone:*.organization.com"},
expectedResult: false,
},
}
for _, testData := range tests {
func(testData testDataDefinition) {
t.Run(testData.name, func(t *testing.T) {
allowedUserRegexes, err := WildcardMXIDsToRegexes(testData.allowedUsers)
if err != nil {
t.Error(err)
}
actualResult := Match(testData.checkedValue, allowedUserRegexes)
if actualResult == testData.expectedResult {
return
}
t.Errorf(
"Expected `%s` compared against `%v` to yield `%v`, not `%v`",
testData.checkedValue,
testData.allowedUsers,
testData.expectedResult,
actualResult,
)
})
}(testData)
}
}

View File

@@ -11,7 +11,7 @@ func Mailbox(email string) string {
if index == -1 {
return email
}
return email[:strings.LastIndex(email, "@")]
return email[:index]
}
// Hostname returns hostname part from email address
@@ -33,3 +33,31 @@ func Bool(str string) bool {
func SanitizeBoolString(str string) string {
return strconv.FormatBool(Bool(str))
}
// StringSlice converts comma-separated string to slice
func StringSlice(str string) []string {
if str == "" {
return nil
}
str = strings.TrimSpace(str)
if strings.IndexByte(str, ',') == -1 {
return []string{str}
}
return strings.Split(str, ",")
}
// SanitizeBoolString converts string to slice and back to string
func SanitizeStringSlice(str string) string {
parts := StringSlice(str)
if len(parts) == 0 {
return str
}
for i, part := range parts {
parts[i] = strings.TrimSpace(part)
}
return strings.Join(parts, ",")
}