121 Commits

Author SHA1 Message Date
Aine
bebfa6df92 provide proper reply-to fallback by default 2023-09-28 15:17:55 +03:00
Aine
8bdd46fb32 make linter happy 2023-09-28 08:31:42 +03:00
Aine
da41bd31fb add !pm banlist:totals, fix notices on reactions 2023-09-28 08:30:37 +03:00
Aine
7fbb279830 use proper thread IDs on metadata save and error reporting 2023-09-27 15:07:55 +03:00
Aine
816db6f409 always reply to a specific message; moved matrix-related utils to the linkpearl; refactoring 2023-09-27 12:43:55 +03:00
Aine
e2f5f4c731 show banlist by day, in weekly chunks 2023-09-26 15:54:27 +03:00
Aine
6be4891165 subaddressing support, closes #61 2023-09-25 23:20:17 +03:00
Aine
18f1113d33 add pm banlist:auth and pm banlist:auto 2023-09-25 21:59:23 +03:00
Aine
b413e5871a partial complexity refactoring 2023-09-23 17:33:58 +03:00
Aine
8f3a74d46c add !pm autoreply 2023-09-23 17:09:18 +03:00
Aine
480c99cf79 add !pm signature option 2023-09-23 16:35:12 +03:00
Aine
74defa85e4 spam:add using emoji 2023-09-22 22:15:53 +03:00
Aine
3bb1f3ecba add missing go packages 2023-09-22 19:43:44 +03:00
Aine
e3557f5522 new commands: spam:list, spam:add, spam:remove, spam:reset; updated readme with the full list of commands; rearranged sections in help 2023-09-22 19:35:28 +03:00
Aine
c4576869ab fix email queue consideration, fixes #66 2023-09-21 15:29:00 +03:00
Aine
8545ce80e4 Shared secret auth support, contributed by @JeWe37 2023-09-20 10:26:20 +03:00
Aine
60b4386dd8 automatically ignore known forwarded addresses, fixes #64 2023-09-18 12:35:37 +03:00
Aine
e90925eceb handle missing threads 2023-09-16 15:42:28 +03:00
Aine
f2432270e5 Merge branch 'main-patch-09ef' into 'main'
Fixes a copy and paste mistake in the docstring of in RoomNoInlines

See merge request etke.cc/postmoogle!45
2023-08-24 16:23:42 +00:00
Yan
d71937a087 Fixes a copy and paste mistake in the docstring of in RoomNoInlines 2023-08-24 16:09:48 +00:00
Aine
429645c3a9 try to handle unknown parent events 2023-08-22 14:41:52 +03:00
Aine
f9d05d94c9 updated deps 2023-06-16 16:29:28 +03:00
Aine
4c11919a46 Merge branch 'mautrix-0.15.x' into 'main'
BREAKING: update mautrix to 0.15.x

See merge request etke.cc/postmoogle!44
2023-06-01 14:32:20 +00:00
Aine
2bdb8ca635 BREAKING: update mautrix to 0.15.x 2023-06-01 14:32:20 +00:00
Aine
a6b20a75ab save metadata even if one of the recipients failed 2023-05-31 10:57:21 +03:00
Aine
9bcc2d462f refactor a bit 2023-05-10 22:43:52 +03:00
Aine
81c6d5abf1 Merge branch 'feature/relay-host' into 'main'
Added support for sending with relay hosts

See merge request etke.cc/postmoogle!43
2023-05-10 19:33:06 +00:00
Niels Bouma
ee8d8680ac Added support for sending with relay hosts 2023-05-10 19:33:05 +00:00
Aine
84102d5b5b add noreplies option 2023-04-10 14:03:43 +03:00
Aine
e8ade4173f export fswatcher to a separate library 2023-04-09 22:19:52 +03:00
Aine
321060d2d6 adjust ignore rules 2023-03-23 22:09:11 +02:00
Aine
2879b10625 add noinlines option 2023-03-01 22:50:02 +02:00
Aine
01b15b7ac4 proper multi-error message, try better to find SMTP server, fixes #58 2023-02-14 20:26:30 +02:00
Aine
3e0ecc1c02 make banlist consistent, fixes #57 2023-02-13 22:05:48 +02:00
Aine
19e2047a2b updated deps 2023-02-13 13:02:13 +02:00
Aine
dbe4a73174 make TLS reload thread-safe on TCP listener 2023-02-13 11:58:31 +02:00
Aine
a7d5207484 Merge branch 'ssl-live-reload' into 'main'
automatic ssl live reload

See merge request etke.cc/postmoogle!42
2023-02-12 20:43:33 +00:00
Aine
0f7af734e5 automatic ssl live reload 2023-02-12 20:43:33 +00:00
Aine
7d0d8cd2e6 log all smtp connection errors 2023-02-11 21:20:41 +02:00
Aine
6d55ee40ed fix dockerfile 2023-02-11 20:55:57 +02:00
Aine
dc82d97aaa update ci; update deps (show all smtp connection errors); migrate from make to just 2023-02-11 20:49:45 +02:00
Aine
12d2fee2d4 fix cc handling 2023-02-06 15:53:16 +02:00
Aine
ddf2460dbd fix dequeue account data issue 2023-01-27 00:02:23 +02:00
Aine
3f1fd00fb6 fix file uploads from incoming emails into threads 2023-01-24 16:34:31 +02:00
Aine
ac9c27aa32 handle multiple emails in header 'To' 2023-01-09 16:23:54 +02:00
Aine
1e9558c1fc registry dual writes 2023-01-08 14:23:56 +02:00
Aine
174930fc90 allow only text message events for commands 2023-01-08 00:58:14 +02:00
Aine
0559978fa2 log level changes 2023-01-04 11:22:50 +02:00
Aine
f54b87c1f7 resync rooms every 5 minutes 2023-01-03 20:13:30 +02:00
Aine
2ac6c64d13 make banlist consistent, fixes #54 2022-12-14 00:35:15 +02:00
Aine
fcd6110790 add trusted proxies 2022-11-27 00:30:50 +02:00
Aine
8d6c4aeafe big refactoring 2022-11-25 23:33:38 +02:00
Aine
14bad9f479 update readme 2022-11-25 16:48:49 +02:00
Aine
4a76a3269d healthchecks.io integration 2022-11-25 16:23:26 +02:00
Aine
351f0fca77 speed up email checks execution 2022-11-24 21:41:45 +02:00
Aine
363ba313e0 update readme 2022-11-23 21:33:29 +02:00
Aine
3115373118 SPF and DKIM checks 2022-11-23 21:30:13 +02:00
Aine
0701f8c9c3 reject wrong email in SMTP MAIL(), reject impersonation attempts 2022-11-23 11:51:12 +02:00
Aine
b4d6d992ac do not react on edits and redactions, add section titles in help message 2022-11-21 23:57:49 +02:00
Aine
21772d7360 mailbox activation, closes #52 2022-11-21 15:37:44 +02:00
Aine
a5edaaea78 respect nosend in thread replies, respect nohtml in !pm send and thread replies (on sending) 2022-11-21 10:50:06 +02:00
Aine
6ddb894577 allow reserved mailboxes, closes #43 2022-11-20 20:55:41 +02:00
Aine
117736dcf3 use correct list of recipients on thread reply and in 'email has been sent' messages 2022-11-20 00:58:51 +02:00
Aine
bb7cf4aa7a cleanup From, To and Cc. Send replies to all recipients (To+Cc) 2022-11-20 00:31:59 +02:00
Aine
8007f77535 Merge branch 'addmeto.cc' into 'main'
correctly handle TCP connections without forging them for banned hosts

See merge request etke.cc/postmoogle!41
2022-11-19 16:21:23 +00:00
Aine
ced98e818e correctly handle TCP connections without forging them for banned hosts 2022-11-19 18:20:57 +02:00
Aine
9d25b9455f Merge branch 'addmeto.cc' into 'main'
CC/BCC support

See merge request etke.cc/postmoogle!40
2022-11-19 16:06:29 +00:00
Aine
1bcf9bb050 set correct Message-Id, From, To, Cc, based on previous emails (and used domain) in the thread 2022-11-19 18:05:26 +02:00
Aine
128d2b595a use the same sender's domain on thread reply as in parent email 2022-11-19 17:41:38 +02:00
Aine
8aac16aca8 make thread replies CC-aware and multi-domain aware 2022-11-19 17:38:13 +02:00
Aine
5fe8603506 add nocc option 2022-11-19 17:09:24 +02:00
Aine
052fd5bb25 refactoring, created email package 2022-11-19 17:00:57 +02:00
Aine
9e532a6007 initial cc support 2022-11-19 16:41:53 +02:00
Aine
ad83eab930 force <style></style> removal in html part of incoming emails 2022-11-19 00:48:48 +02:00
Aine
3ef6d2698e optimize ban checks 2022-11-18 09:22:18 +02:00
Aine
0f2683bcd0 reject connections from banned hosts before talking with them 2022-11-18 08:59:18 +02:00
Aine
e38d4b2fc5 do not perform MX and SMTP checks at all when they are disabled 2022-11-17 23:34:14 +02:00
Aine
2e712e0a67 fix 'email has been sent' msg type, fixes #48 2022-11-17 23:19:16 +02:00
Aine
aba1a6521d compact replies, closes #50 2022-11-17 23:17:23 +02:00
Aine
66bd1a4fab do not add empty mime parts, fixes #51 2022-11-17 23:11:11 +02:00
Aine
99a89ef87a update deps; experiment: log security 2022-11-16 23:00:58 +02:00
Aine
225ba2ee9b adjust auto-retry, fix banned response code, rewrite email composing to enmime, add more logs 2022-11-16 22:22:19 +02:00
Aine
fce6593cd7 send multipart email with both html and plaintext by default, closes #22 2022-11-16 20:01:30 +02:00
Aine
7457f0436e add !pm send:html, closes #46 2022-11-16 19:30:44 +02:00
Aine
8ebe80bc4f add automatic greylisting 2022-11-16 18:47:24 +02:00
Aine
15b90e9e4c add banlist total 2022-11-16 17:37:27 +02:00
Aine
d0fa75b215 banlist visual adjustments 2022-11-16 15:57:31 +02:00
Aine
86cda29729 banlist 2022-11-16 14:23:42 +02:00
Aine
c1d33fe3cb add vendoring 2022-11-16 12:08:51 +02:00
Aine
14751cbf3a exclude failed tls certs, add auth debug log 2022-11-16 10:40:27 +02:00
Aine
919ee46ba4 do not leak domain in multi-domain mode 2022-11-16 10:25:26 +02:00
Aine
ebe9606aa9 real multi-domain support 2022-11-16 09:00:19 +02:00
Aine
f3be3aeabb fix deps 2022-11-15 19:39:54 +02:00
Aine
24e9fb8a59 fix typo 2022-11-15 19:28:38 +02:00
Aine
ec266e9108 wip encrypted parent event 2022-11-15 19:22:15 +02:00
Aine
7c59ff4b2e decrypt parent event only when really needed, lookup threadID only when really needed 2022-11-15 16:02:31 +02:00
Aine
e7be9c6fad update deps, correctly log meta information 2022-11-15 15:37:24 +02:00
Aine
70cd8bd155 Merge branch 'queue' into 'main'
mail queue

See merge request etke.cc/postmoogle!39
2022-11-15 08:21:47 +00:00
Aine
e68d419da4 update readme 2022-11-15 09:46:35 +02:00
Aine
4ef139f875 rename queue config options 2022-11-15 09:45:43 +02:00
Aine
a8780a32c1 explicitly tell about enqueued email 2022-11-15 09:42:07 +02:00
Aine
eb07bc1ac7 mail queue 2022-11-14 20:02:13 +02:00
Aine
ce1599d8a3 cache and encrypt email threads metadata 2022-11-14 18:18:30 +02:00
Aine
d5f2a6b75f fix thread replies in matrix 2022-11-14 15:56:27 +02:00
Aine
94b1d13eb7 try to find parent email by Message-Id and references 2022-11-14 10:42:10 +02:00
Aine
b9cf336a6d fix encrypted thread reply, fix From header in thread reply 2022-11-14 00:38:17 +02:00
Aine
519c44e998 support multi-domain certificates 2022-11-13 16:07:38 +02:00
Aine
29cd6c4dcb add missing References email header, fix Message-Id composing, fix email reply bugs 2022-11-13 15:33:19 +02:00
Aine
0c01987c93 add missing MIME-Version header 2022-11-12 13:01:27 +02:00
Aine
f835a7560d bridge thread replies from matrix to email 2022-11-10 21:58:29 +02:00
Aine
19dec770b9 Merge branch 'refactoring' into 'main'
refactor smtp

See merge request etke.cc/postmoogle!38
2022-11-10 18:54:59 +00:00
Aine
307aca7f23 refactor smtp 2022-11-10 13:26:12 +02:00
Aine
e6722dd5e8 Merge branch 'multidomain' into 'main'
show multi-domain aliases everywhere

See merge request etke.cc/postmoogle!37
2022-11-08 19:21:55 +00:00
Aine
9cfe0a6d4f show multi-domain aliases everywhere 2022-11-08 21:21:06 +02:00
Aine
710e49f4cc Merge branch 'multidomain' into 'main'
initial, rought, not-user-friendly support for multi-domain setup

See merge request etke.cc/postmoogle!36
2022-11-08 16:22:07 +00:00
Aine
15d5afe90f initial, rought, not-user-friendly support for multi-domain setup 2022-11-08 18:16:38 +02:00
Aine
8954a7801a revert AUTH login method 2022-11-08 17:37:21 +02:00
Aine
ebb648807d add LOGIN auth method 2022-11-08 17:07:05 +02:00
Aine
0e10f7caba update deps 2022-11-06 23:45:50 +02:00
Aine
2c47bc7e14 fix lowercase 2022-10-28 08:44:09 +03:00
Aine
8e11c3da83 integrate gitlab dependency proxy 2022-10-26 12:15:50 +03:00
1254 changed files with 780958 additions and 1731 deletions

View File

@@ -6,23 +6,23 @@ lint:
stage: test
image: registry.gitlab.com/etke.cc/base/build
script:
- make lint
- just lint
unit:
stage: test
image: registry.gitlab.com/etke.cc/base/build
script:
- make test
- just test
docker:
stage: release
only: ['main', 'tags']
services:
- docker:dind
image: jdrouet/docker-with-buildx:stable
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/jdrouet/docker-with-buildx:latest
before_script:
- apk --no-cache add make
- apk --no-cache add just
script:
- make login docker
- just login docker
tags:
- docker

View File

@@ -2,7 +2,7 @@ FROM registry.gitlab.com/etke.cc/base/build AS builder
WORKDIR /postmoogle
COPY . .
RUN make build
RUN just build
FROM registry.gitlab.com/etke.cc/base/app

View File

@@ -1,53 +0,0 @@
### CI vars
CI_LOGIN_COMMAND = @echo "Not a CI, skip login"
CI_REGISTRY_IMAGE ?= registry.gitlab.com/etke.cc/postmoogle
CI_COMMIT_TAG ?= latest
# for main branch it must be set explicitly
ifeq ($(CI_COMMIT_TAG), main)
CI_COMMIT_TAG = latest
endif
# login command
ifdef CI_JOB_TOKEN
CI_LOGIN_COMMAND = @docker login -u gitlab-ci-token -p $(CI_JOB_TOKEN) $(CI_REGISTRY)
endif
# update go dependencies
update:
go get ./cmd
go mod tidy
go mod verify
mock:
-@rm -rf mocks
@mockery --all
# run linter
lint:
golangci-lint run ./...
# run linter and fix issues if possible
lintfix:
golangci-lint run --fix ./...
# run unit tests
test:
@go test -coverprofile=cover.out ./...
@go tool cover -func=cover.out
-@rm -f cover.out
# note: make doesn't understand exit code 130 and sets it == 1
run:
@go run ./cmd || exit 0
build:
go build -v -o postmoogle ./cmd
# CI: docker login
login:
@echo "trying to login to docker registry..."
$(CI_LOGIN_COMMAND)
# docker build
docker:
docker buildx create --use
docker buildx build --platform linux/arm64/v8,linux/amd64 --push -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} .

292
README.md
View File

@@ -13,28 +13,31 @@ so you can use it to send emails from your apps and scripts as well.
### Receive
- [x] SMTP server (plaintext and SSL)
- [x] live reload of SSL certs
- [x] Matrix bot
- [x] Configuration in room's account data
- [x] Receive emails to matrix rooms
- [x] Receive attachments
- [x] Subaddressing support
- [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
- [x] Multi-domain support
- [x] SMTP verification
- [x] DKIM verification
- [x] SPF verification
- [x] MX verification
- [x] Spamlist of emails (wildcards supported)
- [x] Spamlist of hosts (per server only)
- [x] Greylisting (per server only)
### Send
- [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
- [x] Reply to matrix thread sends reply into email thread
- [x] Email signatures
- [x] Email autoreply / autoresponder for new email threads
## Configuration
@@ -43,27 +46,38 @@ so you can use it to send emails from your apps and scripts as well.
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_LOGIN** - user login, localpart when logging in with password (e.g., `moogle`), OR full MXID when using shared secret (e.g., `@moogle:example.com`)
* **POSTMOOGLE_PASSWORD** - user password, alternatively you may use shared secret
* **POSTMOOGLE_SHAREDSECRET** - alternative to password, shared secret ([details](https://github.com/devture/matrix-synapse-shared-secret-auth))
* **POSTMOOGLE_DOMAINS** - space separated list of SMTP domains to listen for new emails. The first domain acts as the default domain, all other as aliases
<details>
<summary>other optional config parameters</summary>
* **POSTMOOGLE_PORT** - SMTP port to listen for new emails
* **POSTMOOGLE_PROXIES** - space separated list of IP addresses considered as trusted proxies, thus never banned
* **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_CERT** - space separated list of paths to the SSL certificates (chain) of your domains, note that position in the cert list must match the position of the cert's key in the key list
* **POSTMOOGLE_TLS_KEY** - space separated list of paths to the SSL certificates' private keys of your domains, note that position on the key list must match the position of cert in the cert list
* **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_MONITORING_SENTRY_DSN** - sentry DSN
* **POSTMOOGLE_MONITORING_SENTRY_RATE** - sentry sample rate, from 0 to 100 (default: 20)
* **POSTMOOGLE_MONITORING_HEALTHCHECKS_UUID** - healthchecks.io UUID
* **POSTMOOGLE_MONITORING_HEALTHCHECKS_DURATION** - heathchecks.io duration between pings in secods (default: 5)
* **POSTMOOGLE_LOGLEVEL** - log level
* **POSTMOOGLE_DB_DSN** - database connection string
* **POSTMOOGLE_DB_DIALECT** - database dialect (postgres, sqlite3)
* **POSTMOOGLE_MAILBOXES_RESERVED** - space separated list of reserved mailboxes, [docs/mailboxes.md](docs/mailboxes.md)
* **POSTMOOGLE_MAILBOXES_FORWARDED** - space separated list of forwarded from emails that should be ignored when sending replies
* **POSTMOOGLE_MAILBOXES_ACTIVATION** - activation flow for new mailboxes, [docs/mailboxes.md](docs/mailboxes.md)
* **POSTMOOGLE_MAXSIZE** - max email size (including attachments) in megabytes
* **POSTMOOGLE_ADMINS** - a space-separated list of admin users. See `POSTMOOGLE_USERS` for syntax examples
* **POSTMOOGLE_RELAY_HOST** - SMTP hostname of relay host (e.g. Sendgrid)
* **POSTMOOGLE_RELAY_PORT** - SMTP port of relay host
* **POSTMOOGLE_RELAY_USERNAME** - Username of relay host
* **POSTMOOGLE_RELAY_PASSWORD** - Password of relay host
You can find default values in [config/defaults.go](config/defaults.go)
@@ -71,155 +85,7 @@ You can find default values in [config/defaults.go](config/defaults.go)
### 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>
Follow the [docs/dns](docs/dns.md)
## Usage
@@ -234,37 +100,93 @@ If you want to change them - check available options in the help message (`!pm h
<details>
<summary>Full list of available commands</summary>
* **!pm help** - Show help message
* **!pm stop** - Disable bridge for the room and clear all configuration
> The following section is visible to all allowed users
* **`!pm help`** - Show this help message
* **`!pm stop`** - Disable bridge for the room and clear all configuration
* **`!pm send`** - Send email
---
* **!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
#### mailbox ownership
> The following section is visible to the mailbox owners only
* **`!pm mailbox`** - Get or set mailbox of the room
* **`!pm domain`** - Get or set default domain 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)
* **!pm nofiles** - Get or set `nofiles` of the room (`true` - ignore email attachments; `false` - upload email attachments)
#### mailbox options
> The following section is visible to the mailbox owners only
* **`!pm autoreply`** - Get or set autoreply of the room (markdown supported) that will be sent on any new incoming email thread
* **`!pm signature`** - Get or set signature of the room (markdown supported)
* **`!pm nosend`** - Get or set `nosend` of the room (`true` - disable email sending; `false` - enable email sending)
* **`!pm noreplies`** - Get or set `noreplies` of the room (`true` - ignore matrix replies; `false` - parse matrix replies)
* **`!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 nocc`** - Get or set `nocc` of the room (`true` - hide CC; `false` - show CC)
* **`!pm nosubject`** - Get or set `nosubject` of the room (`true` - hide email subject; `false` - show email subject)
* **`!pm nohtml`** - Get or set `nohtml` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)
* **`!pm nothreads`** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)
* **`!pm nofiles`** - Get or set `nofiles` of the room (`true` - ignore email attachments; `false` - upload email attachments)
* **`!pm noinlines`** - Get or set `noinlines` of the room (`true` - ignore inline attachments; `false` - upload inline attachments)
---
* **!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@*`
#### mailbox security checks
> The following section is visible to the mailbox owners only
* **`!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:spf`** - only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable)
* **`!pm spamcheck:dkim`** - only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`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 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
#### mailbox anti-spam
> The following section is visible to the mailbox owners only
* **`!pm spam:list`** - Show comma-separated spamlist of the room, eg: `spammer@example.com,*@spammer.org,spam@*`
* **`!pm spam:add`** - Mark an email address (or pattern) as spam (or you can react to the email with emoji: ⛔️,🛑, or 🚫)
* **`!pm spam:remove`** - Unmark an email address (or pattern) as spam
* **`!pm spam:reset`** - Reset spamlist
---
#### server options
> The following section is visible to the bridge admins only
* **`!pm adminroom`** - Get or set admin room
* **`!pm users`** - Get or set allowed users
* **`!pm dkim`** - Get DKIM signature
* **`!pm catch-all`** - Get or set catch-all mailbox
* **`!pm queue:batch`** - max amount of emails to process on each queue check
* **`!pm queue:retries`** - max amount of tries per email in queue before removal
* **`!pm mailboxes`** - Show the list of all mailboxes
* **`!pm delete`** - Delete specific mailbox
---
#### server antispam
> The following section is visible to the bridge admins only
* **`!pm greylist`** - Set automatic greylisting duration in minutes (0 - disabled)
* **`!pm banlist`** - Enable/disable banlist and show current values
* **`!pm banlist:auth`** - Enable/disable automatic banning for invalid auth credentials
* **`!pm banlist:auto`** - Enable/disable automatic banning for invalid emails
* **`!pm banlist:totals`** - List banlist totals only
* **`!pm banlist:add`** - Ban an IP
* **`!pm banlist:remove`** - Unban an IP
* **`!pm banlist:reset`** - Reset banlist
</details>

View File

@@ -2,10 +2,11 @@ package bot
import (
"context"
"net"
"regexp"
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/raja/argon2pw"
"gitlab.com/etke.cc/go/mxidwc"
"maunium.net/go/mautrix/id"
@@ -39,9 +40,9 @@ func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
if !b.allowUsers(actorID) {
return false
}
cfg, err := b.getRoomSettings(targetRoomID)
cfg, err := b.cfg.GetRoom(targetRoomID)
if err != nil {
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
b.Error(context.Background(), "failed to retrieve settings: %v", err)
return false
}
@@ -62,34 +63,170 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
return false
}
cfg, err := b.getRoomSettings(targetRoomID)
cfg, err := b.cfg.GetRoom(targetRoomID)
if err != nil {
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
b.Error(context.Background(), "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) {
func (b *Bot) allowReply(actorID id.UserID, targetRoomID id.RoomID) bool {
if !b.allowUsers(actorID) {
return false
}
roomID, ok := b.GetMapping(utils.Mailbox(email))
if !ok {
cfg, err := b.cfg.GetRoom(targetRoomID)
if err != nil {
b.Error(context.Background(), "failed to retrieve settings: %v", err)
return false
}
cfg, err := b.getRoomSettings(roomID)
if err != nil {
b.log.Error("failed to retrieve settings: %v", err)
return !cfg.NoReplies()
}
func (b *Bot) isReserved(mailbox string) bool {
for _, reserved := range b.mbxc.Reserved {
if mailbox == reserved {
return true
}
}
return false
}
// IsGreylisted checks if host is in greylist
func (b *Bot) IsGreylisted(addr net.Addr) bool {
if b.cfg.GetBot().Greylist() == 0 {
return false
}
greylist := b.cfg.GetGreylist()
greylistedAt, ok := greylist.Get(addr)
if !ok {
b.log.Debug().Str("addr", addr.String()).Msg("greylisting")
greylist.Add(addr)
err := b.cfg.SetGreylist(greylist)
if err != nil {
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update greylist")
}
return true
}
duration := time.Duration(b.cfg.GetBot().Greylist()) * time.Minute
return greylistedAt.Add(duration).After(time.Now().UTC())
}
// IsBanned checks if address is banned
func (b *Bot) IsBanned(addr net.Addr) bool {
return b.cfg.GetBanlist().Has(addr)
}
// IsTrusted checks if address is a trusted (proxy)
func (b *Bot) IsTrusted(addr net.Addr) bool {
ip := utils.AddrIP(addr)
for _, proxy := range b.proxies {
if ip == proxy {
b.log.Debug().Str("addr", ip).Msg("address is trusted")
return true
}
}
return false
}
// Ban an address automatically
func (b *Bot) BanAuto(addr net.Addr) {
if !b.cfg.GetBot().BanlistEnabled() {
return
}
if !b.cfg.GetBot().BanlistAuto() {
return
}
if b.IsTrusted(addr) {
return
}
b.log.Debug().Str("addr", addr.String()).Msg("attempting to automatically ban")
banlist := b.cfg.GetBanlist()
banlist.Add(addr)
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update banlist")
}
}
// Ban an address for incorrect auth automatically
func (b *Bot) BanAuth(addr net.Addr) {
if !b.cfg.GetBot().BanlistEnabled() {
return
}
if !b.cfg.GetBot().BanlistAuth() {
return
}
if b.IsTrusted(addr) {
return
}
b.log.Debug().Str("addr", addr.String()).Msg("attempting to automatically ban")
banlist := b.cfg.GetBanlist()
banlist.Add(addr)
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update banlist")
}
}
// Ban an address manually
func (b *Bot) BanManually(addr net.Addr) {
if !b.cfg.GetBot().BanlistEnabled() {
return
}
if b.IsTrusted(addr) {
return
}
b.log.Debug().Str("addr", addr.String()).Msg("attempting to manually ban")
banlist := b.cfg.GetBanlist()
banlist.Add(addr)
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update banlist")
}
}
// AllowAuth check if SMTP login (email) and password are valid
func (b *Bot) AllowAuth(email, password string) (id.RoomID, bool) {
var suffix bool
for _, domain := range b.domains {
if strings.HasSuffix(email, "@"+domain) {
suffix = true
break
}
}
if !suffix {
return "", false
}
roomID, ok := b.getMapping(utils.Mailbox(email))
if !ok {
return "", false
}
cfg, err := b.cfg.GetRoom(roomID)
if err != nil {
b.log.Error().Err(err).Msg("failed to retrieve settings")
return "", false
}
if cfg.NoSend() {
b.log.Warn().Str("email", email).Str("roomID", roomID.String()).Msg("trying to send email, but room is receive-only")
return "", false
}
allow, err := argon2pw.CompareHashWithPassword(cfg.Password(), password)
if err != nil {
b.log.Warn("Password for %s is not valid: %v", email, err)
b.log.Warn().Err(err).Str("email", email).Msg("Password is not valid")
}
return allow
return roomID, allow
}

54
bot/activation.go Normal file
View File

@@ -0,0 +1,54 @@
package bot
import (
"fmt"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
)
type activationFlow func(id.UserID, id.RoomID, string) bool
func (b *Bot) getActivationFlow() activationFlow {
switch b.mbxc.Activation {
case "none":
return b.activateNone
case "notify":
return b.activateNotify
default:
return b.activateNone
}
}
// ActivateMailbox using the configured flow
func (b *Bot) ActivateMailbox(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
flow := b.getActivationFlow()
return flow(ownerID, roomID, mailbox)
}
func (b *Bot) activateNone(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
b.log.Debug().Str("mailbox", mailbox).Str("roomID", roomID.String()).Str("ownerID", ownerID.String()).Msg("activating mailbox through the flow 'none'")
b.rooms.Store(mailbox, roomID)
return true
}
func (b *Bot) activateNotify(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
b.log.Debug().Str("mailbox", mailbox).Str("roomID", roomID.String()).Str("ownerID", ownerID.String()).Msg("activating mailbox through the flow 'notify'")
b.rooms.Store(mailbox, roomID)
if len(b.adminRooms) == 0 {
return true
}
msg := fmt.Sprintf("Mailbox %q has been registered by %q for the room %q", mailbox, ownerID, roomID)
for _, adminRoom := range b.adminRooms {
content := format.RenderMarkdown(msg, true, true)
_, err := b.lp.Send(adminRoom, &content)
if err != nil {
b.log.Info().Str("adminRoom", adminRoom.String()).Msg("cannot send mailbox activation notification to the admin room")
continue
}
break
}
return true
}

View File

@@ -6,46 +6,68 @@ import (
"regexp"
"sync"
"github.com/getsentry/sentry-go"
"gitlab.com/etke.cc/go/logger"
"github.com/rs/zerolog"
"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/bot/config"
"gitlab.com/etke.cc/postmoogle/bot/queue"
"gitlab.com/etke.cc/postmoogle/utils"
)
// Mailboxes config
type MBXConfig struct {
Reserved []string
Forwarded []string
Activation string
}
// Bot represents matrix bot
type Bot struct {
prefix string
domain string
mbxc MBXConfig
domains []string
allowedUsers []*regexp.Regexp
allowedAdmins []*regexp.Regexp
adminRooms []id.RoomID
ignoreBefore int64 // mautrix 0.15.x migration
commands commandList
rooms sync.Map
mta utils.MTA
log *logger.Logger
proxies []string
sendmail func(string, string, string) error
cfg *config.Manager
log *zerolog.Logger
lp *linkpearl.Linkpearl
mu map[id.RoomID]*sync.Mutex
mu utils.Mutex
q *queue.Queue
handledMembershipEvents sync.Map
}
// New creates a new matrix bot
func New(
q *queue.Queue,
lp *linkpearl.Linkpearl,
log *logger.Logger,
log *zerolog.Logger,
cfg *config.Manager,
proxies []string,
prefix string,
domain string,
domains []string,
admins []string,
mbxc MBXConfig,
) (*Bot, error) {
b := &Bot{
prefix: prefix,
domain: domain,
rooms: sync.Map{},
log: log,
lp: lp,
mu: map[id.RoomID]*sync.Mutex{},
domains: domains,
prefix: prefix,
rooms: sync.Map{},
adminRooms: []id.RoomID{},
proxies: proxies,
mbxc: mbxc,
cfg: cfg,
log: log,
lp: lp,
mu: utils.NewMutex(),
q: q,
}
users, err := b.initBotUsers()
if err != nil {
@@ -69,44 +91,45 @@ func New(
}
// Error message to the log and matrix room
func (b *Bot) Error(ctx context.Context, roomID id.RoomID, message string, args ...interface{}) {
b.log.Error(message, args...)
func (b *Bot) Error(ctx context.Context, message string, args ...interface{}) {
evt := eventFromContext(ctx)
threadID := threadIDFromContext(ctx)
if threadID == "" {
threadID = linkpearl.EventParent(evt.ID, evt.Content.AsMessage())
}
err := fmt.Errorf(message, args...)
if hub := sentry.GetHubFromContext(ctx); hub != nil {
sentry.GetHubFromContext(ctx).CaptureException(err)
b.log.Error().Err(err).Msg(err.Error())
if evt == nil {
return
}
if roomID != "" {
b.SendError(ctx, roomID, err.Error())
}
}
// SendError sends an error message to the matrix room
func (b *Bot) SendError(ctx context.Context, roomID id.RoomID, message string) {
b.SendNotice(ctx, roomID, "ERROR: "+message)
}
// SendNotice sends a notice message to the matrix room
func (b *Bot) SendNotice(ctx context.Context, roomID id.RoomID, message string) {
content := format.RenderMarkdown(message, true, true)
content.MsgType = event.MsgNotice
_, err := b.lp.Send(roomID, &content)
if err != nil {
sentry.GetHubFromContext(ctx).CaptureException(err)
var noThreads bool
cfg, cerr := b.cfg.GetRoom(evt.RoomID)
if cerr == nil {
noThreads = cfg.NoThreads()
}
var relatesTo *event.RelatesTo
if threadID != "" {
relatesTo = linkpearl.RelatesTo(threadID, noThreads)
}
b.lp.SendNotice(evt.RoomID, "ERROR: "+err.Error(), relatesTo)
}
// Start performs matrix /sync
func (b *Bot) Start(statusMsg string) error {
if err := b.migrate(); err != nil {
if err := b.migrateMautrix015(); err != nil {
return err
}
if err := b.syncRooms(); err != nil {
return err
}
b.initSync()
b.log.Info("Postmoogle has been started")
b.log.Info().Msg("Postmoogle has been started")
return b.lp.Start(statusMsg)
}
@@ -114,7 +137,7 @@ func (b *Bot) Start(statusMsg string) error {
func (b *Bot) Stop() {
err := b.lp.GetClient().SetPresence(event.PresenceOffline)
if err != nil {
b.log.Error("cannot set presence = offline: %v", err)
b.log.Error().Err(err).Msg("cannot set presence = offline")
}
b.lp.GetClient().StopSync()
}

View File

@@ -6,21 +6,38 @@ import (
"strings"
"time"
"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/bot/config"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils"
)
const (
commandHelp = "help"
commandStop = "stop"
commandSend = "send"
commandDKIM = "dkim"
commandCatchAll = botOptionCatchAll
commandUsers = botOptionUsers
commandDelete = "delete"
commandMailboxes = "mailboxes"
commandHelp = "help"
commandStop = "stop"
commandSend = "send"
commandDKIM = "dkim"
commandCatchAll = config.BotCatchAll
commandUsers = config.BotUsers
commandQueueBatch = config.BotQueueBatch
commandQueueRetries = config.BotQueueRetries
commandSpamlist = "spam:list"
commandSpamlistAdd = "spam:add"
commandSpamlistRemove = "spam:remove"
commandSpamlistReset = "spam:reset"
commandDelete = "delete"
commandBanlist = "banlist"
commandBanlistTotals = "banlist:totals"
commandBanlistAuto = "banlist:auto"
commandBanlistAuth = "banlist:auth"
commandBanlistAdd = "banlist:add"
commandBanlistRemove = "banlist:remove"
commandBanlistReset = "banlist:reset"
commandMailboxes = "mailboxes"
)
type (
@@ -60,114 +77,189 @@ func (b *Bot) initCommands() commandList {
description: "Send email",
allowed: b.allowSend,
},
{allowed: b.allowOwner}, // delimiter
{allowed: b.allowOwner, description: "mailbox ownership"}, // delimiter
// options commands
{
key: roomOptionMailbox,
key: config.RoomMailbox,
description: "Get or set mailbox of the room",
sanitizer: utils.Mailbox,
allowed: b.allowOwner,
},
{
key: roomOptionOwner,
key: config.RoomDomain,
description: "Get or set default domain of the room",
sanitizer: utils.SanitizeDomain,
allowed: b.allowOwner,
},
{
key: config.RoomOwner,
description: "Get or set owner of the room",
sanitizer: func(s string) string { return s },
allowed: b.allowOwner,
},
{
key: roomOptionPassword,
key: config.RoomPassword,
description: "Get or set SMTP password of the room's mailbox",
allowed: b.allowOwner,
},
{allowed: b.allowOwner}, // delimiter
{allowed: b.allowOwner, description: "mailbox options"}, // delimiter
{
key: roomOptionNoSend,
key: config.RoomAutoreply,
description: "Get or set autoreply of the room (markdown supported) that will be send for any new incoming email thread",
sanitizer: func(s string) string { return s },
allowed: b.allowOwner,
},
{
key: config.RoomSignature,
description: "Get or set signature of the room (markdown supported)",
sanitizer: func(s string) string { return s },
allowed: b.allowOwner,
},
{
key: config.RoomNoSend,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - disable email sending; `false` - enable email sending)",
roomOptionNoSend,
config.RoomNoSend,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoSender,
key: config.RoomNoReplies,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - ignore matrix replies; `false` - parse matrix replies)",
config.RoomNoReplies,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: config.RoomNoSender,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide email sender; `false` - show email sender)",
roomOptionNoSender,
config.RoomNoSender,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoRecipient,
key: config.RoomNoRecipient,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide recipient; `false` - show recipient)",
roomOptionNoRecipient,
config.RoomNoRecipient,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoSubject,
key: config.RoomNoCC,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide CC; `false` - show CC)",
config.RoomNoCC,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: config.RoomNoSubject,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - hide email subject; `false` - show email subject)",
roomOptionNoSubject,
config.RoomNoSubject,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoHTML,
key: config.RoomNoHTML,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)",
roomOptionNoHTML,
config.RoomNoHTML,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoThreads,
key: config.RoomNoThreads,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)",
roomOptionNoThreads,
config.RoomNoThreads,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{
key: roomOptionNoFiles,
key: config.RoomNoFiles,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - ignore email attachments; `false` - upload email attachments)",
roomOptionNoFiles,
config.RoomNoFiles,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{allowed: b.allowOwner}, // delimiter
{
key: roomOptionSpamcheckMX,
key: config.RoomNoInlines,
description: fmt.Sprintf(
"Get or set `%s` of the room (`true` - ignore inline attachments; `false` - upload inline attachments)",
config.RoomNoInlines,
),
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{allowed: b.allowOwner, description: "mailbox security checks"}, // delimiter
{
key: config.RoomSpamcheckMX,
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)",
key: config.RoomSpamcheckSPF,
description: "only accept email from senders which authorized to send it (those matching SPF records) (`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,
key: config.RoomSpamcheckDKIM,
description: "only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`true` - enable, `false` - disable)",
sanitizer: utils.SanitizeBoolString,
allowed: b.allowOwner,
},
{allowed: b.allowAdmin}, // delimiter
{
key: botOptionUsers,
key: config.RoomSpamcheckSMTP,
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,
},
{allowed: b.allowOwner, description: "mailbox anti-spam"}, // delimiter
{
key: commandSpamlist,
description: "Show comma-separated spamlist of the room, eg: `spammer@example.com,*@spammer.org,spam@*`",
sanitizer: utils.SanitizeStringSlice,
allowed: b.allowOwner,
},
{
key: commandSpamlistAdd,
description: "Mark an email address (or pattern) as spam (or you can react to the email with emoji: ⛔️,🛑, or 🚫)",
allowed: b.allowOwner,
},
{
key: commandSpamlistRemove,
description: "Unmark an email address (or pattern) as spam",
allowed: b.allowOwner,
},
{
key: commandSpamlistReset,
description: "Reset spamlist",
allowed: b.allowOwner,
},
{allowed: b.allowAdmin, description: "server options"}, // delimiter
{
key: config.BotAdminRoom,
description: "Get or set admin room",
allowed: b.allowAdmin,
},
{
key: config.BotUsers,
description: "Get or set allowed users",
allowed: b.allowAdmin,
},
@@ -181,6 +273,18 @@ func (b *Bot) initCommands() commandList {
description: "Get or set catch-all mailbox",
allowed: b.allowAdmin,
},
{
key: commandQueueBatch,
description: "max amount of emails to process on each queue check",
sanitizer: utils.SanitizeIntString,
allowed: b.allowAdmin,
},
{
key: commandQueueRetries,
description: "max amount of tries per email in queue before removal",
sanitizer: utils.SanitizeIntString,
allowed: b.allowAdmin,
},
{
key: commandMailboxes,
description: "Show the list of all mailboxes",
@@ -191,22 +295,87 @@ func (b *Bot) initCommands() commandList {
description: "Delete specific mailbox",
allowed: b.allowAdmin,
},
{allowed: b.allowAdmin, description: "server antispam"}, // delimiter
{
key: config.BotGreylist,
description: "Set automatic greylisting duration in minutes (0 - disabled)",
allowed: b.allowAdmin,
},
{
key: commandBanlist,
description: "Enable/disable banlist and show current values",
allowed: b.allowAdmin,
},
{
key: commandBanlistAuth,
description: "Enable/disable automatic banning of IP addresses when they try to auth with invalid credentials",
allowed: b.allowAdmin,
},
{
key: commandBanlistAuto,
description: "Enable/disable automatic banning of IP addresses when they try to send invalid emails",
allowed: b.allowAdmin,
},
{
key: commandBanlistTotals,
description: "List banlist totals only",
allowed: b.allowAdmin,
},
{
key: commandBanlistAdd,
description: "Ban an IP",
allowed: b.allowAdmin,
},
{
key: commandBanlistRemove,
description: "Unban an IP",
allowed: b.allowAdmin,
},
{
key: commandBanlistReset,
description: "Reset banlist",
allowed: b.allowAdmin,
},
}
}
func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice []string) {
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().Err(err).Msg("cannot send read receipt")
}
content := evt.Content.AsMessage()
if content == nil {
b.Error(ctx, "cannot read message")
return
}
// ignore notices
if content.MsgType == event.MsgNotice {
return
}
message := strings.TrimSpace(content.Body)
commandSlice := b.parseCommand(message, true)
if commandSlice == nil {
if linkpearl.EventParent("", content) != "" {
b.SendEmailReply(ctx)
}
return
}
cmd := b.commands.get(commandSlice[0])
if cmd == nil {
return
}
_, err := b.lp.GetClient().UserTyping(evt.RoomID, true, 30*time.Second)
_, err = b.lp.GetClient().UserTyping(evt.RoomID, true, 30*time.Second)
if err != nil {
b.log.Error("cannot send typing notification: %v", err)
b.log.Error().Err(err).Msg("cannot send typing notification")
}
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")
b.lp.SendNotice(evt.RoomID, "not allowed to do that, kupo")
return
}
@@ -219,12 +388,36 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
b.runSend(ctx)
case commandDKIM:
b.runDKIM(ctx, commandSlice)
case commandSpamlistAdd:
b.runSpamlistAdd(ctx, commandSlice)
case commandSpamlistRemove:
b.runSpamlistRemove(ctx, commandSlice)
case commandSpamlistReset:
b.runSpamlistReset(ctx)
case config.BotAdminRoom:
b.runAdminRoom(ctx, commandSlice)
case commandUsers:
b.runUsers(ctx, commandSlice)
case commandCatchAll:
b.runCatchAll(ctx, commandSlice)
case commandDelete:
b.runDelete(ctx, commandSlice)
case config.BotGreylist:
b.runGreylist(ctx, commandSlice)
case commandBanlist:
b.runBanlist(ctx, commandSlice)
case commandBanlistAuth:
b.runBanlistAuth(ctx, commandSlice)
case commandBanlistAuto:
b.runBanlistAuto(ctx, commandSlice)
case commandBanlistTotals:
b.runBanlistTotals(ctx)
case commandBanlistAdd:
b.runBanlistAdd(ctx, commandSlice)
case commandBanlistRemove:
b.runBanlistRemove(ctx, commandSlice)
case commandBanlistReset:
b.runBanlistReset(ctx)
case commandMailboxes:
b.sendMailboxes(ctx)
default:
@@ -249,7 +442,7 @@ func (b *Bot) parseCommand(message string, toLower bool) []string {
return strings.Split(strings.TrimSpace(message), " ")
}
func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
func (b *Bot) sendIntroduction(roomID id.RoomID) {
var msg strings.Builder
msg.WriteString("Hello, kupo!\n\n")
@@ -258,22 +451,45 @@ func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
msg.WriteString("To get started, assign an email address to this room by sending a `")
msg.WriteString(b.prefix)
msg.WriteString(" ")
msg.WriteString(roomOptionMailbox)
msg.WriteString(config.RoomMailbox)
msg.WriteString(" SOME_INBOX` command.\n")
msg.WriteString("You will then be able to send emails to `SOME_INBOX@")
msg.WriteString(b.domain)
msg.WriteString("You will then be able to send emails to ")
msg.WriteString(utils.EmailsList("SOME_INBOX", ""))
msg.WriteString("` and have them appear in this room.")
b.SendNotice(ctx, roomID, msg.String())
b.lp.SendNotice(roomID, msg.String())
}
func (b *Bot) getHelpValue(cfg config.Room, cmd command) string {
name := cmd.key
if name == commandSpamlist {
name = config.RoomSpamlist
}
value := cfg.Get(name)
if cmd.sanitizer != nil {
switch value != "" {
case false:
return "(currently not set)"
case true:
txt := "(currently " + value
if cmd.key == config.RoomMailbox {
txt += " (" + utils.EmailsList(value, cfg.Domain()) + ")"
}
return txt + ")"
}
}
return ""
}
func (b *Bot) sendHelp(ctx context.Context) {
evt := eventFromContext(ctx)
cfg, serr := b.getRoomSettings(evt.RoomID)
cfg, serr := b.cfg.GetRoom(evt.RoomID)
if serr != nil {
b.log.Error("cannot retrieve settings: %v", serr)
b.log.Error().Err(serr).Msg("cannot retrieve settings")
}
var msg strings.Builder
@@ -283,7 +499,10 @@ func (b *Bot) sendHelp(ctx context.Context) {
continue
}
if cmd.key == "" {
msg.WriteString("\n---\n")
msg.WriteString("\n---\n\n")
msg.WriteString("#### ")
msg.WriteString(cmd.description)
msg.WriteString("\n")
continue
}
msg.WriteString("* **`")
@@ -291,40 +510,55 @@ func (b *Bot) sendHelp(ctx context.Context) {
msg.WriteString(" ")
msg.WriteString(cmd.key)
msg.WriteString("`**")
value := cfg.Get(cmd.key)
if cmd.sanitizer != nil {
switch value != "" {
case false:
msg.WriteString("(currently not set)")
case true:
msg.WriteString("(currently `")
msg.WriteString(value)
if cmd.key == roomOptionMailbox {
msg.WriteString("@")
msg.WriteString(b.domain)
}
msg.WriteString("`)")
}
}
msg.WriteString(b.getHelpValue(cfg, cmd))
msg.WriteString(" - ")
msg.WriteString(cmd.description)
msg.WriteString("\n")
}
b.SendNotice(ctx, evt.RoomID, msg.String())
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
func (b *Bot) runSend(ctx context.Context) {
evt := eventFromContext(ctx)
if !b.allowSend(evt.Sender, evt.RoomID) {
to, subject, body, shouldSend := b.getSendDetails(ctx)
if !shouldSend {
return
}
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "failed to retrieve room settings: %v", err)
return
}
var htmlBody string
if !cfg.NoHTML() {
htmlBody = format.RenderMarkdown(body, true, true).FormattedBody
}
tos := strings.Split(to, ",")
b.runSendCommand(ctx, cfg, tos, subject, body, htmlBody)
}
func (b *Bot) getSendDetails(ctx context.Context) (string, string, string, bool) {
evt := eventFromContext(ctx)
if !b.allowSend(evt.Sender, evt.RoomID) {
return "", "", "", false
}
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "failed to retrieve room settings: %v", err)
return "", "", "", false
}
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(
b.lp.SendNotice(evt.RoomID, fmt.Sprintf(
"Usage:\n"+
"```\n"+
"%s send someone@example.com\n"+
@@ -333,45 +567,64 @@ func (b *Bot) runSend(ctx context.Context) {
"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
b.prefix),
linkpearl.RelatesTo(evt.ID, cfg.NoThreads()),
)
return "", "", "", false
}
mailbox := cfg.Mailbox()
if mailbox == "" {
b.SendNotice(ctx, evt.RoomID, "mailbox is not configured, kupo")
return
b.lp.SendNotice(evt.RoomID, "mailbox is not configured, kupo", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return "", "", "", false
}
tos := strings.Split(to, ",")
signature := cfg.Signature()
if signature != "" {
body += "\n\n---\n" + signature
}
return to, subject, body, true
}
func (b *Bot) runSendCommand(ctx context.Context, cfg config.Room, tos []string, subject, body, htmlBody string) {
evt := eventFromContext(ctx)
// validate first
for _, to := range tos {
if !utils.AddressValid(to) {
b.Error(ctx, evt.RoomID, "email address is not valid")
if !email.AddressValid(to) {
b.Error(ctx, "email address is not valid")
return
}
}
from := mailbox + "@" + b.domain
ID := fmt.Sprintf("<%s@%s>", evt.ID, b.domain)
b.mu.Lock(evt.RoomID.String())
defer b.mu.Unlock(evt.RoomID.String())
domain := utils.SanitizeDomain(cfg.Domain())
from := cfg.Mailbox() + "@" + domain
ID := email.MessageID(evt.ID, 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)
recipients := []string{to}
eml := email.New(ID, "", " "+ID, subject, from, to, to, "", body, htmlBody, nil, nil)
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
if data == "" {
b.lp.SendNotice(evt.RoomID, "email body is empty", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return
}
queued, err := b.Sendmail(evt.ID, from, to, data)
if queued {
b.log.Warn().Err(err).Msg("email has been queued")
b.saveSentMetadata(ctx, queued, evt.ID, recipients, eml, cfg)
continue
}
if err != nil {
b.Error(ctx, "cannot send email to %s: %v", to, err)
continue
}
b.saveSentMetadata(ctx, false, evt.ID, recipients, eml, cfg)
}
if len(tos) > 1 {
b.SendNotice(ctx, evt.RoomID, "All emails were sent.")
b.lp.SendNotice(evt.RoomID, "All emails were sent.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
}

View File

@@ -3,18 +3,23 @@ package bot
import (
"context"
"fmt"
"net"
"sort"
"strconv"
"strings"
"time"
"gitlab.com/etke.cc/go/secgen"
"gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/utils"
)
func (b *Bot) sendMailboxes(ctx context.Context) {
evt := eventFromContext(ctx)
mailboxes := map[string]roomSettings{}
mailboxes := map[string]config.Room{}
slice := []string{}
b.rooms.Range(func(key any, value any) bool {
if key == nil {
@@ -32,9 +37,9 @@ func (b *Bot) sendMailboxes(ctx context.Context) {
if !ok {
return true
}
config, err := b.getRoomSettings(roomID)
config, err := b.cfg.GetRoom(roomID)
if err != nil {
b.log.Error("cannot retrieve settings: %v", err)
b.log.Error().Err(err).Msg("cannot retrieve settings")
}
mailboxes[mailbox] = config
@@ -44,7 +49,7 @@ func (b *Bot) sendMailboxes(ctx context.Context) {
sort.Strings(slice)
if len(slice) == 0 {
b.SendNotice(ctx, evt.RoomID, "No mailboxes are managed by the bot so far, kupo!")
b.lp.SendNotice(evt.RoomID, "No mailboxes are managed by the bot so far, kupo!", linkpearl.RelatesTo(evt.ID))
return
}
@@ -53,45 +58,43 @@ func (b *Bot) sendMailboxes(ctx context.Context) {
for _, mailbox := range slice {
cfg := mailboxes[mailbox]
msg.WriteString("* `")
msg.WriteString(mailbox)
msg.WriteString("@")
msg.WriteString(b.domain)
msg.WriteString(utils.EmailsList(mailbox, cfg.Domain()))
msg.WriteString("` by ")
msg.WriteString(cfg.Owner())
msg.WriteString("\n")
}
b.SendNotice(ctx, evt.RoomID, msg.String())
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runDelete(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
if len(commandSlice) < 2 {
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Usage: `%s delete MAILBOX`", b.prefix))
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Usage: `%s delete MAILBOX`", b.prefix), linkpearl.RelatesTo(evt.ID))
return
}
mailbox := utils.Mailbox(commandSlice[1])
v, ok := b.rooms.Load(mailbox)
if v == nil || !ok {
b.SendError(ctx, evt.RoomID, "mailbox does not exists, kupo")
b.lp.SendNotice(evt.RoomID, "mailbox does not exists, kupo", linkpearl.RelatesTo(evt.ID))
return
}
roomID := v.(id.RoomID)
b.rooms.Delete(mailbox)
err := b.setRoomSettings(roomID, roomSettings{})
err := b.cfg.SetRoom(roomID, config.Room{})
if err != nil {
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
b.Error(ctx, "cannot update settings: %v", err)
return
}
b.SendNotice(ctx, evt.RoomID, "mailbox has been deleted")
b.lp.SendNotice(evt.RoomID, "mailbox has been deleted", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
users := cfg.Users()
@@ -106,38 +109,38 @@ func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
msg.WriteString("where each pattern is like `@someone:example.com`, ")
msg.WriteString("`@bot.*:example.com`, `@*:another.com`, or `@*:*`\n")
b.SendNotice(ctx, evt.RoomID, msg.String())
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return
}
_, homeserver, err := b.lp.GetClient().UserID.Parse()
if err != nil {
b.SendError(ctx, evt.RoomID, fmt.Sprintf("invalid userID: %v", err))
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("invalid userID: %v", err), linkpearl.RelatesTo(evt.ID))
}
patterns := commandSlice[1:]
allowedUsers, err := parseMXIDpatterns(patterns, "@*:"+homeserver)
if err != nil {
b.SendError(ctx, evt.RoomID, fmt.Sprintf("invalid patterns: %v", err))
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("invalid patterns: %v", err), linkpearl.RelatesTo(evt.ID))
return
}
cfg.Set(botOptionUsers, strings.Join(patterns, " "))
cfg.Set(config.BotUsers, strings.Join(patterns, " "))
err = b.setBotSettings(cfg)
err = b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err)
b.Error(ctx, "cannot set bot config: %v", err)
}
b.allowedUsers = allowedUsers
b.SendNotice(ctx, evt.RoomID, "allowed users updated")
b.lp.SendNotice(evt.RoomID, "allowed users updated", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
cfg := b.cfg.GetBot()
if len(commandSlice) > 1 && commandSlice[1] == "reset" {
cfg.Set(botOptionDKIMPrivateKey, "")
cfg.Set(botOptionDKIMSignature, "")
cfg.Set(config.BotDKIMPrivateKey, "")
cfg.Set(config.BotDKIMSignature, "")
}
signature := cfg.DKIMSignature()
@@ -146,35 +149,40 @@ func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
var derr error
signature, private, derr = secgen.DKIM()
if derr != nil {
b.Error(ctx, evt.RoomID, "cannot generate DKIM signature: %v", derr)
b.Error(ctx, "cannot generate DKIM signature: %v", derr)
return
}
cfg.Set(botOptionDKIMSignature, signature)
cfg.Set(botOptionDKIMPrivateKey, private)
err := b.setBotSettings(cfg)
cfg.Set(config.BotDKIMSignature, signature)
cfg.Set(config.BotDKIMPrivateKey, private)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
b.Error(ctx, "cannot save bot options: %v", err)
return
}
}
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf(
b.lp.SendNotice(evt.RoomID, fmt.Sprintf(
"DKIM signature is: `%s`.\n"+
"You need to add it to your DNS records (if not already):\n"+
"You need to add it to DNS records of all domains added to postmoogle (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))
signature, signature, b.prefix),
linkpearl.RelatesTo(evt.ID),
)
}
func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.getBotSettings()
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
if cfg.CatchAll() != "" {
msg.WriteString(cfg.CatchAll())
msg.WriteString(" (")
msg.WriteString(utils.EmailsList(cfg.CatchAll(), ""))
msg.WriteString(")")
} else {
msg.WriteString("not set")
}
@@ -184,23 +192,330 @@ func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
msg.WriteString(" catch-all MAILBOX`")
msg.WriteString("where mailbox is valid and existing mailbox name\n")
b.SendNotice(ctx, evt.RoomID, msg.String())
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return
}
mailbox := utils.Mailbox(commandSlice[1])
_, ok := b.GetMapping(mailbox)
if !ok {
b.SendError(ctx, evt.RoomID, "mailbox does not exist, kupo.")
b.lp.SendNotice(evt.RoomID, "mailbox does not exist, kupo.", linkpearl.RelatesTo(evt.ID))
return
}
cfg.Set(botOptionCatchAll, mailbox)
err := b.setBotSettings(cfg)
cfg.Set(config.BotCatchAll, mailbox)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
b.Error(ctx, "cannot save bot options: %v", err)
return
}
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s@%s`.", mailbox, b.domain))
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s` (%s).", mailbox, utils.EmailsList(mailbox, "")), linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runAdminRoom(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
if cfg.AdminRoom() != "" {
msg.WriteString(cfg.AdminRoom().String())
} else {
msg.WriteString("not set")
}
msg.WriteString("`\n\n")
msg.WriteString("Usage: `")
msg.WriteString(b.prefix)
msg.WriteString(" adminroom ROOM_ID`")
msg.WriteString("where ROOM_ID is valid and existing matrix room id\n")
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return
}
roomID := b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case
cfg.Set(config.BotAdminRoom, roomID)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, "cannot save bot options: %v", err)
return
}
b.adminRooms = append([]id.RoomID{id.RoomID(roomID)}, b.adminRooms...) // make it the first room in list on the fly
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Admin Room is set to: `%s`.", roomID), linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) {
cfg := b.cfg.GetBot()
greylist := b.cfg.GetGreylist()
var msg strings.Builder
size := len(greylist)
duration := cfg.Greylist()
msg.WriteString("Currently: `")
if duration == 0 {
msg.WriteString("disabled")
} else {
msg.WriteString(cfg.Get(config.BotGreylist))
msg.WriteString("min")
}
msg.WriteString("`")
if size > 0 {
msg.WriteString(", total known: ")
msg.WriteString(strconv.Itoa(size))
msg.WriteString(" hosts (`")
msg.WriteString(strings.Join(greylist.Slice(), "`, `"))
msg.WriteString("`)\n\n")
}
if duration == 0 {
msg.WriteString("\n\nTo enable greylist: `")
msg.WriteString(b.prefix)
msg.WriteString(" greylist MIN`")
msg.WriteString("where `MIN` is duration in minutes for automatic greylisting\n")
}
b.lp.SendNotice(roomID, msg.String(), linkpearl.RelatesTo(eventFromContext(ctx).ID))
}
func (b *Bot) runGreylist(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
if len(commandSlice) < 2 {
b.printGreylist(ctx, evt.RoomID)
return
}
cfg := b.cfg.GetBot()
value := utils.SanitizeIntString(commandSlice[1])
cfg.Set(config.BotGreylist, value)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, "cannot set bot config: %v", err)
}
b.lp.SendNotice(evt.RoomID, "greylist duration has been updated", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
banlist := b.cfg.GetBanlist()
var msg strings.Builder
size := len(banlist)
if size > 0 {
msg.WriteString("Currently: `")
msg.WriteString(cfg.Get(config.BotBanlistEnabled))
msg.WriteString("`, total: ")
msg.WriteString(strconv.Itoa(size))
msg.WriteString("\n\n")
}
if !cfg.BanlistEnabled() {
msg.WriteString("To enable banlist, send `")
msg.WriteString(b.prefix)
msg.WriteString(" banlist true`\n\n")
}
msg.WriteString("To ban somebody: `")
msg.WriteString(b.prefix)
msg.WriteString(" banlist:add IP1 IP2 IP3...`")
msg.WriteString("where each ip is IPv4 or IPv6\n\n")
msg.WriteString("You can find current banlist values below:\n")
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
b.addBanlistTimeline(ctx, false)
return
}
value := utils.SanitizeBoolString(commandSlice[1])
cfg.Set(config.BotBanlistEnabled, value)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, "cannot set bot config: %v", err)
}
b.lp.SendNotice(evt.RoomID, "banlist has been updated", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runBanlistTotals(ctx context.Context) {
evt := eventFromContext(ctx)
banlist := b.cfg.GetBanlist()
var msg strings.Builder
size := len(banlist)
if size == 0 {
b.lp.SendNotice(evt.RoomID, "banlist is empty, kupo.", linkpearl.RelatesTo(evt.ID))
return
}
msg.WriteString("Total: ")
msg.WriteString(strconv.Itoa(size))
msg.WriteString(" hosts banned\n\n")
msg.WriteString("You can find daily totals below:\n")
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
b.addBanlistTimeline(ctx, true)
}
func (b *Bot) runBanlistAuth(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
msg.WriteString(cfg.Get(config.BotBanlistAuth))
msg.WriteString("`\n\n")
if !cfg.BanlistAuth() {
msg.WriteString("To enable automatic banning for invalid credentials, send `")
msg.WriteString(b.prefix)
msg.WriteString(" banlist:auth true` (banlist itself must be enabled!)\n\n")
}
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return
}
value := utils.SanitizeBoolString(commandSlice[1])
cfg.Set(config.BotBanlistAuth, value)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, "cannot set bot config: %v", err)
}
b.lp.SendNotice(evt.RoomID, "auth banning has been updated", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runBanlistAuto(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
cfg := b.cfg.GetBot()
if len(commandSlice) < 2 {
var msg strings.Builder
msg.WriteString("Currently: `")
msg.WriteString(cfg.Get(config.BotBanlistAuto))
msg.WriteString("`\n\n")
if !cfg.BanlistAuto() {
msg.WriteString("To enable automatic banning for invalid emails, send `")
msg.WriteString(b.prefix)
msg.WriteString(" banlist:auto true` (banlist itself must be enabled!)\n\n")
}
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
return
}
value := utils.SanitizeBoolString(commandSlice[1])
cfg.Set(config.BotBanlistAuto, value)
err := b.cfg.SetBot(cfg)
if err != nil {
b.Error(ctx, "cannot set bot config: %v", err)
}
b.lp.SendNotice(evt.RoomID, "auto banning has been updated", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
if len(commandSlice) < 2 {
b.runBanlist(ctx, commandSlice)
return
}
if !b.cfg.GetBot().BanlistEnabled() {
b.lp.SendNotice(evt.RoomID, "banlist is disabled, you have to enable it first, kupo", linkpearl.RelatesTo(evt.ID))
return
}
banlist := b.cfg.GetBanlist()
ips := commandSlice[1:]
for _, ip := range ips {
addr, err := net.ResolveIPAddr("ip", ip)
if err != nil {
b.Error(ctx, "cannot add %s to banlist: %v", ip, err)
return
}
banlist.Add(addr)
}
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.Error(ctx, "cannot set banlist: %v", err)
return
}
b.lp.SendNotice(evt.RoomID, "banlist has been updated, kupo", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
if len(commandSlice) < 2 {
b.runBanlist(ctx, commandSlice)
return
}
if !b.cfg.GetBot().BanlistEnabled() {
b.lp.SendNotice(evt.RoomID, "banlist is disabled, you have to enable it first, kupo", linkpearl.RelatesTo(evt.ID))
return
}
banlist := b.cfg.GetBanlist()
ips := commandSlice[1:]
for _, ip := range ips {
addr, err := net.ResolveIPAddr("ip", ip)
if err != nil {
b.Error(ctx, "cannot remove %s from banlist: %v", ip, err)
return
}
banlist.Remove(addr)
}
err := b.cfg.SetBanlist(banlist)
if err != nil {
b.Error(ctx, "cannot set banlist: %v", err)
return
}
b.lp.SendNotice(evt.RoomID, "banlist has been updated, kupo", linkpearl.RelatesTo(evt.ID))
}
func (b *Bot) addBanlistTimeline(ctx context.Context, onlyTotals bool) {
evt := eventFromContext(ctx)
banlist := b.cfg.GetBanlist()
timeline := map[string][]string{}
for ip, ts := range banlist {
key := "???"
date, _ := time.ParseInLocation(time.RFC1123Z, ts, time.UTC) //nolint:errcheck // stored in that format
if !date.IsZero() {
key = date.Truncate(24 * time.Hour).Format(time.DateOnly)
}
if _, ok := timeline[key]; !ok {
timeline[key] = []string{}
}
timeline[key] = append(timeline[key], ip)
}
keys := utils.MapKeys(timeline)
for _, chunk := range utils.Chunks(keys, 7) {
var txt strings.Builder
for _, day := range chunk {
data := timeline[day]
sort.Strings(data)
txt.WriteString("* `")
txt.WriteString(day)
if onlyTotals {
txt.WriteString("` ")
txt.WriteString(strconv.Itoa(len(data)))
txt.WriteString(" hosts banned\n")
continue
}
txt.WriteString("` `")
txt.WriteString(strings.Join(data, "`, `"))
txt.WriteString("`\n")
}
b.lp.SendNotice(evt.RoomID, txt.String(), linkpearl.RelatesTo(evt.ID))
}
}
func (b *Bot) runBanlistReset(ctx context.Context) {
evt := eventFromContext(ctx)
if !b.cfg.GetBot().BanlistEnabled() {
b.lp.SendNotice(evt.RoomID, "banlist is disabled, you have to enable it first, kupo", linkpearl.RelatesTo(evt.ID))
return
}
err := b.cfg.SetBanlist(config.List{})
if err != nil {
b.Error(ctx, "cannot set banlist: %v", err)
return
}
b.lp.SendNotice(evt.RoomID, "banlist has been reset, kupo", linkpearl.RelatesTo(evt.ID))
}

View File

@@ -3,33 +3,40 @@ package bot
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/raja/argon2pw"
"gitlab.com/etke.cc/linkpearl"
"golang.org/x/exp/slices"
"gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/utils"
)
func (b *Bot) runStop(ctx context.Context) {
evt := eventFromContext(ctx)
cfg, err := b.getRoomSettings(evt.RoomID)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err)
b.Error(ctx, "failed to retrieve settings: %v", err)
return
}
mailbox := cfg.Get(roomOptionMailbox)
mailbox := cfg.Get(config.RoomMailbox)
if mailbox == "" {
b.SendNotice(ctx, evt.RoomID, "that room is not configured yet")
b.lp.SendNotice(evt.RoomID, "that room is not configured yet", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return
}
b.rooms.Delete(mailbox)
err = b.setRoomSettings(evt.RoomID, roomSettings{})
err = b.cfg.SetRoom(evt.RoomID, config.Room{})
if err != nil {
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
b.Error(ctx, "cannot update settings: %v", err)
return
}
b.SendNotice(ctx, evt.RoomID, "mailbox has been disabled")
b.lp.SendNotice(evt.RoomID, "mailbox has been disabled", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
func (b *Bot) handleOption(ctx context.Context, cmd []string) {
@@ -37,44 +44,114 @@ func (b *Bot) handleOption(ctx context.Context, cmd []string) {
b.getOption(ctx, cmd[0])
return
}
b.setOption(ctx, cmd[0], cmd[1])
switch cmd[0] {
case config.RoomActive:
return
case config.RoomMailbox:
b.setMailbox(ctx, cmd[1])
case config.RoomPassword:
b.setPassword(ctx)
default:
b.setOption(ctx, cmd[0], cmd[1])
}
}
func (b *Bot) getOption(ctx context.Context, name string) {
evt := eventFromContext(ctx)
cfg, err := b.getRoomSettings(evt.RoomID)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err)
b.Error(ctx, "failed to retrieve settings: %v", err)
return
}
if name == commandSpamlist {
name = config.RoomSpamlist
}
value := cfg.Get(name)
if value == "" {
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)
b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return
}
if name == roomOptionMailbox {
value = value + "@" + b.domain
if name == config.RoomMailbox {
value = utils.EmailsList(value, cfg.Domain())
}
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 {
if name == config.RoomPassword {
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)
b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
func (b *Bot) setMailbox(ctx context.Context, value string) {
evt := eventFromContext(ctx)
existingID, ok := b.getMapping(value)
if (ok && existingID != "" && existingID != evt.RoomID) || b.isReserved(value) {
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Mailbox `%s` (%s) already taken, kupo", value, utils.EmailsList(value, "")))
return
}
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "failed to retrieve settings: %v", err)
return
}
old := cfg.Get(config.RoomMailbox)
cfg.Set(config.RoomMailbox, value)
cfg.Set(config.RoomOwner, evt.Sender.String())
if old != "" {
b.rooms.Delete(old)
}
active := b.ActivateMailbox(evt.Sender, evt.RoomID, value)
cfg.Set(config.RoomActive, strconv.FormatBool(active))
value = fmt.Sprintf("%s@%s", value, utils.SanitizeDomain(cfg.Domain()))
err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil {
b.Error(ctx, "cannot update settings: %v", err)
return
}
msg := fmt.Sprintf("mailbox of this room set to `%s`", value)
b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
func (b *Bot) setPassword(ctx context.Context) {
evt := eventFromContext(ctx)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "failed to retrieve settings: %v", err)
return
}
value := b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case
value, err = argon2pw.GenerateSaltedHash(value)
if err != nil {
b.Error(ctx, "failed to hash password: %v", err)
return
}
cfg.Set(config.RoomPassword, value)
err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil {
b.Error(ctx, "cannot update settings: %v", err)
return
}
b.lp.SendNotice(evt.RoomID, "SMTP password has been set", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
//nolint:gocognit
func (b *Bot) setOption(ctx context.Context, name, value string) {
cmd := b.commands.get(name)
if cmd != nil && cmd.sanitizer != nil {
@@ -82,49 +159,136 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
}
evt := eventFromContext(ctx)
if name == roomOptionMailbox {
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
}
}
cfg, err := b.getRoomSettings(evt.RoomID)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err)
b.Error(ctx, "failed to retrieve settings: %v", err)
return
}
if name == roomOptionPassword {
value, err = argon2pw.GenerateSaltedHash(value)
if err != nil {
b.Error(ctx, evt.RoomID, "failed to hash password: %v", err)
return
}
if name == config.RoomAutoreply ||
name == config.RoomSignature {
value = strings.Join(b.parseCommand(evt.Content.AsMessage().Body, false)[1:], " ")
}
if value == "reset" {
value = ""
}
old := cfg.Get(name)
cfg.Set(name, value)
if name == roomOptionMailbox {
cfg.Set(roomOptionOwner, evt.Sender.String())
if old != "" {
b.rooms.Delete(old)
}
b.rooms.Store(value, evt.RoomID)
value = fmt.Sprintf("%s@%s", value, b.domain)
if old == value {
b.lp.SendNotice(evt.RoomID, "nothing changed, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return
}
err = b.setRoomSettings(evt.RoomID, cfg)
cfg.Set(name, value)
err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
b.Error(ctx, "cannot update settings: %v", err)
return
}
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, msg)
b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
func (b *Bot) runSpamlistAdd(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
if len(commandSlice) < 2 {
b.getOption(ctx, config.RoomSpamlist)
return
}
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "cannot get room settings: %v", err)
return
}
spamlist := utils.StringSlice(cfg[config.RoomSpamlist])
for _, newItem := range commandSlice[1:] {
newItem = strings.TrimSpace(newItem)
if slices.Contains(spamlist, newItem) {
continue
}
spamlist = append(spamlist, newItem)
}
cfg.Set(config.RoomSpamlist, utils.SliceString(spamlist))
err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil {
b.Error(ctx, "cannot store room settings: %v", err)
return
}
threadID := threadIDFromContext(ctx)
if threadID == "" {
threadID = evt.ID
}
b.lp.SendNotice(evt.RoomID, "spamlist has been updated, kupo", linkpearl.RelatesTo(threadID, cfg.NoThreads()))
}
func (b *Bot) runSpamlistRemove(ctx context.Context, commandSlice []string) {
evt := eventFromContext(ctx)
if len(commandSlice) < 2 {
b.getOption(ctx, config.RoomSpamlist)
return
}
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "cannot get room settings: %v", err)
return
}
toRemove := map[int]struct{}{}
spamlist := utils.StringSlice(cfg[config.RoomSpamlist])
for _, item := range commandSlice[1:] {
item = strings.TrimSpace(item)
idx := slices.Index(spamlist, item)
if idx < 0 {
continue
}
toRemove[idx] = struct{}{}
}
if len(toRemove) == 0 {
b.lp.SendNotice(evt.RoomID, "nothing new, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return
}
updatedSpamlist := []string{}
for i, item := range spamlist {
if _, ok := toRemove[i]; ok {
continue
}
updatedSpamlist = append(updatedSpamlist, item)
}
cfg.Set(config.RoomSpamlist, utils.SliceString(updatedSpamlist))
err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil {
b.Error(ctx, "cannot store room settings: %v", err)
return
}
b.lp.SendNotice(evt.RoomID, "spamlist has been updated, kupo", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}
func (b *Bot) runSpamlistReset(ctx context.Context) {
evt := eventFromContext(ctx)
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
b.Error(ctx, "cannot get room settings: %v", err)
return
}
spamlist := utils.StringSlice(cfg[config.RoomSpamlist])
if len(spamlist) == 0 {
b.lp.SendNotice(evt.RoomID, "spamlist is empty, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
return
}
cfg.Set(config.RoomSpamlist, "")
err = b.cfg.SetRoom(evt.RoomID, cfg)
if err != nil {
b.Error(ctx, "cannot store room settings: %v", err)
return
}
b.lp.SendNotice(evt.RoomID, "spamlist has been reset, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
}

110
bot/config/bot.go Normal file
View File

@@ -0,0 +1,110 @@
package config
import (
"strings"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data key
const acBotKey = "cc.etke.postmoogle.config"
// bot options keys
const (
BotAdminRoom = "adminroom"
BotUsers = "users"
BotCatchAll = "catch-all"
BotDKIMSignature = "dkim.pub"
BotDKIMPrivateKey = "dkim.pem"
BotQueueBatch = "queue:batch"
BotQueueRetries = "queue:retries"
BotBanlistEnabled = "banlist:enabled"
BotBanlistAuto = "banlist:auto"
BotBanlistAuth = "banlist:auth"
BotGreylist = "greylist"
BotMautrix015Migration = "mautrix015migration"
)
// Bot map
type Bot map[string]string
// Get option
func (s Bot) Get(key string) string {
return s[strings.ToLower(strings.TrimSpace(key))]
}
// Set option
func (s Bot) Set(key, value string) {
s[strings.ToLower(strings.TrimSpace(key))] = value
}
// Mautrix015Migration option (timestamp)
func (s Bot) Mautrix015Migration() int64 {
return utils.Int64(s.Get(BotMautrix015Migration))
}
// Users option
func (s Bot) Users() []string {
value := s.Get(BotUsers)
if value == "" {
return []string{}
}
if strings.Contains(value, " ") {
return strings.Split(value, " ")
}
return []string{value}
}
// CatchAll option
func (s Bot) CatchAll() string {
return s.Get(BotCatchAll)
}
// AdminRoom option
func (s Bot) AdminRoom() id.RoomID {
return id.RoomID(s.Get(BotAdminRoom))
}
// BanlistEnabled option
func (s Bot) BanlistEnabled() bool {
return utils.Bool(s.Get(BotBanlistEnabled))
}
// BanlistAuto option
func (s Bot) BanlistAuto() bool {
return utils.Bool(s.Get(BotBanlistAuto))
}
// BanlistAuth option
func (s Bot) BanlistAuth() bool {
return utils.Bool(s.Get(BotBanlistAuth))
}
// Greylist option (duration in minutes)
func (s Bot) Greylist() int {
return utils.Int(s.Get(BotGreylist))
}
// DKIMSignature (DNS TXT record)
func (s Bot) DKIMSignature() string {
return s.Get(BotDKIMSignature)
}
// DKIMPrivateKey keep it secret
func (s Bot) DKIMPrivateKey() string {
return s.Get(BotDKIMPrivateKey)
}
// QueueBatch option
func (s Bot) QueueBatch() int {
return utils.Int(s.Get(BotQueueBatch))
}
// QueueRetries option
func (s Bot) QueueRetries() int {
return utils.Int(s.Get(BotQueueRetries))
}

69
bot/config/lists.go Normal file
View File

@@ -0,0 +1,69 @@
package config
import (
"net"
"sort"
"time"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data keys
const (
acBanlistKey = "cc.etke.postmoogle.banlist"
acGreylistKey = "cc.etke.postmoogle.greylist"
)
// List config
type List map[string]string
// Slice returns slice of ban- or greylist items
func (l List) Slice() []string {
slice := make([]string, 0, len(l))
for item := range l {
slice = append(slice, item)
}
sort.Strings(slice)
return slice
}
// Has addr in ban- or greylist
func (l List) Has(addr net.Addr) bool {
_, ok := l[utils.AddrIP(addr)]
return ok
}
// Get when addr was added in ban- or greylist
func (l List) Get(addr net.Addr) (time.Time, bool) {
from := l[utils.AddrIP(addr)]
if from == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC1123Z, from)
if err != nil {
return time.Time{}, false
}
return t, true
}
// Add an addr to ban- or greylist
func (l List) Add(addr net.Addr) {
key := utils.AddrIP(addr)
if _, ok := l[key]; ok {
return
}
l[key] = time.Now().UTC().Format(time.RFC1123Z)
}
// Remove an addr from ban- or greylist
func (l List) Remove(addr net.Addr) {
key := utils.AddrIP(addr)
if _, ok := l[key]; !ok {
return
}
delete(l, key)
}

116
bot/config/manager.go Normal file
View File

@@ -0,0 +1,116 @@
package config
import (
"github.com/rs/zerolog"
"gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// Manager of configs
type Manager struct {
mu utils.Mutex
log *zerolog.Logger
lp *linkpearl.Linkpearl
}
// New config manager
func New(lp *linkpearl.Linkpearl, log *zerolog.Logger) *Manager {
m := &Manager{
mu: utils.NewMutex(),
lp: lp,
log: log,
}
return m
}
// GetBot config
func (m *Manager) GetBot() Bot {
var err error
var config Bot
config, err = m.lp.GetAccountData(acBotKey)
if err != nil {
m.log.Error().Err(err).Msg("cannot get bot settings")
}
if config == nil {
config = make(Bot, 0)
return config
}
return config
}
// SetBot config
func (m *Manager) SetBot(cfg Bot) error {
return m.lp.SetAccountData(acBotKey, cfg)
}
// GetRoom config
func (m *Manager) GetRoom(roomID id.RoomID) (Room, error) {
config, err := m.lp.GetRoomAccountData(roomID, acRoomKey)
if config == nil {
config = make(Room, 0)
}
return config, err
}
// SetRoom config
func (m *Manager) SetRoom(roomID id.RoomID, cfg Room) error {
return m.lp.SetRoomAccountData(roomID, acRoomKey, cfg)
}
// GetBanlist config
func (m *Manager) GetBanlist() List {
if !m.GetBot().BanlistEnabled() {
return make(List, 0)
}
m.mu.Lock("banlist")
defer m.mu.Unlock("banlist")
config, err := m.lp.GetAccountData(acBanlistKey)
if err != nil {
m.log.Error().Err(err).Msg("cannot get banlist")
}
if config == nil {
config = make(List, 0)
return config
}
return config
}
// SetBanlist config
func (m *Manager) SetBanlist(cfg List) error {
if !m.GetBot().BanlistEnabled() {
return nil
}
m.mu.Lock("banlist")
defer m.mu.Unlock("banlist")
if cfg == nil {
cfg = make(List, 0)
}
return m.lp.SetAccountData(acBanlistKey, cfg)
}
// GetGreylist config
func (m *Manager) GetGreylist() List {
config, err := m.lp.GetAccountData(acGreylistKey)
if err != nil {
m.log.Error().Err(err).Msg("cannot get banlist")
}
if config == nil {
config = make(List, 0)
return config
}
return config
}
// SetGreylist config
func (m *Manager) SetGreylist(cfg List) error {
return m.lp.SetAccountData(acGreylistKey, cfg)
}

206
bot/config/room.go Normal file
View File

@@ -0,0 +1,206 @@
package config
import (
"strings"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data key
const acRoomKey = "cc.etke.postmoogle.settings"
type Room map[string]string
// option keys
const (
RoomActive = ".active"
RoomOwner = "owner"
RoomMailbox = "mailbox"
RoomDomain = "domain"
RoomPassword = "password"
RoomSignature = "signature"
RoomAutoreply = "autoreply"
RoomNoCC = "nocc"
RoomNoFiles = "nofiles"
RoomNoHTML = "nohtml"
RoomNoInlines = "noinlines"
RoomNoRecipient = "norecipient"
RoomNoReplies = "noreplies"
RoomNoSend = "nosend"
RoomNoSender = "nosender"
RoomNoSubject = "nosubject"
RoomNoThreads = "nothreads"
RoomSpamcheckDKIM = "spamcheck:dkim"
RoomSpamcheckMX = "spamcheck:mx"
RoomSpamcheckSMTP = "spamcheck:smtp"
RoomSpamcheckSPF = "spamcheck:spf"
RoomSpamlist = "spamlist"
)
// Get option
func (s Room) Get(key string) string {
return s[strings.ToLower(strings.TrimSpace(key))]
}
// Set option
func (s Room) Set(key, value string) {
s[strings.ToLower(strings.TrimSpace(key))] = value
}
func (s Room) Mailbox() string {
return s.Get(RoomMailbox)
}
func (s Room) Domain() string {
return s.Get(RoomDomain)
}
func (s Room) Owner() string {
return s.Get(RoomOwner)
}
func (s Room) Active() bool {
return utils.Bool(s.Get(RoomActive))
}
func (s Room) Password() string {
return s.Get(RoomPassword)
}
func (s Room) Signature() string {
return s.Get(RoomSignature)
}
func (s Room) Autoreply() string {
return s.Get(RoomAutoreply)
}
func (s Room) NoSend() bool {
return utils.Bool(s.Get(RoomNoSend))
}
func (s Room) NoReplies() bool {
return utils.Bool(s.Get(RoomNoReplies))
}
func (s Room) NoCC() bool {
return utils.Bool(s.Get(RoomNoCC))
}
func (s Room) NoSender() bool {
return utils.Bool(s.Get(RoomNoSender))
}
func (s Room) NoRecipient() bool {
return utils.Bool(s.Get(RoomNoRecipient))
}
func (s Room) NoSubject() bool {
return utils.Bool(s.Get(RoomNoSubject))
}
func (s Room) NoHTML() bool {
return utils.Bool(s.Get(RoomNoHTML))
}
func (s Room) NoThreads() bool {
return utils.Bool(s.Get(RoomNoThreads))
}
func (s Room) NoFiles() bool {
return utils.Bool(s.Get(RoomNoFiles))
}
func (s Room) NoInlines() bool {
return utils.Bool(s.Get(RoomNoInlines))
}
func (s Room) SpamcheckDKIM() bool {
return utils.Bool(s.Get(RoomSpamcheckDKIM))
}
func (s Room) SpamcheckSMTP() bool {
return utils.Bool(s.Get(RoomSpamcheckSMTP))
}
func (s Room) SpamcheckSPF() bool {
return utils.Bool(s.Get(RoomSpamcheckSPF))
}
func (s Room) SpamcheckMX() bool {
return utils.Bool(s.Get(RoomSpamcheckMX))
}
func (s Room) Spamlist() []string {
return utils.StringSlice(s.Get(RoomSpamlist))
}
func (s Room) 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(RoomSpamlist))
delete(s, "spamlist:emails")
delete(s, "spamlist:localparts")
delete(s, "spamlist:hosts")
for _, email := range emails {
if email == "" {
continue
}
uniq[email] = struct{}{}
}
for _, localpart := range localparts {
if localpart == "" {
continue
}
uniq[localpart+"@*"] = struct{}{}
}
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(RoomSpamlist, utils.SliceString(spamlist))
}
// ContentOptions converts room display settings to content options
func (s Room) ContentOptions() *email.ContentOptions {
return &email.ContentOptions{
CC: !s.NoCC(),
HTML: !s.NoHTML(),
Sender: !s.NoSender(),
Recipient: !s.NoRecipient(),
Subject: !s.NoSubject(),
Threads: !s.NoThreads(),
ToKey: "cc.etke.postmoogle.to",
CcKey: "cc.etke.postmoogle.cc",
FromKey: "cc.etke.postmoogle.from",
RcptToKey: "cc.etke.postmoogle.rcptTo",
SubjectKey: "cc.etke.postmoogle.subject",
InReplyToKey: "cc.etke.postmoogle.inReplyTo",
MessageIDKey: "cc.etke.postmoogle.messageID",
ReferencesKey: "cc.etke.postmoogle.references",
}
}

View File

@@ -5,12 +5,14 @@ import (
"github.com/getsentry/sentry-go"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type ctxkey int
const (
ctxEvent ctxkey = iota
ctxEvent ctxkey = iota
ctxThreadID ctxkey = iota
)
func newContext(evt *event.Event) context.Context {
@@ -49,3 +51,21 @@ func eventToContext(ctx context.Context, evt *event.Event) context.Context {
return ctx
}
func threadIDToContext(ctx context.Context, threadID id.EventID) context.Context {
return context.WithValue(ctx, ctxThreadID, threadID)
}
func threadIDFromContext(ctx context.Context) id.EventID {
v := ctx.Value(ctxThreadID)
if v == nil {
return ""
}
threadID, ok := v.(id.EventID)
if !ok {
return ""
}
return threadID
}

View File

@@ -1,52 +1,106 @@
package bot
var migrations = []string{}
import (
"strconv"
"time"
func (b *Bot) migrate() error {
b.log.Debug("migrating database...")
tx, beginErr := b.lp.GetDB().Begin()
if beginErr != nil {
b.log.Error("cannot begin transaction: %v", beginErr)
return beginErr
}
"maunium.net/go/mautrix/id"
for _, query := range migrations {
_, execErr := tx.Exec(query)
if execErr != nil {
b.log.Error("cannot apply migration: %v", execErr)
// nolint // we already have the execErr to return
tx.Rollback()
return execErr
}
}
commitErr := tx.Commit()
if commitErr != nil {
b.log.Error("cannot commit transaction: %v", commitErr)
// nolint // we already have the commitErr to return
tx.Rollback()
return commitErr
}
return nil
}
"gitlab.com/etke.cc/postmoogle/bot/config"
)
func (b *Bot) syncRooms() error {
adminRooms := []id.RoomID{}
adminRoom := b.cfg.GetBot().AdminRoom()
if adminRoom != "" {
adminRooms = append(adminRooms, adminRoom)
}
resp, err := b.lp.GetClient().JoinedRooms()
if err != nil {
return err
}
for _, roomID := range resp.JoinedRooms {
cfg, serr := b.getRoomSettings(roomID)
b.migrateRoomSettings(roomID)
cfg, serr := b.cfg.GetRoom(roomID)
if serr != nil {
continue
}
b.migrateRoomSettings(roomID)
mailbox := cfg.Mailbox()
if mailbox != "" {
active := cfg.Active()
if mailbox != "" && active {
b.rooms.Store(mailbox, roomID)
}
if cfg.Owner() != "" && b.allowAdmin(id.UserID(cfg.Owner()), "") {
adminRooms = append(adminRooms, roomID)
}
}
b.adminRooms = adminRooms
return nil
}
func (b *Bot) migrateRoomSettings(roomID id.RoomID) {
cfg, err := b.cfg.GetRoom(roomID)
if err != nil {
b.log.Error().Err(err).Msg("cannot retrieve room settings")
return
}
if _, ok := cfg[config.RoomActive]; !ok {
cfg.Set(config.RoomActive, "true")
}
if cfg["spamlist:emails"] == "" && cfg["spamlist:localparts"] == "" && cfg["spamlist:hosts"] == "" {
return
}
cfg.MigrateSpamlistSettings()
err = b.cfg.SetRoom(roomID, cfg)
if err != nil {
b.log.Error().Err(err).Msg("cannot migrate room settings")
}
}
// migrateMautrix015 adds a special timestamp to bot's config
// to ignore any message events happened before that timestamp
// with migration to maturix 0.15.x the state store has been changed
// alongside with other database configs to simplify maintenance,
// but with that simplification there is no proper way to migrate
// existing sync token and session info. No data loss, tho.
func (b *Bot) migrateMautrix015() error {
cfg := b.cfg.GetBot()
ts := cfg.Mautrix015Migration()
// already migrated
if ts > 0 {
b.ignoreBefore = ts
return nil
}
ts = time.Now().UTC().UnixMilli()
b.ignoreBefore = ts
tss := strconv.FormatInt(ts, 10)
cfg.Set(config.BotMautrix015Migration, tss)
return b.cfg.SetBot(cfg)
}
func (b *Bot) initBotUsers() ([]string, error) {
cfg := b.cfg.GetBot()
cfgUsers := cfg.Users()
if len(cfgUsers) > 0 {
return cfgUsers, nil
}
_, homeserver, err := b.lp.GetClient().UserID.Parse()
if err != nil {
return nil, err
}
cfg.Set(config.BotUsers, "@*:"+homeserver)
return cfg.Users(), b.cfg.SetBot(cfg)
}
// SyncRooms and mailboxes
func (b *Bot) SyncRooms() {
b.syncRooms() //nolint:errcheck // nothing can be done here
}

View File

@@ -3,12 +3,15 @@ package bot
import (
"context"
"errors"
"fmt"
"strings"
"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/bot/config"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils"
)
@@ -20,15 +23,54 @@ const (
// event keys
const (
eventMessageIDkey = "cc.etke.postmoogle.messageID"
eventInReplyToKey = "cc.etke.postmoogle.inReplyTo"
eventSubjectKey = "cc.etke.postmoogle.subject"
eventFromKey = "cc.etke.postmoogle.from"
eventMessageIDkey = "cc.etke.postmoogle.messageID"
eventReferencesKey = "cc.etke.postmoogle.references"
eventInReplyToKey = "cc.etke.postmoogle.inReplyTo"
eventSubjectKey = "cc.etke.postmoogle.subject"
eventRcptToKey = "cc.etke.postmoogle.rcptTo"
eventFromKey = "cc.etke.postmoogle.from"
eventToKey = "cc.etke.postmoogle.to"
eventCcKey = "cc.etke.postmoogle.cc"
)
// SetMTA sets mail transfer agent instance to the bot
func (b *Bot) SetMTA(mta utils.MTA) {
b.mta = mta
// SetSendmail sets mail sending func to the bot
func (b *Bot) SetSendmail(sendmail func(string, string, string) error) {
b.sendmail = sendmail
b.q.SetSendmail(sendmail)
}
func (b *Bot) shouldQueue(msg string) bool {
errors := strings.Split(msg, ";")
for _, err := range errors {
errParts := strings.Split(strings.TrimSpace(err), ":")
if len(errParts) < 2 {
continue
}
if strings.HasPrefix(strings.TrimSpace(errParts[1]), "4") {
return true
}
}
return false
}
// Sendmail tries to send email immediately, but if it gets 4xx error (greylisting),
// the email will be added to the queue and retried several times after that
func (b *Bot) Sendmail(eventID id.EventID, from, to, data string) (bool, error) {
err := b.sendmail(from, to, data)
if err != nil {
if b.shouldQueue(err.Error()) {
b.log.Info().Err(err).Str("id", eventID.String()).Str("from", from).Str("to", to).Msg("email has been added to the queue")
return true, b.q.Add(eventID.String(), from, to, data)
}
return false, err
}
return false, nil
}
// GetDKIMprivkey returns DKIM private key
func (b *Bot) GetDKIMprivkey() string {
return b.cfg.GetBot().DKIMPrivateKey()
}
func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
@@ -49,7 +91,7 @@ func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
roomID, ok := b.getMapping(mailbox)
if !ok {
catchAll := b.getBotSettings().CatchAll()
catchAll := b.cfg.GetBot().CatchAll()
if catchAll == "" {
return roomID, ok
}
@@ -60,204 +102,500 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
}
// GetIFOptions returns incoming email filtering options (room settings)
func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions {
cfg, err := b.getRoomSettings(roomID)
func (b *Bot) GetIFOptions(roomID id.RoomID) email.IncomingFilteringOptions {
cfg, err := b.cfg.GetRoom(roomID)
if err != nil {
b.log.Error("cannot retrieve room settings: %v", err)
return roomSettings{}
b.log.Error().Err(err).Msg("cannot retrieve room settings")
}
return cfg
}
// Send email to matrix room
func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) error {
roomID, ok := b.GetMapping(email.Mailbox(incoming))
// IncomingEmail sends incoming email to matrix room
//
//nolint:gocognit // TODO
func (b *Bot) IncomingEmail(ctx context.Context, email *email.Email) error {
roomID, ok := b.GetMapping(email.Mailbox(true))
if !ok {
return errors.New("room not found")
}
b.lock(roomID)
defer b.unlock(roomID)
cfg, err := b.getRoomSettings(roomID)
cfg, err := b.cfg.GetRoom(roomID)
if err != nil {
b.Error(ctx, roomID, "cannot get settings: %v", err)
b.Error(ctx, "cannot get settings: %v", err)
}
if !incoming && cfg.NoSend() {
return errors.New("that mailbox is receive-only")
}
b.mu.Lock(roomID.String())
defer b.mu.Unlock(roomID.String())
var threadID id.EventID
if email.InReplyTo != "" && !cfg.NoThreads() {
threadID = b.getThreadID(roomID, email.InReplyTo)
newThread := true
if email.InReplyTo != "" || email.References != "" {
threadID = b.getThreadID(roomID, email.InReplyTo, email.References)
if threadID != "" {
newThread = false
ctx = threadIDToContext(ctx, threadID)
b.setThreadID(roomID, email.MessageID, threadID)
}
}
content := email.Content(threadID, cfg.ContentOptions())
eventID, serr := b.lp.Send(roomID, content)
if serr != nil {
return utils.UnwrapError(serr)
if !strings.Contains(serr.Error(), "M_UNKNOWN") { // if it's not an unknown event event error
return serr
}
threadID = "" // unknown event edge case - remove existing thread ID to avoid complications
newThread = true
}
if threadID == "" {
threadID = eventID
ctx = threadIDToContext(ctx, threadID)
}
if threadID == "" && !cfg.NoThreads() {
b.setThreadID(roomID, email.MessageID, eventID)
threadID = eventID
}
b.setThreadID(roomID, email.MessageID, threadID)
b.setLastEventID(roomID, threadID, eventID)
if !cfg.NoInlines() {
b.sendFiles(ctx, roomID, email.InlineFiles, cfg.NoThreads(), threadID)
}
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()))
if newThread && cfg.Autoreply() != "" {
b.sendAutoreply(roomID, threadID)
}
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)
//nolint:gocognit // TODO
func (b *Bot) sendAutoreply(roomID id.RoomID, threadID id.EventID) {
cfg, err := b.cfg.GetRoom(roomID)
if err != nil {
b.log.Error("cannot get parent event: %v", err)
return "", "", ""
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 "", "", ""
text := cfg.Autoreply()
if text == "" {
return
}
threadEvt, err := b.lp.GetClient().GetEvent(roomID, threadID)
if err != nil {
b.log.Error().Err(err).Msg("cannot get thread event for autoreply")
return
}
evt := &event.Event{
ID: threadID + "-autoreply",
RoomID: roomID,
Content: event.Content{
Parsed: &event.MessageEventContent{
RelatesTo: &event.RelatesTo{
Type: event.RelThread,
EventID: threadID,
},
},
},
}
meta := b.getParentEmail(evt, cfg.Mailbox())
if meta.To == "" {
return
}
if meta.ThreadID == "" {
meta.ThreadID = threadID
}
if meta.Subject == "" {
meta.Subject = "Automatic response"
}
content := format.RenderMarkdown(text, true, true)
signature := format.RenderMarkdown(cfg.Signature(), true, true)
body := content.Body
if signature.Body != "" {
body += "\n\n---\n" + signature.Body
}
var htmlBody string
if !cfg.NoHTML() {
htmlBody = content.FormattedBody
if htmlBody != "" && signature.FormattedBody != "" {
htmlBody += "<br><hr><br>" + signature.FormattedBody
}
}
to := utils.EventField[string](&parentEvt.Content, eventFromKey)
inReplyTo := utils.EventField[string](&parentEvt.Content, eventMessageIDkey)
if inReplyTo == "" {
inReplyTo = parentID.String()
meta.MessageID = email.MessageID(evt.ID, meta.FromDomain)
meta.References = meta.References + " " + meta.MessageID
b.log.Info().Any("meta", meta).Msg("sending automatic reply")
eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil, nil)
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
if data == "" {
return
}
subject := utils.EventField[string](&parentEvt.Content, eventSubjectKey)
if subject != "" {
subject = "Re: " + subject
} else {
subject = strings.SplitN(content.Body, "\n", 1)[0]
var queued bool
ctx := newContext(threadEvt)
recipients := meta.Recipients
for _, to := range recipients {
queued, err = b.Sendmail(evt.ID, meta.From, to, data)
if queued {
b.log.Info().Err(err).Str("from", meta.From).Str("to", to).Msg("email has been queued")
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg, "Autoreply has been sent (queued)")
continue
}
if err != nil {
b.Error(ctx, "cannot send email: %v", err)
continue
}
}
return to, inReplyTo, subject
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg, "Autoreply has been sent")
}
// 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
func (b *Bot) canReply(sender id.UserID, roomID id.RoomID) bool {
return b.allowSend(sender, roomID) && b.allowReply(sender, roomID)
}
// SendEmailReply sends replies from matrix thread to email thread
//
//nolint:gocognit // TODO
func (b *Bot) SendEmailReply(ctx context.Context) {
evt := eventFromContext(ctx)
cfg, err := b.getRoomSettings(evt.RoomID)
if !b.canReply(evt.Sender, evt.RoomID) {
return
}
cfg, err := b.cfg.GetRoom(evt.RoomID)
if err != nil {
return err
b.Error(ctx, "cannot retrieve room settings: %v", err)
return
}
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
b.Error(ctx, "mailbox is not configured, kupo")
return
}
content := evt.Content.AsMessage()
if subject == "" {
subject = strings.SplitN(content.Body, "\n", 1)[0]
b.mu.Lock(evt.RoomID.String())
defer b.mu.Unlock(evt.RoomID.String())
meta := b.getParentEmail(evt, mailbox)
if meta.To == "" {
b.Error(ctx, "cannot find parent email and continue the thread. Please, start a new email thread")
return
}
if body == "" {
if content.FormattedBody != "" {
body = content.FormattedBody
} else {
body = content.Body
if meta.ThreadID == "" {
meta.ThreadID = b.getThreadID(evt.RoomID, meta.InReplyTo, meta.References)
ctx = threadIDToContext(ctx, meta.ThreadID)
}
content := evt.Content.AsMessage()
if meta.Subject == "" {
meta.Subject = strings.SplitN(content.Body, "\n", 1)[0]
}
signature := format.RenderMarkdown(cfg.Signature(), true, true)
body := content.Body
if signature.Body != "" {
body += "\n\n---\n" + signature.Body
}
var htmlBody string
if !cfg.NoHTML() {
htmlBody = content.FormattedBody
if htmlBody != "" && signature.FormattedBody != "" {
htmlBody += "<br><hr><br>" + signature.FormattedBody
}
}
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)
meta.MessageID = email.MessageID(evt.ID, meta.FromDomain)
meta.References = meta.References + " " + meta.MessageID
b.log.Info().Any("meta", meta).Msg("sending email reply")
eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil, nil)
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
if data == "" {
b.lp.SendNotice(evt.RoomID, "email body is empty", linkpearl.RelatesTo(meta.ThreadID, cfg.NoThreads()))
return
}
var queued bool
recipients := meta.Recipients
for _, to := range recipients {
queued, err = b.Sendmail(evt.ID, meta.From, to, data)
if queued {
b.log.Info().Err(err).Str("from", meta.From).Str("to", to).Msg("email has been queued")
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg)
continue
}
if err != nil {
b.Error(ctx, "cannot send email: %v", err)
continue
}
}
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg)
}
type parentEmail struct {
MessageID string
ThreadID id.EventID
From string
FromDomain string
To string
RcptTo string
CC string
InReplyTo string
References string
Subject string
Recipients []string
}
// fixtofrom attempts to "fix" or rather reverse the To, From and CC headers
// of parent email by using parent email as metadata source for a new email
// that will be sent from postmoogle.
// To do so, we need to reverse From and To headers, but Cc should be adjusted as well,
// thus that hacky workaround below:
func (e *parentEmail) fixtofrom(newSenderMailbox string, domains []string) string {
newSenders := make(map[string]string, len(domains))
for _, domain := range domains {
sender := newSenderMailbox + "@" + domain
newSenders[sender] = sender
}
// try to determine previous email of the room mailbox
// by matching RCPT TO, To and From fields
// why? Because of possible multi-domain setup and we won't leak information
var previousSender string
rcptToSender, ok := newSenders[e.RcptTo]
if ok {
previousSender = rcptToSender
}
toSender, ok := newSenders[e.To]
if ok {
previousSender = toSender
}
fromSender, ok := newSenders[e.From]
if ok {
previousSender = fromSender
}
// Message-Id should not leak information either
e.FromDomain = utils.SanitizeDomain(utils.Hostname(previousSender))
originalFrom := e.From
// reverse From if needed
if fromSender == "" {
e.From = previousSender
}
// reverse To if needed
if toSender != "" {
e.To = originalFrom
}
// replace previous recipient of the email which is sender now with the original From
for newSender := range newSenders {
if strings.Contains(e.CC, newSender) {
e.CC = strings.ReplaceAll(e.CC, newSender, originalFrom)
}
}
return previousSender
}
func (e *parentEmail) calculateRecipients(from string, forwardedFrom []string) {
recipients := map[string]struct{}{}
recipients[e.From] = struct{}{}
for _, addr := range strings.Split(email.Address(e.To), ",") {
recipients[addr] = struct{}{}
}
for _, addr := range email.AddressList(e.CC) {
recipients[addr] = struct{}{}
}
for _, addr := range forwardedFrom {
delete(recipients, addr)
}
delete(recipients, from)
rcpts := make([]string, 0, len(recipients))
for rcpt := range recipients {
rcpts = append(rcpts, rcpt)
}
e.Recipients = rcpts
}
func (b *Bot) getParentEvent(evt *event.Event) (id.EventID, *event.Event) {
content := evt.Content.AsMessage()
threadID := linkpearl.EventParent(evt.ID, content)
b.log.Debug().Str("eventID", evt.ID.String()).Str("threadID", threadID.String()).Msg("looking up for the parent event within thread")
if threadID == evt.ID {
b.log.Debug().Str("eventID", evt.ID.String()).Msg("event is the thread itself")
return threadID, evt
}
lastEventID := b.getLastEventID(evt.RoomID, threadID)
b.log.Debug().Str("eventID", evt.ID.String()).Str("threadID", threadID.String()).Str("lastEventID", lastEventID.String()).Msg("the last event of the thread (and parent of the event) has been found")
if lastEventID == evt.ID {
return threadID, evt
}
parentEvt, err := b.lp.GetClient().GetEvent(evt.RoomID, lastEventID)
if err != nil {
b.log.Error().Err(err).Msg("cannot get parent event")
return threadID, nil
}
linkpearl.ParseContent(parentEvt, parentEvt.Type, b.log)
if !b.lp.GetMachine().StateStore.IsEncrypted(evt.RoomID) {
return threadID, parentEvt
}
decrypted, err := b.lp.GetClient().Crypto.Decrypt(parentEvt)
if err != nil {
b.log.Error().Err(err).Msg("cannot decrypt parent event")
return threadID, nil
}
return threadID, decrypted
}
func (b *Bot) getParentEmail(evt *event.Event, newFromMailbox string) *parentEmail {
parent := &parentEmail{}
threadID, parentEvt := b.getParentEvent(evt)
parent.ThreadID = threadID
if parentEvt == nil {
return parent
}
if parentEvt.ID == evt.ID {
return parent
}
parent.From = linkpearl.EventField[string](&parentEvt.Content, eventFromKey)
parent.To = linkpearl.EventField[string](&parentEvt.Content, eventToKey)
parent.CC = linkpearl.EventField[string](&parentEvt.Content, eventCcKey)
parent.RcptTo = linkpearl.EventField[string](&parentEvt.Content, eventRcptToKey)
parent.InReplyTo = linkpearl.EventField[string](&parentEvt.Content, eventMessageIDkey)
parent.References = linkpearl.EventField[string](&parentEvt.Content, eventReferencesKey)
senderEmail := parent.fixtofrom(newFromMailbox, b.domains)
parent.calculateRecipients(senderEmail, b.mbxc.Forwarded)
parent.MessageID = email.MessageID(parentEvt.ID, parent.FromDomain)
if parent.InReplyTo == "" {
parent.InReplyTo = parent.MessageID
}
if parent.References == "" {
parent.References = " " + parent.MessageID
}
parent.Subject = linkpearl.EventField[string](&parentEvt.Content, eventSubjectKey)
if parent.Subject != "" {
parent.Subject = "Re: " + parent.Subject
} else {
parent.Subject = strings.SplitN(evt.Content.AsMessage().Body, "\n", 1)[0]
}
return parent
}
// saveSentMetadata used to save metadata from !pm sent and thread reply events to a separate notice message
// because that metadata is needed to determine email thread relations
func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, recipients []string, eml *email.Email, cfg config.Room, textOverride ...string) {
addrs := strings.Join(recipients, ", ")
text := "Email has been sent to " + addrs
if queued {
text = "Email to " + addrs + " has been queued"
}
if len(textOverride) > 0 {
text = textOverride[0]
}
evt := eventFromContext(ctx)
content := eml.Content(threadID, cfg.ContentOptions())
notice := format.RenderMarkdown(text, true, true)
msgContent, ok := content.Parsed.(*event.MessageEventContent)
if !ok {
b.Error(ctx, "cannot parse message")
return
}
msgContent.MsgType = event.MsgNotice
msgContent.Body = notice.Body
msgContent.FormattedBody = notice.FormattedBody
msgContent.RelatesTo = linkpearl.RelatesTo(threadID, cfg.NoThreads())
content.Parsed = msgContent
msgID, err := b.lp.Send(evt.RoomID, content)
if err != nil {
b.Error(ctx, "cannot send notice: %v", err)
return
}
domain := utils.SanitizeDomain(cfg.Domain())
b.setThreadID(evt.RoomID, email.MessageID(evt.ID, domain), threadID)
b.setThreadID(evt.RoomID, email.MessageID(msgID, domain), threadID)
b.setLastEventID(evt.RoomID, threadID, msgID)
}
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()
err := b.lp.SendFile(roomID, req, file.MsgType, utils.RelatesTo(!noThreads, parentID))
err := b.lp.SendFile(roomID, req, file.MsgType, linkpearl.RelatesTo(parentID, noThreads))
if err != nil {
b.Error(ctx, roomID, "cannot upload file %s: %v", req.FileName, err)
b.Error(ctx, "cannot upload file %s: %v", req.FileName, err)
}
}
}
func (b *Bot) getThreadID(roomID id.RoomID, messageID string) id.EventID {
key := acMessagePrefix + "." + messageID
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 ""
}
func (b *Bot) getThreadID(roomID id.RoomID, messageID string, references string) id.EventID {
refs := []string{messageID}
if references != "" {
refs = append(refs, strings.Split(references, " ")...)
}
return data["eventID"]
for _, refID := range refs {
key := acMessagePrefix + "." + refID
data, err := b.lp.GetRoomAccountData(roomID, key)
if err != nil {
b.log.Error().Err(err).Str("key", key).Msg("cannot retrieve thread ID")
continue
}
if data["eventID"] == "" {
continue
}
resp, err := b.lp.GetClient().GetEvent(roomID, id.EventID(data["eventID"]))
if err != nil {
b.log.Warn().Err(err).Str("roomID", roomID.String()).Str("eventID", data["eventID"]).Msg("cannot get event by id (may be removed)")
continue
}
return resp.ID
}
return ""
}
func (b *Bot) setThreadID(roomID id.RoomID, messageID string, eventID id.EventID) {
key := acMessagePrefix + "." + messageID
data := map[string]id.EventID{
"eventID": eventID,
}
err := b.lp.GetClient().SetRoomAccountData(roomID, key, data)
err := b.lp.SetRoomAccountData(roomID, key, map[string]string{"eventID": eventID.String()})
if err != nil {
if !strings.Contains(err.Error(), "M_NOT_FOUND") {
b.log.Error("cannot save account data %s: %v", key, err)
}
b.log.Error().Err(err).Str("key", key).Msg("cannot save thread ID")
}
}
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)
data, err := b.lp.GetRoomAccountData(roomID, key)
if err != nil {
if !strings.Contains(err.Error(), "M_NOT_FOUND") {
b.log.Error("cannot retrieve account data %s: %v", key, err)
return threadID
}
b.log.Error().Err(err).Str("key", key).Msg("cannot retrieve last event ID")
return threadID
}
if data["eventID"] != "" {
return id.EventID(data["eventID"])
}
return data["eventID"]
return threadID
}
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)
err := b.lp.SetRoomAccountData(roomID, key, map[string]string{"eventID": eventID.String()})
if err != nil {
if !strings.Contains(err.Error(), "M_NOT_FOUND") {
b.log.Error("cannot save account data %s: %v", key, err)
}
b.log.Error().Err(err).Str("key", key).Msg("cannot save thread ID")
}
}

View File

@@ -1,27 +0,0 @@
package bot
import (
"context"
"strings"
)
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, true)
if cmd == nil {
return
}
b.handleCommand(ctx, evt, cmd)
}

View File

@@ -1,26 +0,0 @@
package bot
import (
"sync"
"maunium.net/go/mautrix/id"
)
func (b *Bot) lock(roomID id.RoomID) {
_, ok := b.mu[roomID]
if !ok {
b.mu[roomID] = &sync.Mutex{}
}
b.mu[roomID].Lock()
}
func (b *Bot) unlock(roomID id.RoomID) {
_, ok := b.mu[roomID]
if !ok {
return
}
b.mu[roomID].Unlock()
delete(b.mu, roomID)
}

79
bot/queue/manager.go Normal file
View File

@@ -0,0 +1,79 @@
package queue
import (
"github.com/rs/zerolog"
"gitlab.com/etke.cc/linkpearl"
"gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/utils"
)
const (
acQueueKey = "cc.etke.postmoogle.mailqueue"
defaultQueueBatch = 10
defaultQueueRetries = 100
)
// Queue manager
type Queue struct {
mu utils.Mutex
lp *linkpearl.Linkpearl
cfg *config.Manager
log *zerolog.Logger
sendmail func(string, string, string) error
}
// New queue
func New(lp *linkpearl.Linkpearl, cfg *config.Manager, log *zerolog.Logger) *Queue {
return &Queue{
mu: utils.Mutex{},
lp: lp,
cfg: cfg,
log: log,
}
}
// SetSendmail func
func (q *Queue) SetSendmail(function func(string, string, string) error) {
q.sendmail = function
}
// Process queue
func (q *Queue) Process() {
q.log.Debug().Msg("staring queue processing...")
cfg := q.cfg.GetBot()
batchSize := cfg.QueueBatch()
if batchSize == 0 {
batchSize = defaultQueueBatch
}
maxRetries := cfg.QueueRetries()
if maxRetries == 0 {
maxRetries = defaultQueueRetries
}
q.mu.Lock(acQueueKey)
defer q.mu.Unlock(acQueueKey)
index, err := q.lp.GetAccountData(acQueueKey)
if err != nil {
q.log.Error().Err(err).Msg("cannot get queue index")
}
i := 0
for id, itemkey := range index {
if i > batchSize {
q.log.Debug().Msg("finished re-deliveries from queue")
return
}
if dequeue := q.try(itemkey, maxRetries); dequeue {
q.log.Info().Str("id", id).Msg("email has been delivered")
err = q.Remove(id)
if err != nil {
q.log.Error().Err(err).Str("id", id).Msg("cannot dequeue email")
}
}
i++
}
q.log.Debug().Msg("ended queue processing")
}

101
bot/queue/queue.go Normal file
View File

@@ -0,0 +1,101 @@
package queue
import (
"strconv"
)
// Add to queue
func (q *Queue) Add(id, from, to, data string) error {
itemkey := acQueueKey + "." + id
item := map[string]string{
"attempts": "0",
"data": data,
"from": from,
"to": to,
"id": id,
}
q.mu.Lock(itemkey)
defer q.mu.Unlock(itemkey)
err := q.lp.SetAccountData(itemkey, item)
if err != nil {
q.log.Error().Err(err).Str("id", id).Msg("cannot enqueue email")
return err
}
q.mu.Lock(acQueueKey)
defer q.mu.Unlock(acQueueKey)
queueIndex, err := q.lp.GetAccountData(acQueueKey)
if err != nil {
q.log.Error().Err(err).Msg("cannot get queue index")
return err
}
queueIndex[id] = itemkey
err = q.lp.SetAccountData(acQueueKey, queueIndex)
if err != nil {
q.log.Error().Err(err).Msg("cannot save queue index")
return err
}
return nil
}
// Remove from queue
func (q *Queue) Remove(id string) error {
index, err := q.lp.GetAccountData(acQueueKey)
if err != nil {
q.log.Error().Err(err).Msg("cannot get queue index")
return err
}
itemkey := index[id]
if itemkey == "" {
itemkey = acQueueKey + "." + id
}
delete(index, id)
err = q.lp.SetAccountData(acQueueKey, index)
if err != nil {
q.log.Error().Err(err).Msg("cannot update queue index")
return err
}
q.mu.Lock(itemkey)
defer q.mu.Unlock(itemkey)
return q.lp.SetAccountData(itemkey, map[string]string{})
}
// try to send email
func (q *Queue) try(itemkey string, maxRetries int) bool {
q.mu.Lock(itemkey)
defer q.mu.Unlock(itemkey)
item, err := q.lp.GetAccountData(itemkey)
if err != nil {
q.log.Error().Err(err).Str("id", itemkey).Msg("cannot retrieve a queue item")
return false
}
q.log.Debug().Any("item", item).Msg("processing queue item")
attempts, err := strconv.Atoi(item["attempts"])
if err != nil {
q.log.Error().Err(err).Str("id", itemkey).Msg("cannot parse attempts")
return false
}
if attempts > maxRetries {
return true
}
err = q.sendmail(item["from"], item["to"], item["data"])
if err == nil {
q.log.Info().Str("id", itemkey).Msg("email from queue was delivered")
return true
}
q.log.Info().Str("id", itemkey).Str("from", item["from"]).Str("to", item["to"]).Err(err).Msg("attempted to deliver email, but it's not ready yet")
attempts++
item["attempts"] = strconv.Itoa(attempts)
err = q.lp.SetAccountData(itemkey, item)
if err != nil {
q.log.Error().Err(err).Str("id", itemkey).Msg("cannot update attempt count on email")
}
return false
}

44
bot/reaction.go Normal file
View File

@@ -0,0 +1,44 @@
package bot
import (
"context"
"gitlab.com/etke.cc/linkpearl"
"maunium.net/go/mautrix/event"
)
var supportedReactions = map[string]string{
"⛔️": commandSpamlistAdd,
"🛑": commandSpamlistAdd,
"🚫": commandSpamlistAdd,
"spam": commandSpamlistAdd,
}
func (b *Bot) handleReaction(ctx context.Context) {
evt := eventFromContext(ctx)
content := evt.Content.AsReaction()
action, ok := supportedReactions[content.GetRelatesTo().Key]
if !ok { // cannot do anything with it
return
}
srcID := content.GetRelatesTo().EventID
srcEvt, err := b.lp.GetClient().GetEvent(evt.RoomID, srcID)
if err != nil {
b.Error(ctx, "cannot find event %s: %v", srcID, err)
return
}
threadID := linkpearl.EventParent(srcID, srcEvt.Content.AsMessage())
ctx = threadIDToContext(ctx, threadID)
linkpearl.ParseContent(evt, event.EventMessage, b.log)
switch action {
case commandSpamlistAdd:
sender := linkpearl.EventField[string](&srcEvt.Content, eventFromKey)
if sender == "" {
b.Error(ctx, "cannot get sender of the email")
return
}
b.runSpamlistAdd(ctx, []string{commandSpamlistAdd, linkpearl.EventField[string](&srcEvt.Content, eventFromKey)})
}
}

View File

@@ -1,90 +0,0 @@
package bot
import (
"strings"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data key
const acBotSettingsKey = "cc.etke.postmoogle.config"
// bot options keys
const (
botOptionUsers = "users"
botOptionCatchAll = "catch-all"
botOptionDKIMSignature = "dkim.pub"
botOptionDKIMPrivateKey = "dkim.pem"
)
type botSettings map[string]string
// Get option
func (s botSettings) Get(key string) string {
return s[strings.ToLower(strings.TrimSpace(key))]
}
// Set option
func (s botSettings) Set(key, value string) {
s[strings.ToLower(strings.TrimSpace(key))] = value
}
// Users option
func (s botSettings) Users() []string {
value := s.Get(botOptionUsers)
if value == "" {
return []string{}
}
if strings.Contains(value, " ") {
return strings.Split(value, " ")
}
return []string{value}
}
// 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 {
return cfgUsers, nil
}
_, 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 {
config, err := b.lp.GetAccountData(acBotSettingsKey)
if err != nil {
b.log.Error("cannot get bot settings: %v", utils.UnwrapError(err))
}
if config == nil {
config = map[string]string{}
}
return config
}
func (b *Bot) setBotSettings(cfg botSettings) error {
return utils.UnwrapError(b.lp.SetAccountData(acBotSettingsKey, cfg))
}

View File

@@ -1,184 +0,0 @@
package bot
import (
"strings"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/utils"
)
// account data key
const acRoomSettingsKey = "cc.etke.postmoogle.settings"
// option keys
const (
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
// Get option
func (s roomSettings) Get(key string) string {
return s[strings.ToLower(strings.TrimSpace(key))]
}
// Set option
func (s roomSettings) Set(key, value string) {
s[strings.ToLower(strings.TrimSpace(key))] = value
}
func (s roomSettings) Mailbox() string {
return s.Get(roomOptionMailbox)
}
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))
}
func (s roomSettings) NoHTML() bool {
return utils.Bool(s.Get(roomOptionNoHTML))
}
func (s roomSettings) NoThreads() bool {
return utils.Bool(s.Get(roomOptionNoThreads))
}
func (s roomSettings) NoFiles() bool {
return utils.Bool(s.Get(roomOptionNoFiles))
}
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{}{}
}
for _, localpart := range localparts {
if localpart == "" {
continue
}
uniq[localpart+"@*"] = struct{}{}
}
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) {
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 {
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

@@ -21,18 +21,20 @@ func (b *Bot) initSync() {
event.EventMessage,
func(_ mautrix.EventSource, evt *event.Event) {
go b.onMessage(evt)
})
},
)
b.lp.OnEventType(
event.EventEncrypted,
event.EventReaction,
func(_ mautrix.EventSource, evt *event.Event) {
go b.onEncryptedMessage(evt)
})
go b.onReaction(evt)
},
)
}
// 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)
b.log.Debug().Str("userID", evt.Sender.String()).Msg("Rejecting room invitation from unallowed user")
return false
}
@@ -40,6 +42,11 @@ func (b *Bot) joinPermit(evt *event.Event) bool {
}
func (b *Bot) onMembership(evt *event.Event) {
// mautrix 0.15.x migration
if b.ignoreBefore >= evt.Timestamp {
return
}
ctx := newContext(evt)
evtType := evt.Content.AsMember().Membership
@@ -60,25 +67,27 @@ func (b *Bot) onMessage(evt *event.Event) {
if evt.Sender == b.lp.GetClient().UserID {
return
}
// mautrix 0.15.x migration
if b.ignoreBefore >= evt.Timestamp {
return
}
ctx := newContext(evt)
b.handle(ctx)
}
func (b *Bot) onEncryptedMessage(evt *event.Event) {
func (b *Bot) onReaction(evt *event.Event) {
// ignore own messages
if evt.Sender == b.lp.GetClient().UserID {
return
}
ctx := newContext(evt)
decrypted, err := b.lp.GetMachine().DecryptMegolmEvent(evt)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot decrypt a message: %v", err)
// mautrix 0.15.x migration
if b.ignoreBefore >= evt.Timestamp {
return
}
ctx = eventToContext(ctx, decrypted)
b.handle(ctx)
ctx := newContext(evt)
b.handleReaction(ctx)
}
// onBotJoin handles the "bot joined the room" event
@@ -88,11 +97,11 @@ func (b *Bot) onBotJoin(ctx context.Context) {
// as described in this bug report: https://github.com/matrix-org/synapse/issues/9768
_, ok := b.handledMembershipEvents.LoadOrStore(evt.ID, true)
if ok {
b.log.Info("Suppressing already handled event %s", evt.ID)
b.log.Info().Str("eventID", evt.ID.String()).Msg("Suppressing already handled event")
return
}
b.sendIntroduction(ctx, evt.RoomID)
b.sendIntroduction(evt.RoomID)
b.sendHelp(ctx)
}
@@ -100,17 +109,22 @@ func (b *Bot) onLeave(ctx context.Context) {
evt := eventFromContext(ctx)
_, ok := b.handledMembershipEvents.LoadOrStore(evt.ID, true)
if ok {
b.log.Info("Suppressing already handled event %s", evt.ID)
b.log.Info().Str("eventID", evt.ID.String()).Msg("Suppressing already handled event")
return
}
members := b.lp.GetStore().GetRoomMembers(evt.RoomID)
members, err := b.lp.GetClient().StateStore.GetRoomJoinedOrInvitedMembers(evt.RoomID)
if err != nil {
b.log.Error().Err(err).Str("roomID", evt.RoomID.String()).Msg("cannot get joined or invited members")
return
}
count := len(members)
if count == 1 && members[0] == b.lp.GetClient().UserID {
b.log.Info("no more users left in the %s room", evt.RoomID)
b.log.Info().Str("roomID", evt.RoomID.String()).Msg("no more users left in the room")
b.runStop(ctx)
_, err := b.lp.GetClient().LeaveRoom(evt.RoomID)
if err != nil {
b.Error(ctx, evt.RoomID, "cannot leave empty room: %v", err)
b.Error(ctx, "cannot leave empty room: %v", err)
}
}
}

View File

@@ -2,113 +2,163 @@ package main
import (
"database/sql"
"io"
"os"
"os/signal"
"strings"
"syscall"
"time"
zlogsentry "github.com/archdx/zerolog-sentry"
"github.com/getsentry/sentry-go"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"gitlab.com/etke.cc/go/logger"
"github.com/mileusna/crontab"
"github.com/rs/zerolog"
"gitlab.com/etke.cc/go/healthchecks"
"gitlab.com/etke.cc/linkpearl"
lpcfg "gitlab.com/etke.cc/linkpearl/config"
"gitlab.com/etke.cc/postmoogle/bot"
mxconfig "gitlab.com/etke.cc/postmoogle/bot/config"
"gitlab.com/etke.cc/postmoogle/bot/queue"
"gitlab.com/etke.cc/postmoogle/config"
"gitlab.com/etke.cc/postmoogle/smtp"
"gitlab.com/etke.cc/postmoogle/utils"
)
var (
mxb *bot.Bot
smtpserv *smtp.Server
log *logger.Logger
q *queue.Queue
hc *healthchecks.Client
mxc *mxconfig.Manager
mxb *bot.Bot
cron *crontab.Crontab
smtpm *smtp.Manager
log zerolog.Logger
)
func main() {
quit := make(chan struct{})
cfg := config.New()
log = logger.New("postmoogle.", cfg.LogLevel)
initLog(cfg)
utils.SetDomains(cfg.Domains)
log.Info("#############################")
log.Info("Postmoogle")
log.Info("Matrix: true")
log.Info("#############################")
log.Info().Msg("#############################")
log.Info().Msg("Postmoogle")
log.Info().Msg("Matrix: true")
log.Info().Msg("#############################")
log.Debug("starting internal components...")
initSentry(cfg)
initBot(cfg)
log.Debug().Msg("starting internal components...")
initHealthchecks(cfg)
initMatrix(cfg)
initSMTP(cfg)
initCron()
initShutdown(quit)
defer recovery()
go startBot(cfg.StatusMsg)
if err := smtpserv.Start(); err != nil {
if err := smtpm.Start(); err != nil {
//nolint:gocritic
log.Fatal("SMTP server crashed: %v", err)
log.Fatal().Err(err).Msg("SMTP server crashed")
}
<-quit
}
func initSentry(cfg *config.Config) {
err := sentry.Init(sentry.ClientOptions{
Dsn: cfg.Sentry.DSN,
AttachStacktrace: true,
})
func initLog(cfg *config.Config) {
loglevel, err := zerolog.ParseLevel(cfg.LogLevel)
if err != nil {
log.Fatal("cannot initialize sentry: %v", err)
loglevel = zerolog.InfoLevel
}
zerolog.SetGlobalLevel(loglevel)
var w io.Writer
consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout, PartsExclude: []string{zerolog.TimestampFieldName}}
sentryWriter, err := zlogsentry.New(cfg.Monitoring.SentryDSN)
if err == nil {
w = io.MultiWriter(sentryWriter, consoleWriter)
} else {
w = consoleWriter
}
log = zerolog.New(w).With().Timestamp().Caller().Logger()
}
func initBot(cfg *config.Config) {
func initHealthchecks(cfg *config.Config) {
if cfg.Monitoring.HealchecksUUID == "" {
return
}
hc = healthchecks.New(cfg.Monitoring.HealchecksUUID, func(operation string, err error) {
log.Error().Err(err).Str("operation", operation).Msg("healthchecks operation failed")
})
hc.Start(strings.NewReader("starting postmoogle"))
go hc.Auto(cfg.Monitoring.HealthechsDuration)
}
func initMatrix(cfg *config.Config) {
db, err := sql.Open(cfg.DB.Dialect, cfg.DB.DSN)
if err != nil {
log.Fatal("cannot initialize SQL database: %v", err)
log.Fatal().Err(err).Msg("cannot initialize SQL database")
}
mxlog := logger.New("matrix.", cfg.LogLevel)
lp, err := linkpearl.New(&lpcfg.Config{
lp, err := linkpearl.New(&linkpearl.Config{
Homeserver: cfg.Homeserver,
Login: cfg.Login,
Password: cfg.Password,
SharedSecret: cfg.SharedSecret,
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),
Logger: log,
})
if err != nil {
// nolint // Fatal = panic, not os.Exit()
log.Fatal("cannot initialize matrix bot: %v", err)
log.Fatal().Err(err).Msg("cannot initialize matrix bot")
}
mxb, err = bot.New(lp, mxlog, cfg.Prefix, cfg.Domain, cfg.Admins)
mxc = mxconfig.New(lp, &log)
q = queue.New(lp, mxc, &log)
mxb, err = bot.New(q, lp, &log, mxc, cfg.Proxies, cfg.Prefix, cfg.Domains, cfg.Admins, bot.MBXConfig(cfg.Mailboxes))
if err != nil {
// nolint // Fatal = panic, not os.Exit()
log.Fatal("cannot start matrix bot: %v", err)
log.Panic().Err(err).Msg("cannot start matrix bot")
}
log.Debug("bot has been created")
log.Debug().Msg("bot has been created")
}
func initSMTP(cfg *config.Config) {
smtpserv = smtp.NewServer(&smtp.Config{
Domain: cfg.Domain,
smtpm = smtp.NewManager(&smtp.Config{
Domains: cfg.Domains,
Port: cfg.Port,
TLSCert: cfg.TLS.Cert,
TLSKey: cfg.TLS.Key,
TLSCerts: cfg.TLS.Certs,
TLSKeys: cfg.TLS.Keys,
TLSPort: cfg.TLS.Port,
TLSRequired: cfg.TLS.Required,
LogLevel: cfg.LogLevel,
Logger: &log,
MaxSize: cfg.MaxSize,
Bot: mxb,
Callers: []smtp.Caller{mxb, q},
Relay: &smtp.RelayConfig{
Host: cfg.Relay.Host,
Port: cfg.Relay.Port,
Usename: cfg.Relay.Username,
Password: cfg.Relay.Password,
},
})
}
func initCron() {
cron = crontab.New()
err := cron.AddJob("* * * * *", q.Process)
if err != nil {
log.Error().Err(err).Msg("cannot start queue processing cronjob")
}
err = cron.AddJob("*/5 * * * *", mxb.SyncRooms)
if err != nil {
log.Error().Err(err).Msg("cannot start sync rooms cronjob")
}
}
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)
@@ -122,29 +172,33 @@ func initShutdown(quit chan struct{}) {
}
func startBot(statusMsg string) {
log.Debug("starting matrix bot: %s...", statusMsg)
log.Debug().Str("status message", statusMsg).Msg("starting matrix bot...")
err := mxb.Start(statusMsg)
if err != nil {
//nolint:gocritic
log.Fatal("cannot start the bot: %v", err)
log.Panic().Err(err).Msg("cannot start the bot")
}
}
func shutdown() {
log.Info("Shutting down...")
smtpserv.Stop()
log.Info().Msg("Shutting down...")
cron.Shutdown()
smtpm.Stop()
mxb.Stop()
if hc != nil {
hc.Shutdown()
hc.ExitStatus(0, strings.NewReader("shutting down postmoogle"))
}
sentry.Flush(5 * time.Second)
log.Info("Postmoogle has been stopped")
log.Info().Msg("Postmoogle has been stopped")
os.Exit(0)
}
func recovery() {
defer shutdown()
err := recover()
// no problem just shutdown
if err == nil {
if err != nil {
sentry.CurrentHub().Recover(err)
}
}

View File

@@ -1,6 +1,8 @@
package config
import (
"time"
"gitlab.com/etke.cc/go/env"
)
@@ -14,29 +16,55 @@ func New() *Config {
Homeserver: env.String("homeserver", defaultConfig.Homeserver),
Login: env.String("login", defaultConfig.Login),
Password: env.String("password", defaultConfig.Password),
SharedSecret: env.String("sharedsecret", defaultConfig.SharedSecret),
Prefix: env.String("prefix", defaultConfig.Prefix),
Domain: env.String("domain", defaultConfig.Domain),
Domains: migrateDomains("domain", "domains"),
Port: env.String("port", defaultConfig.Port),
Proxies: env.Slice("proxies"),
NoEncryption: env.Bool("noencryption"),
DataSecret: env.String("data.secret", defaultConfig.DataSecret),
MaxSize: env.Int("maxsize", defaultConfig.MaxSize),
StatusMsg: env.String("statusmsg", defaultConfig.StatusMsg),
Admins: env.Slice("admins"),
Mailboxes: Mailboxes{
Reserved: env.Slice("mailboxes.reserved"),
Forwarded: env.Slice("mailboxes.forwarded"),
Activation: env.String("mailboxes.activation", defaultConfig.Mailboxes.Activation),
},
TLS: TLS{
Cert: env.String("tls.cert", defaultConfig.TLS.Cert),
Key: env.String("tls.key", defaultConfig.TLS.Key),
Certs: env.Slice("tls.cert"),
Keys: env.Slice("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),
Monitoring: Monitoring{
SentryDSN: env.String("monitoring.sentry.dsn", env.String("sentry.dsn", "")),
SentrySampleRate: env.Int("monitoring.sentry.rate", env.Int("sentry.rate", 0)),
HealchecksUUID: env.String("monitoring.healthchecks.uuid", ""),
HealthechsDuration: time.Duration(env.Int("monitoring.healthchecks.duration", int(defaultConfig.Monitoring.HealthechsDuration))) * time.Second,
},
LogLevel: env.String("loglevel", defaultConfig.LogLevel),
DB: DB{
DSN: env.String("db.dsn", defaultConfig.DB.DSN),
Dialect: env.String("db.dialect", defaultConfig.DB.Dialect),
},
Relay: Relay{
Host: env.String("relay.host", defaultConfig.Relay.Host),
Port: env.String("relay.port", defaultConfig.Relay.Port),
Username: env.String("relay.username", defaultConfig.Relay.Username),
Password: env.String("relay.password", defaultConfig.Relay.Password),
},
}
return cfg
}
func migrateDomains(oldKey, newKey string) []string {
domains := []string{}
old := env.String(oldKey, "")
if old != "" {
domains = append(domains, old)
}
return append(domains, env.Slice(newKey)...)
}

View File

@@ -2,15 +2,22 @@ package config
var defaultConfig = &Config{
LogLevel: "INFO",
Domain: "localhost",
Domains: []string{"localhost"},
Port: "25",
Prefix: "!pm",
MaxSize: 1024,
StatusMsg: "Delivering emails",
Mailboxes: Mailboxes{
Activation: "none",
},
DB: DB{
DSN: "local.db",
Dialect: "sqlite3",
},
Monitoring: Monitoring{
SentrySampleRate: 20,
HealthechsDuration: 5,
},
TLS: TLS{
Port: "587",
},

View File

@@ -1,17 +1,23 @@
package config
import "time"
// Config of Postmoogle
type Config struct {
// Homeserver url
Homeserver string
// Login is a MXID localpart (scheduler - OK, @scheduler:example.com - wrong)
// Login is a localpart if logging in with password (postmoogle) OR full MXID if logging in with shared secret (@postmoogle:example.com)
Login string
// Password for login/password auth only
Password string
// Domain for SMTP
Domain string
// SharedSecret for login/sharedsecret auth only
SharedSecret string
// Domains for SMTP
Domains []string
// Port for SMTP
Port string
// Proxies is list of trusted SMTP proxies
Proxies []string
// RoomID of the admin room
LogLevel string
// DataSecret is account data secret key (password) to encrypt all account data values
@@ -24,6 +30,8 @@ type Config struct {
MaxSize int
// StatusMsg of the bot
StatusMsg string
// Mailboxes config
Mailboxes Mailboxes
// Admins holds list of admin users (wildcards supported), e.g.: @*:example.com, @bot.*:example.com, @admin:*. Empty = no admins
Admins []string
@@ -33,8 +41,10 @@ type Config struct {
// TLS config
TLS TLS
// Sentry config
Sentry Sentry
// Monitoring config
Monitoring Monitoring
Relay Relay
}
// DB config
@@ -47,13 +57,31 @@ type DB struct {
// TLS config
type TLS struct {
Cert string
Key string
Certs []string
Keys []string
Port string
Required bool
}
// Sentry config
type Sentry struct {
DSN string
// Monitoring config
type Monitoring struct {
SentryDSN string
SentrySampleRate int
HealchecksUUID string
HealthechsDuration time.Duration
}
// Mailboxes config
type Mailboxes struct {
Reserved []string
Forwarded []string
Activation string
}
// Relay config
type Relay struct {
Host string
Port string
Username string
Password string
}

159
docs/dns.md Normal file
View File

@@ -0,0 +1,159 @@
# DNS configuration
the following configuration is required only if you want to send emails from Postmoogle
# MX
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>
# SPF
Aadd a new SPF DNS record of the `TXT` type for your domain that will be used with Postmoogle,
with format: `v=spf1 ip4:SERVER_IP4 -all` (replace `SERVER_IP4` with your server's IP address),
for servers with IPv6: `v=spf1 ip6:SERVER_IP6 -all` (you may use both `ip4` and `ip6` in one TXT record).
<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>
# DMARC
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>
# DKIM
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>
# rDNS
> additional PTR record will help you to get better spam score
Configure Reverse DNS of your server. Unfortunately, rDNS is provider-specific, so you have to find out how to configure it with your hosting provider. Search for something like: `PROVIDER configure "rdns"` (where `PROVIDER` is your hosting provider name)

25
docs/mailboxes.md Normal file
View File

@@ -0,0 +1,25 @@
# Mailboxes configuration
## `POSTMOOGLE_MAILBOXES_RESERVED`
Space separated list of reserved mailboxes, example:
```bash
export POSTMOOGLE_MAILBOXES_RESERVED=admin root postmaster
```
Nobody can create a mailbox from that list
## `POSTMOOGLE_MAILBOXES_ACTIVATION`
Type of activation flow:
### `none` (default)
If `POSTMOOGLE_MAILBOXES_ACTIVATION=none` mailbox will be just created as is, without any additional checks.
### `notify`
If `POSTMOOGLE_MAILBOXES_ACTIVATION=notify`, mailbox will be created as in `none` case **and** notification will be sent to one of the mailboxes managed by a postmoogle admin.
To make it work, a postmoogle admin (or multiple admins) should either set `!pm adminroom` or create at least one mailbox.

42
docs/tricks.md Normal file
View File

@@ -0,0 +1,42 @@
# tricks
<!-- vim-markdown-toc GitLab -->
* [Logs](#logs)
* [get most active hosts](#get-most-active-hosts)
<!-- vim-markdown-toc -->
## Logs
### get most active hosts
Even if you use postmoogle as an internal mail server and contact "outside internet" quite rarely,
you will see lots of connections to your SMTP servers from random hosts over internet that do... nothing?
They don't send any valid emails or do something meaningful, thus you can safely assume they are spammers.
To get top X (in example: top 10) hosts with biggest count of attempts to connect to your postmoogle instance, follow the steps:
1. enable debug log: `export POSTMOOGLE_LOGLEVEL=debug`
2. restart postmoogle and wait some time to get stats
3. run the following bash one-liner to show top 10 hosts by connections count:
```bash
journalctl -o cat -u postmoogle | grep "smtp.DEBUG accepted connection from " | grep -oE "[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}" | sort | uniq -ci | sort -rn | head -n 10
253 111.111.111.111
183 222.222.222.222
39 333.333.333.333
38 444.444.444.444
18 555.555.555.555
16 666.666.666.666
8 777.777.777.777
5 888.888.888.888
5 999.999.999.999
4 010.010.010.010
```
of course, IP addresses above are crafted just to visualize their place in that top, according to the number of connections done.
In reality, you will see real IP addresses here. Usually, only hosts with hundreds or thousands of connections for the last 7 days worth checking.
What's next?
Do **not** ban them right away. Check WHOIS info for each host and only after that decide if you really want to ban that host or not.

View File

@@ -1,3 +1,3 @@
#!/bin/bash
ssmtp -v test@localhost < $1
ssmtp -v test+sub@localhost < $1

View File

@@ -1,6 +1,6 @@
#!/bin/bash
for i in {0..10..1}; do
for i in {0..100..1}; do
echo "#${i}..."
ssmtp test@localhost < $1
done

View File

@@ -3,7 +3,7 @@ Content-Type: multipart/alternative; boundary="Apple-Mail=_E091454E-BCFA-43B4-99
Subject: MIME test 1
Date: Sat, 13 Oct 2012 15:33:07 -0700
Message-Id: <4E2E5A48-1A2C-4450-8663-D41B451DA93A@makita.skynet>
To: test@localhost
To: test+sub@localhost
Mime-Version: 1.0 (Apple Message framework v1283)
X-Mailer: Apple Mail (2.1283)

249
email/email.go Normal file
View File

@@ -0,0 +1,249 @@
package email
import (
"crypto"
"crypto/x509"
"encoding/pem"
"strings"
"github.com/emersion/go-msgauth/dkim"
"github.com/jhillyerd/enmime"
"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"
)
// Email object
type Email struct {
Date string
MessageID string
InReplyTo string
References string
From string
To string
RcptTo string
CC []string
Subject string
Text string
HTML string
Files []*utils.File
InlineFiles []*utils.File
}
// New constructs Email object
func New(messageID, inReplyTo, references, subject, from, to, rcptto, cc, text, html string, files, inline []*utils.File) *Email {
email := &Email{
Date: dateNow(),
MessageID: messageID,
InReplyTo: inReplyTo,
References: references,
From: Address(from),
To: Address(to),
CC: AddressList(cc),
RcptTo: Address(rcptto),
Subject: subject,
Text: text,
HTML: html,
Files: files,
InlineFiles: inline,
}
if html != "" {
html = styleRegex.ReplaceAllString(html, "")
email.HTML = html
}
return email
}
// FromEnvelope constructs Email object from envelope
func FromEnvelope(rcptto string, envelope *enmime.Envelope) *Email {
datetime, _ := envelope.Date() //nolint:errcheck // handled in dateNow()
date := dateNow(datetime)
var html string
if envelope.HTML != "" {
html = styleRegex.ReplaceAllString(envelope.HTML, "")
}
files := make([]*utils.File, 0, len(envelope.Attachments))
for _, attachment := range envelope.Attachments {
file := utils.NewFile(attachment.FileName, attachment.Content)
files = append(files, file)
}
inlines := make([]*utils.File, 0, len(envelope.Inlines))
for _, inline := range envelope.Inlines {
file := utils.NewFile(inline.FileName, inline.Content)
inlines = append(inlines, file)
}
email := &Email{
Date: date,
MessageID: envelope.GetHeader("Message-Id"),
InReplyTo: envelope.GetHeader("In-Reply-To"),
References: envelope.GetHeader("References"),
From: Address(envelope.GetHeader("From")),
To: Address(envelope.GetHeader("To")),
RcptTo: Address(rcptto),
CC: AddressList(envelope.GetHeader("Cc")),
Subject: envelope.GetHeader("Subject"),
Text: envelope.Text,
HTML: html,
Files: files,
InlineFiles: inlines,
}
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 utils.Mailbox(e.RcptTo)
}
return utils.Mailbox(e.From)
}
func (e *Email) contentHeader(threadID id.EventID, text *strings.Builder, options *ContentOptions) {
if options.Sender {
text.WriteString(e.From)
}
if options.Recipient {
mailbox, sub, host := utils.EmailParts(e.To)
text.WriteString(" ➡️ ")
text.WriteString(mailbox)
text.WriteString("@")
text.WriteString(host)
if sub != "" {
text.WriteString(" (")
text.WriteString(sub)
text.WriteString(")")
}
}
if options.CC && len(e.CC) > 0 {
text.WriteString("\ncc: ")
text.WriteString(strings.Join(e.CC, ", "))
}
if options.Sender || options.Recipient || options.CC {
text.WriteString("\n\n")
}
if options.Subject && threadID == "" {
text.WriteString("# ")
text.WriteString(e.Subject)
text.WriteString("\n\n")
}
}
// 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
e.contentHeader(threadID, &text, options)
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 = linkpearl.RelatesTo(threadID, !options.Threads)
var cc string
if len(e.CC) > 0 {
cc = strings.Join(e.CC, ", ")
}
content := event.Content{
Raw: map[string]interface{}{
options.MessageIDKey: e.MessageID,
options.InReplyToKey: e.InReplyTo,
options.ReferencesKey: e.References,
options.SubjectKey: e.Subject,
options.RcptToKey: e.RcptTo,
options.FromKey: e.From,
options.ToKey: e.To,
options.CcKey: cc,
},
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 {
textSize := len(e.Text)
htmlSize := len(e.HTML)
if textSize == 0 && htmlSize == 0 {
return ""
}
mail := enmime.Builder().
From("", e.From).
To("", e.To).
Header("Message-Id", e.MessageID).
Subject(e.Subject)
if textSize > 0 {
mail = mail.Text([]byte(e.Text))
}
if htmlSize > 0 {
mail = mail.HTML([]byte(e.HTML))
}
if e.InReplyTo != "" {
mail = mail.Header("In-Reply-To", e.InReplyTo)
}
if e.References != "" {
mail = mail.Header("References", e.References)
}
if len(e.CC) > 0 {
for _, addr := range e.CC {
mail = mail.CC("", addr)
}
}
root, err := mail.Build()
if err != nil {
return ""
}
var data strings.Builder
err = root.Encode(&data)
if err != nil {
return ""
}
domain := strings.SplitN(e.From, "@", 2)[1]
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()
}

31
email/options.go Normal file
View File

@@ -0,0 +1,31 @@
package email
// IncomingFilteringOptions for incoming mail
type IncomingFilteringOptions interface {
SpamcheckDKIM() bool
SpamcheckSMTP() bool
SpamcheckSPF() bool
SpamcheckMX() bool
Spamlist() []string
}
// ContentOptions represents settings that specify how an email is to be converted to a Matrix message
type ContentOptions struct {
// On/Off
CC bool
Sender bool
Recipient bool
Subject bool
HTML bool
Threads bool
// Keys
MessageIDKey string
InReplyToKey string
ReferencesKey string
SubjectKey string
FromKey string
ToKey string
CcKey string
RcptToKey string
}

66
email/utils.go Normal file
View File

@@ -0,0 +1,66 @@
package email
import (
"fmt"
"net/mail"
"regexp"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
var styleRegex = regexp.MustCompile("<style((.|\n|\r)*?)<\\/style>")
// AddressValid checks if email address is valid
func AddressValid(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}
// MessageID generates email Message-Id from matrix event ID
func MessageID(eventID id.EventID, domain string) string {
return fmt.Sprintf("<%s@%s>", eventID, domain)
}
// Address gets email address from a valid email address notation (eg: "Jane Doe" <jane@example.com> -> jane@example.com)
func Address(email string) string {
addr, _ := mail.ParseAddress(email) //nolint:errcheck // if it fails here, nothing will help
if addr == nil {
list := AddressList(email)
if len(list) > 0 {
return strings.Join(list, ",")
}
return email
}
return addr.Address
}
// Address gets email address from a valid email address notation (eg: "Jane Doe" <jane@example.com>, john.doe@example.com -> jane@example.com, john.doe@example.com)
func AddressList(emailList string) []string {
if emailList == "" {
return []string{}
}
list, _ := mail.ParseAddressList(emailList) //nolint:errcheck // if it fails here, nothing will help
if len(list) == 0 {
return []string{}
}
addrs := make([]string, 0, len(list))
for _, addr := range list {
addrs = append(addrs, addr.Address)
}
return addrs
}
// dateNow returns Date in RFC1123 with numeric timezone
func dateNow(original ...time.Time) string {
now := time.Now().UTC()
if len(original) > 0 && !original[0].IsZero() {
now = original[0]
}
return now.Format(time.RFC1123Z)
}

48
go.mod
View File

@@ -5,51 +5,57 @@ go 1.18
// replace gitlab.com/etke.cc/linkpearl => ../linkpearl
require (
github.com/archdx/zerolog-sentry v1.2.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/fsnotify/fsnotify v1.6.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.7
github.com/mattn/go-sqlite3 v1.14.15
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.17
github.com/mcnijman/go-emailaddress v1.1.0
github.com/mileusna/crontab v1.2.0
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39
github.com/rs/zerolog v1.30.0
gitlab.com/etke.cc/go/env v1.0.0
gitlab.com/etke.cc/go/logger v1.1.0
gitlab.com/etke.cc/go/fswatcher v1.0.0
gitlab.com/etke.cc/go/healthchecks v1.0.1
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
gitlab.com/etke.cc/go/trysmtp v1.1.3
gitlab.com/etke.cc/go/validator v1.0.6
gitlab.com/etke.cc/linkpearl v0.0.0-20230928120707-1e99315dc616
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
maunium.net/go/mautrix v0.16.1
)
require (
blitiri.com.ar/go/spf v1.5.1 // indirect
github.com/buger/jsonparser v1.0.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // 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/google/uuid v1.3.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.1 // 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-isatty v0.0.19 // 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.28.0 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/gjson v1.16.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // 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
github.com/yuin/goldmark v1.5.6 // indirect
go.mau.fi/util v0.1.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
maunium.net/go/maulogger/v2 v2.4.1 // indirect
)

103
go.sum
View File

@@ -1,7 +1,13 @@
blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE=
blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/archdx/zerolog-sentry v1.2.0 h1:FDFqlo5XvL/jpDAPoAWI15EjJQVFvixn70v3IH//eTM=
github.com/archdx/zerolog-sentry v1.2.0/go.mod h1:3H8gClGFafB90fKMsvfP017bdmkG5MD6UiA+6iPEwGw=
github.com/buger/jsonparser v1.0.0 h1:etJTGF5ESxjI0Ic2UaLQs2LQQpa8G9ykQScukbh4L8A=
github.com/buger/jsonparser v1.0.0/go.mod h1:tgcrVJ81GPSF0mz+0nu1Xaz0fazGPrmmJfJtxjbHhUQ=
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/coreos/go-systemd/v22 v22.5.0/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=
@@ -16,6 +22,8 @@ github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDm
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/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
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=
@@ -27,33 +35,35 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.1 h1:5pv5N1lT1fjLg2VQ5KWc7kmucp2x/kvFOnxuVTqZ6x4=
github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
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.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/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=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mcnijman/go-emailaddress v1.1.0 h1:7/Uxgn9pXwXmvXsFSgORo6XoRTrttj7AGmmB2yFArAg=
github.com/mcnijman/go-emailaddress v1.1.0/go.mod h1:m+aauxGmv31sB5zZ1I8ICcMoa9ZHOA9RiurCijfvkhI=
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/mileusna/crontab v1.2.0 h1:x9ZmE2A4p6CDqMEGQ+GbqsNtnmbdmWMQYShdQu8LvrU=
github.com/mileusna/crontab v1.2.0/go.mod h1:dbns64w/u3tUnGZGf8pAa76ZqOfeBX4olW4U1ZwExmc=
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=
@@ -66,18 +76,18 @@ github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39/go.mod h1:idX/fPqw
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.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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c=
github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
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/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg=
github.com/tidwall/gjson v1.16.0/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/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -85,30 +95,37 @@ 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.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.5.6 h1:COmQAWTCcGetChm3Ig7G/t8AFAN00t+o8Mt4cf7JpwA=
github.com/yuin/goldmark v1.5.6/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/go/fswatcher v1.0.0 h1:uyiVn+1NVCjOLZrXSZouIDBDZBMwVipS4oYuvAFpPzo=
gitlab.com/etke.cc/go/fswatcher v1.0.0/go.mod h1:MqTOxyhXfvaVZQUL9/Ksbl2ow1PTBVu3eqIldvMq0RE=
gitlab.com/etke.cc/go/healthchecks v1.0.1 h1:IxPB+r4KtEM6wf4K7MeQoH1XnuBITMGUqFaaRIgxeUY=
gitlab.com/etke.cc/go/healthchecks v1.0.1/go.mod h1:EzQjwSawh8tQEX43Ls0dI9mND6iWd5NHtmapdO24fMI=
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=
gitlab.com/etke.cc/go/trysmtp v1.1.3 h1:e2EHond77onMaecqCg6mWumffTSEf+ycgj88nbeefDI=
gitlab.com/etke.cc/go/trysmtp v1.1.3/go.mod h1:lOO7tTdAE0a3ETV3wN3GJ7I1Tqewu7YTpPWaOmTteV0=
gitlab.com/etke.cc/go/validator v1.0.6 h1:w0Muxf9Pqw7xvF7NaaswE6d7r9U3nB2t2l5PnFMrecQ=
gitlab.com/etke.cc/go/validator v1.0.6/go.mod h1:Id0SxRj0J3IPhiKlj0w1plxVLZfHlkwipn7HfRZsDts=
gitlab.com/etke.cc/linkpearl v0.0.0-20230928120707-1e99315dc616 h1:Gvhmq84VmAJN1xRzRBK79XJVObAvVcx9Q3s6K+Zo644=
gitlab.com/etke.cc/linkpearl v0.0.0-20230928120707-1e99315dc616/go.mod h1:IZ0TE+ZnIdJLb538owDMxhtpWH7blfW+oR7e5XRXxNY=
go.mau.fi/util v0.1.0 h1:BwIFWIOEeO7lsiI2eWKFkWTfc5yQmoe+0FYyOFVyaoE=
go.mau.fi/util v0.1.0/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84=
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/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
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/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
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=
@@ -116,22 +133,24 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
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-20221013171732-95e765b1cc43 h1:OK7RB6t2WQX54srQQYSXMW8dF5C6/8+oA/s5QBmmto4=
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/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/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
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=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.2 h1:HuIDgigR6VY2QUPyZADCwn8UZWYAqi31a77qd1jMPA4=
maunium.net/go/mautrix v0.12.2/go.mod h1:bCw45Qx/m9qsz7eazmbe7Rzq5ZbTPzwRE1UgX2S9DXs=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
maunium.net/go/mautrix v0.16.1 h1:Wb3CvOCe8A/NLsFeZYxKrgXKiqeZUQEBD1zqm7n/kWk=
maunium.net/go/mautrix v0.16.1/go.mod h1:2Jf15tulVtr6LxoiRL4smRXwpkGWUNfBFhwh/aXDBuk=

45
justfile Normal file
View File

@@ -0,0 +1,45 @@
CI_REGISTRY_IMAGE := env_var_or_default("CI_REGISTRY_IMAGE", "registry.gitlab.com/etke.cc/postmoogle")
REGISTRY_IMAGE := env_var_or_default("REGISTRY_IMAGE", "registry.etke.cc/etke.cc/postmoogle")
CI_COMMIT_TAG := if env_var_or_default("CI_COMMIT_TAG", "main") == "main" { "latest" } else { env_var_or_default("CI_COMMIT_TAG", "latest") }
# show help by default
default:
@just --list --justfile {{ justfile() }}
# update go deps
update:
go get ./cmd
go get gitlab.com/etke.cc/linkpearl@latest
go mod tidy
go mod vendor
# run linter
lint:
golangci-lint run ./...
# automatically fix liter issues
lintfix:
golangci-lint run --fix ./...
# run unit tests
test:
@go test -coverprofile=cover.out ./...
@go tool cover -func=cover.out
-@rm -f cover.out
# run app
run:
@go run ./cmd
# build app
build:
go build -v -o postmoogle ./cmd
# docker login
login:
@docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
# docker build
docker:
docker buildx create --use
docker buildx build --platform linux/arm64/v8,linux/amd64 --push -t {{ CI_REGISTRY_IMAGE }}:{{ CI_COMMIT_TAG }} -t {{ REGISTRY_IMAGE }}:{{ CI_COMMIT_TAG }} .

109
smtp/client.go Normal file
View File

@@ -0,0 +1,109 @@
package smtp
import (
"crypto/tls"
"io"
"net/smtp"
"strings"
"github.com/rs/zerolog"
"gitlab.com/etke.cc/go/trysmtp"
)
type MailSender interface {
Send(from string, to string, data string) error
}
// SMTP client
type Client struct {
config *RelayConfig
log *zerolog.Logger
}
func newClient(cfg *RelayConfig, log *zerolog.Logger) *Client {
return &Client{
config: cfg,
log: log,
}
}
// Send email
func (c Client) Send(from string, to string, data string) error {
c.log.Debug().Str("from", from).Str("to", to).Msg("sending email")
var conn *smtp.Client
var err error
if c.config.Host != "" {
conn, err = c.createDirectClient(from, to)
} else {
conn, err = trysmtp.Connect(from, to)
}
if conn == nil {
c.log.Error().Err(err).Str("server_of", to).Msg("cannot connect to SMTP server")
return err
}
if err != nil {
c.log.Warn().Err(err).Str("server_of", to).Msg("connection to the SMTP server returned non-fatal error(-s)")
}
defer conn.Close()
var w io.WriteCloser
w, err = conn.Data()
if err != nil {
c.log.Error().Err(err).Msg("cannot send DATA command")
return err
}
defer w.Close()
c.log.Debug().Str("DATA", data).Msg("sending command")
_, err = strings.NewReader(data).WriteTo(w)
if err != nil {
c.log.Error().Err(err).Msg("cannot write DATA")
return err
}
c.log.Debug().Msg("email has been sent")
return nil
}
// createDirectClient connects directly to the provided smtp host
func (c *Client) createDirectClient(from string, to string) (*smtp.Client, error) {
localname := strings.SplitN(from, "@", 2)[1]
target := c.config.Host + ":" + c.config.Port
conn, err := smtp.Dial(target)
if err != nil {
return nil, err
}
err = conn.Hello(localname)
if err != nil {
return nil, err
}
if ok, _ := conn.Extension("STARTTLS"); ok {
config := &tls.Config{ServerName: c.config.Host}
conn.StartTLS(config) //nolint:errcheck // if it doesn't work - we can't do anything anyway
}
if c.config.Usename != "" {
err = conn.Auth(smtp.PlainAuth("", c.config.Usename, c.config.Password, c.config.Host))
if err != nil {
conn.Close()
return nil, err
}
}
err = conn.Mail(from)
if err != nil {
conn.Close()
return nil, err
}
err = conn.Rcpt(to)
if err != nil {
conn.Close()
return nil, err
}
return conn, nil
}

87
smtp/listener.go Normal file
View File

@@ -0,0 +1,87 @@
package smtp
import (
"crypto/tls"
"net"
"sync"
"github.com/rs/zerolog"
)
// Listener that rejects connections from banned hosts
type Listener struct {
log *zerolog.Logger
done chan struct{}
tls *tls.Config
tlsMu sync.Mutex
listener net.Listener
isBanned func(net.Addr) bool
}
func NewListener(port string, tlsConfig *tls.Config, isBanned func(net.Addr) bool, log *zerolog.Logger) (*Listener, error) {
actual, err := net.Listen("tcp", ":"+port)
if err != nil {
return nil, err
}
return &Listener{
log: log,
done: make(chan struct{}, 1),
tls: tlsConfig,
listener: actual,
isBanned: isBanned,
}, nil
}
func (l *Listener) SetTLSConfig(cfg *tls.Config) {
l.tlsMu.Lock()
l.tls = cfg
l.tlsMu.Unlock()
}
// Accept waits for and returns the next connection to the listener.
func (l *Listener) Accept() (net.Conn, error) {
for {
conn, err := l.listener.Accept()
if err != nil {
select {
case <-l.done:
return conn, err
default:
l.log.Warn().Err(err).Msg("cannot accept connection")
continue
}
}
if l.isBanned(conn.RemoteAddr()) {
conn.Close()
l.log.Info().Str("addr", conn.RemoteAddr().String()).Msg("rejected connection (already banned)")
continue
}
l.log.Info().Str("addr", conn.RemoteAddr().String()).Msg("accepted connection")
if l.tls != nil {
return l.acceptTLS(conn)
}
return conn, nil
}
}
func (l *Listener) acceptTLS(conn net.Conn) (net.Conn, error) {
l.tlsMu.Lock()
defer l.tlsMu.Unlock()
return tls.Server(conn, l.tls), nil
}
// Close closes the listener.
// Any blocked Accept operations will be unblocked and return errors.
func (l *Listener) Close() error {
close(l.done)
return l.listener.Close()
}
// Addr returns the listener's network address.
func (l *Listener) Addr() net.Addr {
return l.listener.Addr()
}

44
smtp/logger.go Normal file
View File

@@ -0,0 +1,44 @@
package smtp
import (
"strings"
"github.com/rs/zerolog"
)
// validatorLoggerWrapper is a wrapper around zerolog.Logger to implement validator.Logger interface
type validatorLoggerWrapper struct {
log *zerolog.Logger
}
func (l validatorLoggerWrapper) Info(msg string, args ...interface{}) {
l.log.Info().Msgf(msg, args...)
}
func (l validatorLoggerWrapper) Error(msg string, args ...interface{}) {
l.log.Error().Msgf(msg, args...)
}
// loggerWrapper is a wrapper around any logger to implement smtp.Logger interface
type loggerWrapper struct {
log func(string, ...interface{})
}
func (l loggerWrapper) Printf(format string, v ...interface{}) {
l.log(format, v...)
}
func (l loggerWrapper) Println(v ...interface{}) {
msg := strings.Repeat("%v ", len(v))
l.log(msg, v...)
}
// loggerWriter is a wrapper around io.Writer to implement io.Writer interface
type loggerWriter struct {
log func(string)
}
func (l loggerWriter) Write(p []byte) (n int, err error) {
l.log(string(p))
return len(p), nil
}

215
smtp/manager.go Normal file
View File

@@ -0,0 +1,215 @@
package smtp
import (
"context"
"crypto/tls"
"net"
"sync"
"time"
"github.com/emersion/go-smtp"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog"
"gitlab.com/etke.cc/go/fswatcher"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/email"
)
type Config struct {
Domains []string
Port string
TLSCerts []string
TLSKeys []string
TLSPort string
TLSRequired bool
Logger *zerolog.Logger
MaxSize int
Bot matrixbot
Callers []Caller
Relay *RelayConfig
}
type TLSConfig struct {
Listener *Listener
Config *tls.Config
Certs []string
Keys []string
Port string
Mu sync.Mutex
}
type RelayConfig struct {
Host string
Port string
Usename string
Password string
}
type Manager struct {
log *zerolog.Logger
bot matrixbot
fsw *fswatcher.Watcher
smtp *smtp.Server
errs chan error
port string
tls TLSConfig
}
type matrixbot interface {
AllowAuth(string, string) (id.RoomID, bool)
IsGreylisted(net.Addr) bool
IsBanned(net.Addr) bool
IsTrusted(net.Addr) bool
BanAuto(net.Addr)
BanAuth(net.Addr)
GetMapping(string) (id.RoomID, bool)
GetIFOptions(id.RoomID) email.IncomingFilteringOptions
IncomingEmail(context.Context, *email.Email) error
GetDKIMprivkey() string
}
// Caller is Sendmail caller
type Caller interface {
SetSendmail(func(string, string, string) error)
}
// NewManager creates new SMTP server manager
func NewManager(cfg *Config) *Manager {
mailsrv := &mailServer{
log: cfg.Logger,
bot: cfg.Bot,
domains: cfg.Domains,
sender: newClient(cfg.Relay, cfg.Logger),
}
for _, caller := range cfg.Callers {
caller.SetSendmail(mailsrv.sender.Send)
}
s := smtp.NewServer(mailsrv)
s.ErrorLog = loggerWrapper{func(s string, i ...interface{}) { cfg.Logger.Error().Msgf(s, i...) }}
s.ReadTimeout = 10 * time.Second
s.WriteTimeout = 10 * time.Second
s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024
s.AllowInsecureAuth = !cfg.TLSRequired
s.EnableREQUIRETLS = cfg.TLSRequired
s.EnableSMTPUTF8 = true
// set domain in greeting only in single-domain mode
if len(cfg.Domains) == 1 {
s.Domain = cfg.Domains[0]
}
loglevel := cfg.Logger.GetLevel()
if loglevel == zerolog.InfoLevel || loglevel == zerolog.DebugLevel || loglevel == zerolog.TraceLevel {
s.Debug = loggerWriter{func(s string) { cfg.Logger.Info().Msg(s) }}
}
fsw, err := fswatcher.New(append(cfg.TLSCerts, cfg.TLSKeys...), 0)
if err != nil {
cfg.Logger.Error().Err(err).Msg("cannot start FS watcher")
}
m := &Manager{
smtp: s,
bot: cfg.Bot,
log: cfg.Logger,
fsw: fsw,
port: cfg.Port,
tls: TLSConfig{
Certs: cfg.TLSCerts,
Keys: cfg.TLSKeys,
Port: cfg.TLSPort,
},
}
m.tls.Mu.Lock()
m.loadTLSConfig()
m.tls.Mu.Unlock()
if m.fsw != nil {
go m.fsw.Start(func(_ fsnotify.Event) {
m.tls.Mu.Lock()
defer m.tls.Mu.Unlock()
ok := m.loadTLSConfig()
if ok {
m.tls.Listener.SetTLSConfig(m.tls.Config)
}
})
}
return m
}
// Start SMTP server
func (m *Manager) Start() error {
m.errs = make(chan error, 1)
go m.listen(m.port, nil)
if m.tls.Config != nil {
go m.listen(m.tls.Port, m.tls.Config)
}
return <-m.errs
}
// Stop SMTP server
func (m *Manager) Stop() {
err := m.fsw.Stop()
if err != nil {
m.log.Error().Err(err).Msg("cannot stop filesystem watcher properly")
}
err = m.smtp.Close()
if err != nil {
m.log.Error().Err(err).Msg("cannot stop SMTP server properly")
}
m.log.Info().Msg("SMTP server has been stopped")
}
func (m *Manager) listen(port string, tlsConfig *tls.Config) {
lwrapper, err := NewListener(port, tlsConfig, m.bot.IsBanned, m.log)
if err != nil {
m.log.Error().Err(err).Str("port", port).Msg("cannot start listener")
m.errs <- err
return
}
if tlsConfig != nil {
m.tls.Listener = lwrapper
}
m.log.Info().Str("port", port).Msg("Starting SMTP server")
err = m.smtp.Serve(lwrapper)
if err != nil {
m.log.Error().Str("port", port).Err(err).Msg("cannot start SMTP server")
m.errs <- err
close(m.errs)
}
}
// loadTLSConfig returns true if certs were loaded and false if not
func (m *Manager) loadTLSConfig() bool {
m.log.Info().Msg("(re)loading TLS config")
if len(m.tls.Certs) == 0 || len(m.tls.Keys) == 0 {
m.log.Warn().Msg("SSL certificates are not provided")
return false
}
certificates := make([]tls.Certificate, 0, len(m.tls.Certs))
for i, path := range m.tls.Certs {
tlsCert, err := tls.LoadX509KeyPair(path, m.tls.Keys[i])
if err != nil {
m.log.Error().Err(err).Msg("cannot load SSL certificate")
continue
}
certificates = append(certificates, tlsCert)
}
if len(certificates) == 0 {
return false
}
m.tls.Config = &tls.Config{Certificates: certificates}
m.smtp.TLSConfig = m.tls.Config
return true
}

View File

@@ -1,48 +0,0 @@
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
}

View File

@@ -1,121 +0,0 @@
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
}

View File

@@ -1,60 +0,0 @@
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,128 +1,93 @@
package smtp
import (
"crypto/tls"
"net"
"os"
"time"
"context"
"github.com/emersion/go-smtp"
"gitlab.com/etke.cc/go/logger"
"github.com/getsentry/sentry-go"
"github.com/rs/zerolog"
"gitlab.com/etke.cc/postmoogle/email"
)
type Config struct {
Domain string
Port string
var (
// ErrBanned returned to banned hosts
ErrBanned = &smtp.SMTPError{
Code: 554,
EnhancedCode: smtp.EnhancedCode{5, 5, 4},
Message: "please, don't bother me anymore, kupo.",
}
// ErrNoUser returned when no such mailbox found
ErrNoUser = &smtp.SMTPError{
Code: 550,
EnhancedCode: smtp.EnhancedCode{5, 5, 0},
Message: "no such user here, kupo.",
}
)
TLSCert string
TLSKey string
TLSPort string
TLSRequired bool
LogLevel string
MaxSize int
Bot Bot
type mailServer struct {
bot matrixbot
log *zerolog.Logger
domains []string
sender MailSender
}
type Server struct {
log *logger.Logger
msa *smtp.Server
errs chan error
// Login used for outgoing mail submissions only (when you use postmoogle as smtp server in your scripts)
func (m *mailServer) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
m.log.Debug().Str("username", username).Any("state", state).Msg("Login")
if m.bot.IsBanned(state.RemoteAddr) {
return nil, ErrBanned
}
port string
tlsPort string
tlsCfg *tls.Config
if !email.AddressValid(username) {
m.log.Debug().Str("address", username).Msg("address is invalid")
m.bot.BanAuth(state.RemoteAddr)
return nil, ErrBanned
}
roomID, allow := m.bot.AllowAuth(username, password)
if !allow {
m.log.Debug().Str("username", username).Msg("username or password is invalid")
m.bot.BanAuth(state.RemoteAddr)
return nil, ErrBanned
}
return &outgoingSession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
sendmail: m.sender.Send,
privkey: m.bot.GetDKIMprivkey(),
from: username,
log: m.log,
domains: m.domains,
getRoomID: m.bot.GetMapping,
fromRoom: roomID,
tos: []string{},
}, nil
}
// 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,
mta: sender,
bot: cfg.Bot,
domain: cfg.Domain,
}
receiver.bot.SetMTA(sender)
s := smtp.NewServer(receiver)
s.Domain = cfg.Domain
s.ReadTimeout = 10 * time.Second
s.WriteTimeout = 10 * time.Second
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
// AnonymousLogin used for incoming mail submissions only
func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
m.log.Debug().Any("state", state).Msg("AnonymousLogin")
if m.bot.IsBanned(state.RemoteAddr) {
return nil, ErrBanned
}
server := &Server{
msa: s,
log: log,
port: cfg.Port,
tlsPort: cfg.TLSPort,
}
server.loadTLSConfig(cfg.TLSCert, cfg.TLSKey)
return server
return &incomingSession{
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
getRoomID: m.bot.GetMapping,
getFilters: m.bot.GetIFOptions,
receiveEmail: m.ReceiveEmail,
ban: m.bot.BanAuto,
greylisted: m.bot.IsGreylisted,
trusted: m.bot.IsTrusted,
log: m.log,
domains: m.domains,
addr: state.RemoteAddr,
tos: []string{},
}, nil
}
// 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
// ReceiveEmail - incoming mail into matrix room
func (m *mailServer) ReceiveEmail(ctx context.Context, eml *email.Email) error {
return m.bot.IncomingEmail(ctx, eml)
}

250
smtp/session.go Normal file
View File

@@ -0,0 +1,250 @@
package smtp
import (
"bytes"
"context"
"errors"
"io"
"net"
"strconv"
"github.com/emersion/go-msgauth/dkim"
"github.com/emersion/go-smtp"
"github.com/getsentry/sentry-go"
"github.com/jhillyerd/enmime"
"github.com/rs/zerolog"
"gitlab.com/etke.cc/go/validator"
"maunium.net/go/mautrix/id"
"gitlab.com/etke.cc/postmoogle/email"
"gitlab.com/etke.cc/postmoogle/utils"
)
// incomingSession represents an SMTP-submission session receiving emails from remote servers
type incomingSession struct {
log *zerolog.Logger
getRoomID func(string) (id.RoomID, bool)
getFilters func(id.RoomID) email.IncomingFilteringOptions
receiveEmail func(context.Context, *email.Email) error
greylisted func(net.Addr) bool
trusted func(net.Addr) bool
ban func(net.Addr)
domains []string
roomID id.RoomID
ctx context.Context
addr net.Addr
tos []string
from string
}
func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
if !email.AddressValid(from) {
s.log.Debug().Str("from", from).Msg("address is invalid")
s.ban(s.addr)
return ErrBanned
}
s.from = from
s.log.Debug().Str("from", from).Any("options", opts).Msg("incoming mail")
return nil
}
func (s *incomingSession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.tos = append(s.tos, to)
hostname := utils.Hostname(to)
var domainok bool
for _, domain := range s.domains {
if hostname == domain {
domainok = true
break
}
}
if !domainok {
s.log.Debug().Str("to", to).Msg("wrong domain")
return ErrNoUser
}
var ok bool
s.roomID, ok = s.getRoomID(utils.Mailbox(to))
if !ok {
s.log.Debug().Str("to", to).Msg("mapping not found")
return ErrNoUser
}
s.log.Debug().Str("to", to).Msg("mail")
return nil
}
// getAddr gets real address of incoming email serder,
// including special case of trusted proxy
func (s *incomingSession) getAddr(envelope *enmime.Envelope) net.Addr {
if !s.trusted(s.addr) {
return s.addr
}
addrHeader := envelope.GetHeader("X-Real-Addr")
if addrHeader == "" {
return s.addr
}
host, portString, _ := net.SplitHostPort(addrHeader) //nolint:errcheck
if host == "" {
return s.addr
}
var port int
port, _ = strconv.Atoi(portString) //nolint:errcheck
realAddr := &net.TCPAddr{IP: net.ParseIP(host), Port: port}
s.log.Info().Str("addr", realAddr.String()).Msg("real address")
return realAddr
}
func (s *incomingSession) Data(r io.Reader) error {
data, err := io.ReadAll(r)
if err != nil {
s.log.Error().Err(err).Msg("cannot read DATA")
return err
}
reader := bytes.NewReader(data)
parser := enmime.NewParser()
envelope, err := parser.ReadEnvelope(reader)
if err != nil {
return err
}
addr := s.getAddr(envelope)
reader.Seek(0, io.SeekStart) //nolint:errcheck
validations := s.getFilters(s.roomID)
if !validateIncoming(s.from, s.tos[0], addr, s.log, validations) {
s.ban(addr)
return ErrBanned
}
if s.greylisted(addr) {
return &smtp.SMTPError{
Code: 451,
EnhancedCode: smtp.EnhancedCode{4, 5, 1},
Message: "You have been greylisted, try again a bit later.",
}
}
if validations.SpamcheckDKIM() {
results, verr := dkim.Verify(reader)
if verr != nil {
s.log.Error().Err(verr).Msg("cannot verify DKIM")
return verr
}
for _, result := range results {
if result.Err != nil {
s.log.Info().Str("domain", result.Domain).Err(result.Err).Msg("DKIM verification failed")
return result.Err
}
}
}
eml := email.FromEnvelope(s.tos[0], envelope)
for _, to := range s.tos {
eml.RcptTo = to
err := s.receiveEmail(s.ctx, eml)
if err != nil {
return err
}
}
return nil
}
func (s *incomingSession) Reset() {}
func (s *incomingSession) Logout() error { return nil }
// outgoingSession represents an SMTP-submission session sending emails from external scripts, using postmoogle as SMTP server
type outgoingSession struct {
log *zerolog.Logger
sendmail func(string, string, string) error
privkey string
domains []string
getRoomID func(string) (id.RoomID, bool)
ctx context.Context
tos []string
from string
fromRoom id.RoomID
}
func (s *outgoingSession) Mail(from string, opts smtp.MailOptions) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
if !email.AddressValid(from) {
return errors.New("please, provide email address")
}
hostname := utils.Hostname(from)
var domainok bool
for _, domain := range s.domains {
if hostname == domain {
domainok = true
break
}
}
if !domainok {
s.log.Debug().Str("from", from).Msg("wrong domain")
return ErrNoUser
}
roomID, ok := s.getRoomID(utils.Mailbox(from))
if !ok {
s.log.Debug().Str("from", from).Msg("mapping not found")
return ErrNoUser
}
if s.fromRoom != roomID {
s.log.Warn().Str("from_roomID", s.fromRoom.String()).Str("roomID", roomID.String()).Msg("sender from different room tries to impersonate another mailbox")
return ErrNoUser
}
return nil
}
func (s *outgoingSession) Rcpt(to string) error {
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
s.tos = append(s.tos, to)
s.log.Debug().Str("to", to).Msg("mail")
return nil
}
func (s *outgoingSession) Data(r io.Reader) error {
parser := enmime.NewParser()
envelope, err := parser.ReadEnvelope(r)
if err != nil {
return err
}
eml := email.FromEnvelope(s.tos[0], envelope)
for _, to := range s.tos {
eml.RcptTo = to
err := s.sendmail(eml.From, to, eml.Compose(s.privkey))
if err != nil {
return err
}
}
return nil
}
func (s *outgoingSession) Reset() {}
func (s *outgoingSession) Logout() error { return nil }
func validateIncoming(from, to string, senderAddr net.Addr, log *zerolog.Logger, options email.IncomingFilteringOptions) bool {
var sender net.IP
switch netaddr := senderAddr.(type) {
case *net.TCPAddr:
sender = netaddr.IP
default:
host, _, _ := net.SplitHostPort(senderAddr.String()) // nolint:errcheck
sender = net.ParseIP(host)
}
enforce := validator.Enforce{
Email: true,
MX: options.SpamcheckMX(),
SPF: options.SpamcheckSPF(),
SMTP: options.SpamcheckSMTP(),
}
v := validator.New(options.Spamlist(), enforce, to, &validatorLoggerWrapper{log: log})
return v.Email(from, sender)
}

View File

@@ -1,209 +0,0 @@
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
To string
Subject string
Text string
HTML string
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,
To: to,
Subject: subject,
Text: text,
HTML: html,
Files: files,
}
if html != "" {
var err error
html, err = StripHTMLTag(html, "style")
if err == nil {
email.HTML = html
}
}
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

@@ -1,44 +0,0 @@
package utils
import (
"bytes"
"strings"
"golang.org/x/net/html"
)
// StripHTMLTag from text
//
// Source: https://siongui.github.io/2018/01/16/go-remove-html-inline-style/
func StripHTMLTag(text, tag string) (string, error) {
doc, err := html.Parse(strings.NewReader(text))
if err != nil {
return "", err
}
stripHTMLTag(doc, tag)
var out bytes.Buffer
err = html.Render(&out, doc)
if err != nil {
return "", err
}
return out.String(), nil
}
func stripHTMLTag(node *html.Node, tag string) {
i := -1
for index, attr := range node.Attr {
if attr.Key == tag {
i = index
break
}
}
if i != -1 {
node.Attr = append(node.Attr[:i], node.Attr[i+1:]...)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
stripHTMLTag(child, tag)
}
}

78
utils/mail.go Normal file
View File

@@ -0,0 +1,78 @@
package utils
import (
"strings"
"github.com/mcnijman/go-emailaddress"
)
// Mailbox returns mailbox part from email address
func Mailbox(email string) string {
mailbox, _, _ := EmailParts(email)
return mailbox
}
// Subaddress returns sub address part form email address
func Subaddress(email string) string {
_, sub, _ := EmailParts(email)
return sub
}
// Hostname returns hostname part from email address
func Hostname(email string) string {
_, _, hostname := EmailParts(email)
return hostname
}
// EmailParts parses email address into mailbox, subaddress, and hostname
func EmailParts(email string) (string, string, string) {
var mailbox, hostname string
address, err := emailaddress.Parse(email)
if err == nil {
mailbox = address.LocalPart
hostname = address.Domain
} else {
mailbox = email
hostname = email
mIdx := strings.Index(email, "@")
hIdx := strings.LastIndex(email, "@")
if mIdx != -1 {
mailbox = email[:mIdx]
}
if hIdx != -1 {
hostname = email[hIdx+1:]
}
}
var sub string
idx := strings.Index(mailbox, "+")
if idx != -1 {
sub = strings.ReplaceAll(mailbox[idx:], "+", "")
mailbox = strings.ReplaceAll(mailbox[:idx], "+", "")
}
return mailbox, sub, hostname
}
// EmailsList returns human-readable list of mailbox's emails for all available domains
func EmailsList(mailbox string, domain string) string {
var msg strings.Builder
domain = SanitizeDomain(domain)
msg.WriteString(mailbox)
msg.WriteString("@")
msg.WriteString(domain)
count := len(domains) - 1
for i, aliasDomain := range domains {
if i < count {
msg.WriteString(", ")
}
if aliasDomain == domain {
continue
}
msg.WriteString(mailbox)
msg.WriteString("@")
msg.WriteString(aliasDomain)
}
return msg.String()
}

70
utils/mail_test.go Normal file
View File

@@ -0,0 +1,70 @@
package utils
import "testing"
func TestMailbox(t *testing.T) {
tests := map[string]string{
"mailbox@example.com": "mailbox",
"mail-box@example.com": "mail-box",
"mailbox": "mailbox",
"mail@box@example.com": "mail",
"mailbox+@example.com": "mailbox",
"mailbox+sub@example.com": "mailbox",
"mailbox+++sub@example.com": "mailbox",
}
for in, expected := range tests {
t.Run(in, func(t *testing.T) {
output := Mailbox(in)
if output != expected {
t.Error(expected, "!=", output)
}
})
}
}
func TestSubaddress(t *testing.T) {
tests := map[string]string{
"mailbox@example@example.com": "",
"mail-box@example.com": "",
"mailbox+": "",
"mailbox+sub@example.com": "sub",
"mailbox+++sub@example.com": "sub",
}
for in, expected := range tests {
t.Run(in, func(t *testing.T) {
output := Subaddress(in)
if output != expected {
t.Error(expected, "!=", output)
}
})
}
}
func TestHostname(t *testing.T) {
tests := map[string]string{
"mailbox@example.com": "example.com",
"mailbox": "mailbox",
"mail@box@example.com": "example.com",
}
for in, expected := range tests {
t.Run(in, func(t *testing.T) {
output := Hostname(in)
if output != expected {
t.Error(expected, "!=", output)
}
})
}
}
func TestEmailList(t *testing.T) {
domains = []string{"example.com", "example.org"}
expected := "test@example.org, test@example.com"
actual := EmailsList("test", "example.org")
if actual != expected {
t.Error(expected, "!=", actual)
}
}

32
utils/mutex.go Normal file
View File

@@ -0,0 +1,32 @@
package utils
import "sync"
// Mutex map
type Mutex map[string]*sync.Mutex
// NewMutex map
func NewMutex() Mutex {
return Mutex{}
}
// Lock by key
func (m Mutex) Lock(key string) {
_, ok := m[key]
if !ok {
m[key] = &sync.Mutex{}
}
m[key].Lock()
}
// Unlock by key
func (m Mutex) Unlock(key string) {
_, ok := m[key]
if !ok {
return
}
m[key].Unlock()
delete(m, key)
}

View File

@@ -1,22 +1,43 @@
package utils
import (
"net"
"sort"
"strconv"
"strings"
)
// Mailbox returns mailbox part from email address
func Mailbox(email string) string {
index := strings.LastIndex(email, "@")
if index == -1 {
return email
}
return email[:index]
var domains []string
// SetDomains for later use
func SetDomains(slice []string) {
domains = slice
}
// Hostname returns hostname part from email address
func Hostname(email string) string {
return email[strings.LastIndex(email, "@")+1:]
// AddrIP returns IP from a network address
func AddrIP(addr net.Addr) string {
key := addr.String()
host, _, _ := net.SplitHostPort(key) //nolint:errcheck // either way it's ok
if host != "" {
key = host
}
return key
}
// SanitizeDomain checks that input domain is available for use
func SanitizeDomain(domain string) string {
domain = strings.TrimSpace(domain)
if domain == "" {
return domains[0]
}
for _, allowed := range domains {
if domain == allowed {
return domain
}
}
return domains[0]
}
// Bool converts string to boolean
@@ -34,6 +55,52 @@ func SanitizeBoolString(str string) string {
return strconv.FormatBool(Bool(str))
}
// Int converts string to integer
func Int(str string) int {
if str == "" {
return 0
}
i, err := strconv.Atoi(str)
if err != nil {
return 0
}
return i
}
// Int64 converts string into int64
func Int64(str string) int64 {
if str == "" {
return 0
}
i, err := strconv.ParseInt(str, 10, 64)
if err != nil {
return 0
}
return i
}
// SanitizeBoolString converts string to integer and back to string
func SanitizeIntString(str string) string {
return strconv.Itoa(Int(str))
}
// SliceString converts slice into comma-separated string
func SliceString(strs []string) string {
res := []string{}
for _, str := range strs {
str = strings.TrimSpace(str)
if str == "" {
continue
}
res = append(res, str)
}
sort.Strings(res)
return strings.Join(res, ",")
}
// StringSlice converts comma-separated string to slice
func StringSlice(str string) []string {
if str == "" {
@@ -45,19 +112,35 @@ func StringSlice(str string) []string {
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
}
parts := strings.Split(str, ",")
for i, part := range parts {
parts[i] = strings.TrimSpace(part)
}
return strings.Join(parts, ",")
return parts
}
// SanitizeBoolString converts string to slice and back to string
func SanitizeStringSlice(str string) string {
return SliceString(StringSlice(str))
}
// MapKeys returns sorted keys of the map
func MapKeys[V any](data map[string]V) []string {
keys := make([]string, 0, len(data))
for k := range data {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// Chunks divides slice by chunks with specified size
func Chunks[T any](slice []T, chunkSize int) [][]T {
chunks := make([][]T, 0, (len(slice)+chunkSize-1)/chunkSize)
for chunkSize < len(slice) {
slice, chunks = slice[chunkSize:], append(chunks, slice[0:chunkSize:chunkSize])
}
return append(chunks, slice)
}

10
vendor/blitiri.com.ar/go/spf/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Ignore anything beginning with a dot: these are usually temporary or
# unimportant.
.*
# Exceptions to the rule above: files we care about that would otherwise be
# excluded.
!.gitignore
# go-fuzz build artifacts.
*-fuzz.zip

27
vendor/blitiri.com.ar/go/spf/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,27 @@
Licensed under the MIT licence, which is reproduced below (from
https://opensource.org/licenses/MIT).
-----
Copyright (c) 2016
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

49
vendor/blitiri.com.ar/go/spf/README.md generated vendored Normal file
View File

@@ -0,0 +1,49 @@
# blitiri.com.ar/go/spf
[![GoDoc](https://godoc.org/blitiri.com.ar/go/spf?status.svg)](https://pkg.go.dev/blitiri.com.ar/go/spf)
[![Build Status](https://gitlab.com/albertito/spf/badges/master/pipeline.svg)](https://gitlab.com/albertito/spf/-/pipelines)
[![Go Report Card](https://goreportcard.com/badge/github.com/albertito/spf)](https://goreportcard.com/report/github.com/albertito/spf)
[![Coverage Status](https://coveralls.io/repos/github/albertito/spf/badge.svg?branch=next)](https://coveralls.io/github/albertito/spf)
[spf](https://godoc.org/blitiri.com.ar/go/spf) is an open source
implementation of the [Sender Policy Framework
(SPF)](https://en.wikipedia.org/wiki/Sender_Policy_Framework) in Go.
It is used by the [chasquid](https://blitiri.com.ar/p/chasquid/) and
[maddy](https://maddy.email) SMTP servers.
## Example
```go
// Check if `sender` is authorized to send from the given `ip`. The `domain`
// is used if the sender doesn't have one.
result, err := spf.CheckHostWithSender(ip, domain, sender)
if result == spf.Fail {
// Not authorized to send.
}
```
See the [package documentation](https://pkg.go.dev/blitiri.com.ar/go/spf) for
more details.
## Status
All SPF mechanisms, modifiers, and macros are supported.
The API should be considered stable. Major version changes will be announced
to the mailing list (details below).
## Contact
If you have any questions, comments or patches please send them to the mailing
list, `chasquid@googlegroups.com`.
To subscribe, send an email to `chasquid+subscribe@googlegroups.com`.
You can also browse the
[archives](https://groups.google.com/forum/#!forum/chasquid).

58
vendor/blitiri.com.ar/go/spf/fuzz.go generated vendored Normal file
View File

@@ -0,0 +1,58 @@
// Fuzz testing for package spf.
//
// Run it with:
//
// go-fuzz-build blitiri.com.ar/go/spf
// go-fuzz -bin=./spf-fuzz.zip -workdir=testdata/fuzz
//
//go:build gofuzz
// +build gofuzz
package spf
import (
"net"
"blitiri.com.ar/go/spf/internal/dnstest"
)
// Parsed IP addresses, for convenience.
var (
ip1110 = net.ParseIP("1.1.1.0")
ip1111 = net.ParseIP("1.1.1.1")
ip6666 = net.ParseIP("2001:db8::68")
ip6660 = net.ParseIP("2001:db8::0")
)
// DNS resolver to use. Will be initialized once with the expected fixtures,
// and then reused on each fuzz run.
var dns = dnstest.NewResolver()
func init() {
dns.Ip["d1111"] = []net.IP{ip1111}
dns.Ip["d1110"] = []net.IP{ip1110}
dns.Mx["d1110"] = []*net.MX{{"d1110", 5}, {"nothing", 10}}
dns.Ip["d6666"] = []net.IP{ip6666}
dns.Ip["d6660"] = []net.IP{ip6660}
dns.Mx["d6660"] = []*net.MX{{"d6660", 5}, {"nothing", 10}}
dns.Addr["2001:db8::68"] = []string{"sonlas6.", "domain.", "d6666."}
dns.Addr["1.1.1.1"] = []string{"lalala.", "domain.", "d1111."}
}
func Fuzz(data []byte) int {
// The domain's TXT record comes from the fuzzer.
dns.Txt["domain"] = []string{string(data)}
v4result, _ := CheckHostWithSender(
ip1111, "helo", "domain", WithResolver(dns))
v6result, _ := CheckHostWithSender(
ip6666, "helo", "domain", WithResolver(dns))
// Raise priority if any of the results was something other than
// PermError, as it means the data was better formed.
if v4result != PermError || v6result != PermError {
return 1
}
return 0
}

111
vendor/blitiri.com.ar/go/spf/internal/dnstest/dns.go generated vendored Normal file
View File

@@ -0,0 +1,111 @@
// DNS resolver for testing purposes.
//
// In the future, when go fuzz can make use of _test.go files, we can rename
// this file dns_test.go and remove this extra package entirely.
// Until then, unfortunately this is the most reasonable way to share these
// helpers between go and fuzz tests.
package dnstest
import (
"context"
"net"
"strings"
)
// Testing DNS resolver.
//
// Not exported since this is not part of the public API and only used
// internally on tests.
//
type TestResolver struct {
Txt map[string][]string
Mx map[string][]*net.MX
Ip map[string][]net.IP
Addr map[string][]string
Cname map[string]string
Errors map[string]error
}
func NewResolver() *TestResolver {
return &TestResolver{
Txt: map[string][]string{},
Mx: map[string][]*net.MX{},
Ip: map[string][]net.IP{},
Addr: map[string][]string{},
Cname: map[string]string{},
Errors: map[string]error{},
}
}
var nxDomainErr = &net.DNSError{
Err: "domain not found (for testing)",
IsNotFound: true,
}
func (r *TestResolver) LookupTXT(ctx context.Context, domain string) (txts []string, err error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
domain = strings.ToLower(domain)
domain = strings.TrimRight(domain, ".")
if cname, ok := r.Cname[domain]; ok {
return r.LookupTXT(ctx, cname)
}
if _, ok := r.Txt[domain]; !ok && r.Errors[domain] == nil {
return nil, nxDomainErr
}
return r.Txt[domain], r.Errors[domain]
}
func (r *TestResolver) LookupMX(ctx context.Context, domain string) (mxs []*net.MX, err error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
domain = strings.ToLower(domain)
domain = strings.TrimRight(domain, ".")
if cname, ok := r.Cname[domain]; ok {
return r.LookupMX(ctx, cname)
}
if _, ok := r.Mx[domain]; !ok && r.Errors[domain] == nil {
return nil, nxDomainErr
}
return r.Mx[domain], r.Errors[domain]
}
func (r *TestResolver) LookupIPAddr(ctx context.Context, host string) (as []net.IPAddr, err error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
host = strings.ToLower(host)
host = strings.TrimRight(host, ".")
if cname, ok := r.Cname[host]; ok {
return r.LookupIPAddr(ctx, cname)
}
if _, ok := r.Ip[host]; !ok && r.Errors[host] == nil {
return nil, nxDomainErr
}
return ipsToAddrs(r.Ip[host]), r.Errors[host]
}
func ipsToAddrs(ips []net.IP) []net.IPAddr {
as := []net.IPAddr{}
for _, ip := range ips {
as = append(as, net.IPAddr{IP: ip, Zone: ""})
}
return as
}
func (r *TestResolver) LookupAddr(ctx context.Context, host string) (addrs []string, err error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
host = strings.ToLower(host)
host = strings.TrimRight(host, ".")
if cname, ok := r.Cname[host]; ok {
return r.LookupAddr(ctx, cname)
}
if _, ok := r.Addr[host]; !ok && r.Errors[host] == nil {
return nil, nxDomainErr
}
return r.Addr[host], r.Errors[host]
}

1044
vendor/blitiri.com.ar/go/spf/spf.go generated vendored Normal file

File diff suppressed because it is too large Load Diff

1
vendor/github.com/archdx/zerolog-sentry/.gitignore generated vendored Normal file
View File

@@ -0,0 +1 @@
cover.out

20
vendor/github.com/archdx/zerolog-sentry/.golangci.yml generated vendored Normal file
View File

@@ -0,0 +1,20 @@
linters:
enable-all: false
enable:
- unparam
- whitespace
- unconvert
- bodyclose
- gofmt
- nakedret
- prealloc
- rowserrcheck
- unconvert
- gocritic
- godox
- errcheck
- ineffassign
linters-settings:
govet:
check-shadowing: true

175
vendor/github.com/archdx/zerolog-sentry/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,175 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.

18
vendor/github.com/archdx/zerolog-sentry/Makefile generated vendored Normal file
View File

@@ -0,0 +1,18 @@
GO?=go
modules:
@$(GO) mod tidy -v
test:
@$(GO) test -v -race -cover
lint:
golangci-lint run --deadline=5m -v
benchmarks:
@$(GO) test -bench=. -benchmem
coverage:
@$(GO) test -race -covermode=atomic -coverprofile=cover.out
.PHONY: modules test lint benchmarks coverage

30
vendor/github.com/archdx/zerolog-sentry/README.md generated vendored Normal file
View File

@@ -0,0 +1,30 @@
# zerolog-sentry
[![Build Status](https://github.com/archdx/zerolog-sentry/workflows/test/badge.svg)](https://github.com/archdx/zerolog-sentry/actions)
[![codecov](https://codecov.io/gh/archdx/zerolog-sentry/branch/master/graph/badge.svg)](https://codecov.io/gh/archdx/zerolog-sentry)
### Example
```go
import (
"errors"
"io"
stdlog "log"
"os"
"github.com/archdx/zerolog-sentry"
"github.com/rs/zerolog"
)
func main() {
w, err := zlogsentry.New("http://e35657dcf4fb4d7c98a1c0b8a9125088@localhost:9000/2")
if err != nil {
stdlog.Fatal(err)
}
defer w.Close()
logger := zerolog.New(io.MultiWriter(w, os.Stdout)).With().Timestamp().Logger()
logger.Error().Err(errors.New("dial timeout")).Msg("test message")
}
```

260
vendor/github.com/archdx/zerolog-sentry/writer.go generated vendored Normal file
View File

@@ -0,0 +1,260 @@
package zlogsentry
import (
"io"
"time"
"unsafe"
"github.com/buger/jsonparser"
"github.com/getsentry/sentry-go"
"github.com/rs/zerolog"
)
var levelsMapping = map[zerolog.Level]sentry.Level{
zerolog.DebugLevel: sentry.LevelDebug,
zerolog.InfoLevel: sentry.LevelInfo,
zerolog.WarnLevel: sentry.LevelWarning,
zerolog.ErrorLevel: sentry.LevelError,
zerolog.FatalLevel: sentry.LevelFatal,
zerolog.PanicLevel: sentry.LevelFatal,
}
var _ = io.WriteCloser(new(Writer))
var now = time.Now
// Writer is a sentry events writer with std io.Writer iface.
type Writer struct {
hub *sentry.Hub
levels map[zerolog.Level]struct{}
flushTimeout time.Duration
}
// Write handles zerolog's json and sends events to sentry.
func (w *Writer) Write(data []byte) (int, error) {
event, ok := w.parseLogEvent(data)
if ok {
w.hub.CaptureEvent(event)
// should flush before os.Exit
if event.Level == sentry.LevelFatal {
w.hub.Flush(w.flushTimeout)
}
}
return len(data), nil
}
// Close forces client to flush all pending events.
// Can be useful before application exits.
func (w *Writer) Close() error {
w.hub.Flush(w.flushTimeout)
return nil
}
func (w *Writer) parseLogEvent(data []byte) (*sentry.Event, bool) {
const logger = "zerolog"
lvlStr, err := jsonparser.GetUnsafeString(data, zerolog.LevelFieldName)
if err != nil {
return nil, false
}
lvl, err := zerolog.ParseLevel(lvlStr)
if err != nil {
return nil, false
}
_, enabled := w.levels[lvl]
if !enabled {
return nil, false
}
sentryLvl, ok := levelsMapping[lvl]
if !ok {
return nil, false
}
event := sentry.Event{
Timestamp: now(),
Level: sentryLvl,
Logger: logger,
Extra: map[string]interface{}{},
}
err = jsonparser.ObjectEach(data, func(key, value []byte, vt jsonparser.ValueType, offset int) error {
switch string(key) {
case zerolog.MessageFieldName:
event.Message = bytesToStrUnsafe(value)
case zerolog.ErrorFieldName:
event.Exception = append(event.Exception, sentry.Exception{
Value: bytesToStrUnsafe(value),
Stacktrace: newStacktrace(),
})
case zerolog.LevelFieldName, zerolog.TimestampFieldName:
default:
event.Extra[string(key)] = bytesToStrUnsafe(value)
}
return nil
})
if err != nil {
return nil, false
}
return &event, true
}
func newStacktrace() *sentry.Stacktrace {
const (
module = "github.com/archdx/zerolog-sentry"
loggerModule = "github.com/rs/zerolog"
)
st := sentry.NewStacktrace()
threshold := len(st.Frames) - 1
// drop current module frames
for ; threshold > 0 && st.Frames[threshold].Module == module; threshold-- {
}
outer:
// try to drop zerolog module frames after logger call point
for i := threshold; i > 0; i-- {
if st.Frames[i].Module == loggerModule {
for j := i - 1; j >= 0; j-- {
if st.Frames[j].Module != loggerModule {
threshold = j
break outer
}
}
break
}
}
st.Frames = st.Frames[:threshold+1]
return st
}
func bytesToStrUnsafe(data []byte) string {
return *(*string)(unsafe.Pointer(&data))
}
// WriterOption configures sentry events writer.
type WriterOption interface {
apply(*config)
}
type optionFunc func(*config)
func (fn optionFunc) apply(c *config) { fn(c) }
type config struct {
levels []zerolog.Level
sampleRate float64
release string
environment string
serverName string
ignoreErrors []string
debug bool
flushTimeout time.Duration
}
// WithLevels configures zerolog levels that have to be sent to Sentry.
// Default levels are: error, fatal, panic.
func WithLevels(levels ...zerolog.Level) WriterOption {
return optionFunc(func(cfg *config) {
cfg.levels = levels
})
}
// WithSampleRate configures the sample rate as a percentage of events to be sent in the range of 0.0 to 1.0.
func WithSampleRate(rate float64) WriterOption {
return optionFunc(func(cfg *config) {
cfg.sampleRate = rate
})
}
// WithRelease configures the release to be sent with events.
func WithRelease(release string) WriterOption {
return optionFunc(func(cfg *config) {
cfg.release = release
})
}
// WithEnvironment configures the environment to be sent with events.
func WithEnvironment(environment string) WriterOption {
return optionFunc(func(cfg *config) {
cfg.environment = environment
})
}
// WithServerName configures the server name field for events. Default value is OS hostname.
func WithServerName(serverName string) WriterOption {
return optionFunc(func(cfg *config) {
cfg.serverName = serverName
})
}
// WithIgnoreErrors configures the list of regexp strings that will be used to match against event's message
// and if applicable, caught errors type and value. If the match is found, then a whole event will be dropped.
func WithIgnoreErrors(reList []string) WriterOption {
return optionFunc(func(cfg *config) {
cfg.ignoreErrors = reList
})
}
// WithDebug enables sentry client debug logs.
func WithDebug() WriterOption {
return optionFunc(func(cfg *config) {
cfg.debug = true
})
}
// New creates writer with provided DSN and options.
func New(dsn string, opts ...WriterOption) (*Writer, error) {
cfg := newDefaultConfig()
for _, opt := range opts {
opt.apply(&cfg)
}
err := sentry.Init(sentry.ClientOptions{
Dsn: dsn,
SampleRate: cfg.sampleRate,
Release: cfg.release,
Environment: cfg.environment,
ServerName: cfg.serverName,
IgnoreErrors: cfg.ignoreErrors,
Debug: cfg.debug,
})
if err != nil {
return nil, err
}
levels := make(map[zerolog.Level]struct{}, len(cfg.levels))
for _, lvl := range cfg.levels {
levels[lvl] = struct{}{}
}
return &Writer{
hub: sentry.CurrentHub(),
levels: levels,
flushTimeout: cfg.flushTimeout,
}, nil
}
func newDefaultConfig() config {
return config{
levels: []zerolog.Level{
zerolog.ErrorLevel,
zerolog.FatalLevel,
zerolog.PanicLevel,
},
sampleRate: 1.0,
flushTimeout: 3 * time.Second,
}
}

12
vendor/github.com/buger/jsonparser/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,12 @@
*.test
*.out
*.mprof
.idea
vendor/github.com/buger/goterm/
prof.cpu
prof.mem

8
vendor/github.com/buger/jsonparser/.travis.yml generated vendored Normal file
View File

@@ -0,0 +1,8 @@
language: go
go:
- 1.7.x
- 1.8.x
- 1.9.x
- 1.10.x
- 1.11.x
script: go test -v ./.

12
vendor/github.com/buger/jsonparser/Dockerfile generated vendored Normal file
View File

@@ -0,0 +1,12 @@
FROM golang:1.6
RUN go get github.com/Jeffail/gabs
RUN go get github.com/bitly/go-simplejson
RUN go get github.com/pquerna/ffjson
RUN go get github.com/antonholmquist/jason
RUN go get github.com/mreiferson/go-ujson
RUN go get -tags=unsafe -u github.com/ugorji/go/codec
RUN go get github.com/mailru/easyjson
WORKDIR /go/src/github.com/buger/jsonparser
ADD . /go/src/github.com/buger/jsonparser

21
vendor/github.com/buger/jsonparser/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016 Leonid Bugaev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

36
vendor/github.com/buger/jsonparser/Makefile generated vendored Normal file
View File

@@ -0,0 +1,36 @@
SOURCE = parser.go
CONTAINER = jsonparser
SOURCE_PATH = /go/src/github.com/buger/jsonparser
BENCHMARK = JsonParser
BENCHTIME = 5s
TEST = .
DRUN = docker run -v `pwd`:$(SOURCE_PATH) -i -t $(CONTAINER)
build:
docker build -t $(CONTAINER) .
race:
$(DRUN) --env GORACE="halt_on_error=1" go test ./. $(ARGS) -v -race -timeout 15s
bench:
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -benchtime $(BENCHTIME) -v
bench_local:
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench . $(ARGS) -benchtime $(BENCHTIME) -v
profile:
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -memprofile mem.mprof -v
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -cpuprofile cpu.out -v
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -c
test:
$(DRUN) go test $(LDFLAGS) ./ -run $(TEST) -timeout 10s $(ARGS) -v
fmt:
$(DRUN) go fmt ./...
vet:
$(DRUN) go vet ./.
bash:
$(DRUN) /bin/bash

365
vendor/github.com/buger/jsonparser/README.md generated vendored Normal file
View File

@@ -0,0 +1,365 @@
[![Go Report Card](https://goreportcard.com/badge/github.com/buger/jsonparser)](https://goreportcard.com/report/github.com/buger/jsonparser) ![License](https://img.shields.io/dub/l/vibe-d.svg)
# Alternative JSON parser for Go (so far fastest)
It does not require you to know the structure of the payload (eg. create structs), and allows accessing fields by providing the path to them. It is up to **10 times faster** than standard `encoding/json` package (depending on payload size and usage), **allocates no memory**. See benchmarks below.
## Rationale
Originally I made this for a project that relies on a lot of 3rd party APIs that can be unpredictable and complex.
I love simplicity and prefer to avoid external dependecies. `encoding/json` requires you to know exactly your data structures, or if you prefer to use `map[string]interface{}` instead, it will be very slow and hard to manage.
I investigated what's on the market and found that most libraries are just wrappers around `encoding/json`, there is few options with own parsers (`ffjson`, `easyjson`), but they still requires you to create data structures.
Goal of this project is to push JSON parser to the performance limits and not sacrifice with compliance and developer user experience.
## Example
For the given JSON our goal is to extract the user's full name, number of github followers and avatar.
```go
import "github.com/buger/jsonparser"
...
data := []byte(`{
"person": {
"name": {
"first": "Leonid",
"last": "Bugaev",
"fullName": "Leonid Bugaev"
},
"github": {
"handle": "buger",
"followers": 109
},
"avatars": [
{ "url": "https://avatars1.githubusercontent.com/u/14009?v=3&s=460", "type": "thumbnail" }
]
},
"company": {
"name": "Acme"
}
}`)
// You can specify key path by providing arguments to Get function
jsonparser.Get(data, "person", "name", "fullName")
// There is `GetInt` and `GetBoolean` helpers if you exactly know key data type
jsonparser.GetInt(data, "person", "github", "followers")
// When you try to get object, it will return you []byte slice pointer to data containing it
// In `company` it will be `{"name": "Acme"}`
jsonparser.Get(data, "company")
// If the key doesn't exist it will throw an error
var size int64
if value, err := jsonparser.GetInt(data, "company", "size"); err == nil {
size = value
}
// You can use `ArrayEach` helper to iterate items [item1, item2 .... itemN]
jsonparser.ArrayEach(data, func(value []byte, dataType jsonparser.ValueType, offset int, err error) {
fmt.Println(jsonparser.Get(value, "url"))
}, "person", "avatars")
// Or use can access fields by index!
jsonparser.GetString(data, "person", "avatars", "[0]", "url")
// You can use `ObjectEach` helper to iterate objects { "key1":object1, "key2":object2, .... "keyN":objectN }
jsonparser.ObjectEach(data, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error {
fmt.Printf("Key: '%s'\n Value: '%s'\n Type: %s\n", string(key), string(value), dataType)
return nil
}, "person", "name")
// The most efficient way to extract multiple keys is `EachKey`
paths := [][]string{
[]string{"person", "name", "fullName"},
[]string{"person", "avatars", "[0]", "url"},
[]string{"company", "url"},
}
jsonparser.EachKey(data, func(idx int, value []byte, vt jsonparser.ValueType, err error){
switch idx {
case 0: // []string{"person", "name", "fullName"}
...
case 1: // []string{"person", "avatars", "[0]", "url"}
...
case 2: // []string{"company", "url"},
...
}
}, paths...)
// For more information see docs below
```
## Need to speedup your app?
I'm available for consulting and can help you push your app performance to the limits. Ping me at: leonsbox@gmail.com.
## Reference
Library API is really simple. You just need the `Get` method to perform any operation. The rest is just helpers around it.
You also can view API at [godoc.org](https://godoc.org/github.com/buger/jsonparser)
### **`Get`**
```go
func Get(data []byte, keys ...string) (value []byte, dataType jsonparser.ValueType, offset int, err error)
```
Receives data structure, and key path to extract value from.
Returns:
* `value` - Pointer to original data structure containing key value, or just empty slice if nothing found or error
* `dataType` - Can be: `NotExist`, `String`, `Number`, `Object`, `Array`, `Boolean` or `Null`
* `offset` - Offset from provided data structure where key value ends. Used mostly internally, for example for `ArrayEach` helper.
* `err` - If the key is not found or any other parsing issue, it should return error. If key not found it also sets `dataType` to `NotExist`
Accepts multiple keys to specify path to JSON value (in case of quering nested structures).
If no keys are provided it will try to extract the closest JSON value (simple ones or object/array), useful for reading streams or arrays, see `ArrayEach` implementation.
Note that keys can be an array indexes: `jsonparser.GetInt("person", "avatars", "[0]", "url")`, pretty cool, yeah?
### **`GetString`**
```go
func GetString(data []byte, keys ...string) (val string, err error)
```
Returns strings properly handing escaped and unicode characters. Note that this will cause additional memory allocations.
### **`GetUnsafeString`**
If you need string in your app, and ready to sacrifice with support of escaped symbols in favor of speed. It returns string mapped to existing byte slice memory, without any allocations:
```go
s, _, := jsonparser.GetUnsafeString(data, "person", "name", "title")
switch s {
case 'CEO':
...
case 'Engineer'
...
...
}
```
Note that `unsafe` here means that your string will exist until GC will free underlying byte slice, for most of cases it means that you can use this string only in current context, and should not pass it anywhere externally: through channels or any other way.
### **`GetBoolean`**, **`GetInt`** and **`GetFloat`**
```go
func GetBoolean(data []byte, keys ...string) (val bool, err error)
func GetFloat(data []byte, keys ...string) (val float64, err error)
func GetInt(data []byte, keys ...string) (val int64, err error)
```
If you know the key type, you can use the helpers above.
If key data type do not match, it will return error.
### **`ArrayEach`**
```go
func ArrayEach(data []byte, cb func(value []byte, dataType jsonparser.ValueType, offset int, err error), keys ...string)
```
Needed for iterating arrays, accepts a callback function with the same return arguments as `Get`.
### **`ObjectEach`**
```go
func ObjectEach(data []byte, callback func(key []byte, value []byte, dataType ValueType, offset int) error, keys ...string) (err error)
```
Needed for iterating object, accepts a callback function. Example:
```go
var handler func([]byte, []byte, jsonparser.ValueType, int) error
handler = func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error {
//do stuff here
}
jsonparser.ObjectEach(myJson, handler)
```
### **`EachKey`**
```go
func EachKey(data []byte, cb func(idx int, value []byte, dataType jsonparser.ValueType, err error), paths ...[]string)
```
When you need to read multiple keys, and you do not afraid of low-level API `EachKey` is your friend. It read payload only single time, and calls callback function once path is found. For example when you call multiple times `Get`, it has to process payload multiple times, each time you call it. Depending on payload `EachKey` can be multiple times faster than `Get`. Path can use nested keys as well!
```go
paths := [][]string{
[]string{"uuid"},
[]string{"tz"},
[]string{"ua"},
[]string{"st"},
}
var data SmallPayload
jsonparser.EachKey(smallFixture, func(idx int, value []byte, vt jsonparser.ValueType, err error){
switch idx {
case 0:
data.Uuid, _ = value
case 1:
v, _ := jsonparser.ParseInt(value)
data.Tz = int(v)
case 2:
data.Ua, _ = value
case 3:
v, _ := jsonparser.ParseInt(value)
data.St = int(v)
}
}, paths...)
```
### **`Set`**
```go
func Set(data []byte, setValue []byte, keys ...string) (value []byte, err error)
```
Receives existing data structure, key path to set, and value to set at that key. *This functionality is experimental.*
Returns:
* `value` - Pointer to original data structure with updated or added key value.
* `err` - If any parsing issue, it should return error.
Accepts multiple keys to specify path to JSON value (in case of updating or creating nested structures).
Note that keys can be an array indexes: `jsonparser.Set(data, []byte("http://github.com"), "person", "avatars", "[0]", "url")`
### **`Delete`**
```go
func Delete(data []byte, keys ...string) value []byte
```
Receives existing data structure, and key path to delete. *This functionality is experimental.*
Returns:
* `value` - Pointer to original data structure with key path deleted if it can be found. If there is no key path, then the whole data structure is deleted.
Accepts multiple keys to specify path to JSON value (in case of updating or creating nested structures).
Note that keys can be an array indexes: `jsonparser.Delete(data, "person", "avatars", "[0]", "url")`
## What makes it so fast?
* It does not rely on `encoding/json`, `reflection` or `interface{}`, the only real package dependency is `bytes`.
* Operates with JSON payload on byte level, providing you pointers to the original data structure: no memory allocation.
* No automatic type conversions, by default everything is a []byte, but it provides you value type, so you can convert by yourself (there is few helpers included).
* Does not parse full record, only keys you specified
## Benchmarks
There are 3 benchmark types, trying to simulate real-life usage for small, medium and large JSON payloads.
For each metric, the lower value is better. Time/op is in nanoseconds. Values better than standard encoding/json marked as bold text.
Benchmarks run on standard Linode 1024 box.
Compared libraries:
* https://golang.org/pkg/encoding/json
* https://github.com/Jeffail/gabs
* https://github.com/a8m/djson
* https://github.com/bitly/go-simplejson
* https://github.com/antonholmquist/jason
* https://github.com/mreiferson/go-ujson
* https://github.com/ugorji/go/codec
* https://github.com/pquerna/ffjson
* https://github.com/mailru/easyjson
* https://github.com/buger/jsonparser
#### TLDR
If you want to skip next sections we have 2 winner: `jsonparser` and `easyjson`.
`jsonparser` is up to 10 times faster than standard `encoding/json` package (depending on payload size and usage), and almost infinitely (literally) better in memory consumption because it operates with data on byte level, and provide direct slice pointers.
`easyjson` wins in CPU in medium tests and frankly i'm impressed with this package: it is remarkable results considering that it is almost drop-in replacement for `encoding/json` (require some code generation).
It's hard to fully compare `jsonparser` and `easyjson` (or `ffson`), they a true parsers and fully process record, unlike `jsonparser` which parse only keys you specified.
If you searching for replacement of `encoding/json` while keeping structs, `easyjson` is an amazing choice. If you want to process dynamic JSON, have memory constrains, or more control over your data you should try `jsonparser`.
`jsonparser` performance heavily depends on usage, and it works best when you do not need to process full record, only some keys. The more calls you need to make, the slower it will be, in contrast `easyjson` (or `ffjson`, `encoding/json`) parser record only 1 time, and then you can make as many calls as you want.
With great power comes great responsibility! :)
#### Small payload
Each test processes 190 bytes of http log as a JSON record.
It should read multiple fields.
https://github.com/buger/jsonparser/blob/master/benchmark/benchmark_small_payload_test.go
Library | time/op | bytes/op | allocs/op
------ | ------- | -------- | -------
encoding/json struct | 7879 | 880 | 18
encoding/json interface{} | 8946 | 1521 | 38
Jeffail/gabs | 10053 | 1649 | 46
bitly/go-simplejson | 10128 | 2241 | 36
antonholmquist/jason | 27152 | 7237 | 101
github.com/ugorji/go/codec | 8806 | 2176 | 31
mreiferson/go-ujson | **7008** | **1409** | 37
a8m/djson | 3862 | 1249 | 30
pquerna/ffjson | **3769** | **624** | **15**
mailru/easyjson | **2002** | **192** | **9**
buger/jsonparser | **1367** | **0** | **0**
buger/jsonparser (EachKey API) | **809** | **0** | **0**
Winners are ffjson, easyjson and jsonparser, where jsonparser is up to 9.8x faster than encoding/json and 4.6x faster than ffjson, and slightly faster than easyjson.
If you look at memory allocation, jsonparser has no rivals, as it makes no data copy and operates with raw []byte structures and pointers to it.
#### Medium payload
Each test processes a 2.4kb JSON record (based on Clearbit API).
It should read multiple nested fields and 1 array.
https://github.com/buger/jsonparser/blob/master/benchmark/benchmark_medium_payload_test.go
| Library | time/op | bytes/op | allocs/op |
| ------- | ------- | -------- | --------- |
| encoding/json struct | 57749 | 1336 | 29 |
| encoding/json interface{} | 79297 | 10627 | 215 |
| Jeffail/gabs | 83807 | 11202 | 235 |
| bitly/go-simplejson | 88187 | 17187 | 220 |
| antonholmquist/jason | 94099 | 19013 | 247 |
| github.com/ugorji/go/codec | 114719 | 6712 | 152 |
| mreiferson/go-ujson | **56972** | 11547 | 270 |
| a8m/djson | 28525 | 10196 | 198 |
| pquerna/ffjson | **20298** | **856** | **20** |
| mailru/easyjson | **10512** | **336** | **12** |
| buger/jsonparser | **15955** | **0** | **0** |
| buger/jsonparser (EachKey API) | **8916** | **0** | **0** |
The difference between ffjson and jsonparser in CPU usage is smaller, while the memory consumption difference is growing. On the other hand `easyjson` shows remarkable performance for medium payload.
`gabs`, `go-simplejson` and `jason` are based on encoding/json and map[string]interface{} and actually only helpers for unstructured JSON, their performance correlate with `encoding/json interface{}`, and they will skip next round.
`go-ujson` while have its own parser, shows same performance as `encoding/json`, also skips next round. Same situation with `ugorji/go/codec`, but it showed unexpectedly bad performance for complex payloads.
#### Large payload
Each test processes a 24kb JSON record (based on Discourse API)
It should read 2 arrays, and for each item in array get a few fields.
Basically it means processing a full JSON file.
https://github.com/buger/jsonparser/blob/master/benchmark/benchmark_large_payload_test.go
| Library | time/op | bytes/op | allocs/op |
| --- | --- | --- | --- |
| encoding/json struct | 748336 | 8272 | 307 |
| encoding/json interface{} | 1224271 | 215425 | 3395 |
| a8m/djson | 510082 | 213682 | 2845 |
| pquerna/ffjson | **312271** | **7792** | **298** |
| mailru/easyjson | **154186** | **6992** | **288** |
| buger/jsonparser | **85308** | **0** | **0** |
`jsonparser` now is a winner, but do not forget that it is way more lightweight parser than `ffson` or `easyjson`, and they have to parser all the data, while `jsonparser` parse only what you need. All `ffjson`, `easysjon` and `jsonparser` have their own parsing code, and does not depend on `encoding/json` or `interface{}`, thats one of the reasons why they are so fast. `easyjson` also use a bit of `unsafe` package to reduce memory consuption (in theory it can lead to some unexpected GC issue, but i did not tested enough)
Also last benchmark did not included `EachKey` test, because in this particular case we need to read lot of Array values, and using `ArrayEach` is more efficient.
## Questions and support
All bug-reports and suggestions should go though Github Issues.
## Contributing
1. Fork it
2. Create your feature branch (git checkout -b my-new-feature)
3. Commit your changes (git commit -am 'Added some feature')
4. Push to the branch (git push origin my-new-feature)
5. Create new Pull Request
## Development
All my development happens using Docker, and repo include some Make tasks to simplify development.
* `make build` - builds docker image, usually can be called only once
* `make test` - run tests
* `make fmt` - run go fmt
* `make bench` - run benchmarks (if you need to run only single benchmark modify `BENCHMARK` variable in make file)
* `make profile` - runs benchmark and generate 3 files- `cpu.out`, `mem.mprof` and `benchmark.test` binary, which can be used for `go tool pprof`
* `make bash` - enter container (i use it for running `go tool pprof` above)

47
vendor/github.com/buger/jsonparser/bytes.go generated vendored Normal file
View File

@@ -0,0 +1,47 @@
package jsonparser
import (
bio "bytes"
)
// minInt64 '-9223372036854775808' is the smallest representable number in int64
const minInt64 = `9223372036854775808`
// About 2x faster then strconv.ParseInt because it only supports base 10, which is enough for JSON
func parseInt(bytes []byte) (v int64, ok bool, overflow bool) {
if len(bytes) == 0 {
return 0, false, false
}
var neg bool = false
if bytes[0] == '-' {
neg = true
bytes = bytes[1:]
}
var b int64 = 0
for _, c := range bytes {
if c >= '0' && c <= '9' {
b = (10 * v) + int64(c-'0')
} else {
return 0, false, false
}
if overflow = (b < v); overflow {
break
}
v = b
}
if overflow {
if neg && bio.Equal(bytes, []byte(minInt64)) {
return b, true, false
}
return 0, false, true
}
if neg {
return -v, true, false
} else {
return v, true, false
}
}

25
vendor/github.com/buger/jsonparser/bytes_safe.go generated vendored Normal file
View File

@@ -0,0 +1,25 @@
// +build appengine appenginevm
package jsonparser
import (
"strconv"
)
// See fastbytes_unsafe.go for explanation on why *[]byte is used (signatures must be consistent with those in that file)
func equalStr(b *[]byte, s string) bool {
return string(*b) == s
}
func parseFloat(b *[]byte) (float64, error) {
return strconv.ParseFloat(string(*b), 64)
}
func bytesToString(b *[]byte) string {
return string(*b)
}
func StringToBytes(s string) []byte {
return []byte(s)
}

42
vendor/github.com/buger/jsonparser/bytes_unsafe.go generated vendored Normal file
View File

@@ -0,0 +1,42 @@
// +build !appengine,!appenginevm
package jsonparser
import (
"reflect"
"strconv"
"unsafe"
)
//
// The reason for using *[]byte rather than []byte in parameters is an optimization. As of Go 1.6,
// the compiler cannot perfectly inline the function when using a non-pointer slice. That is,
// the non-pointer []byte parameter version is slower than if its function body is manually
// inlined, whereas the pointer []byte version is equally fast to the manually inlined
// version. Instruction count in assembly taken from "go tool compile" confirms this difference.
//
// TODO: Remove hack after Go 1.7 release
//
func equalStr(b *[]byte, s string) bool {
return *(*string)(unsafe.Pointer(b)) == s
}
func parseFloat(b *[]byte) (float64, error) {
return strconv.ParseFloat(*(*string)(unsafe.Pointer(b)), 64)
}
// A hack until issue golang/go#2632 is fixed.
// See: https://github.com/golang/go/issues/2632
func bytesToString(b *[]byte) string {
return *(*string)(unsafe.Pointer(b))
}
func StringToBytes(s string) []byte {
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
bh := reflect.SliceHeader{
Data: sh.Data,
Len: sh.Len,
Cap: sh.Len,
}
return *(*[]byte)(unsafe.Pointer(&bh))
}

173
vendor/github.com/buger/jsonparser/escape.go generated vendored Normal file
View File

@@ -0,0 +1,173 @@
package jsonparser
import (
"bytes"
"unicode/utf8"
)
// JSON Unicode stuff: see https://tools.ietf.org/html/rfc7159#section-7
const supplementalPlanesOffset = 0x10000
const highSurrogateOffset = 0xD800
const lowSurrogateOffset = 0xDC00
const basicMultilingualPlaneReservedOffset = 0xDFFF
const basicMultilingualPlaneOffset = 0xFFFF
func combineUTF16Surrogates(high, low rune) rune {
return supplementalPlanesOffset + (high-highSurrogateOffset)<<10 + (low - lowSurrogateOffset)
}
const badHex = -1
func h2I(c byte) int {
switch {
case c >= '0' && c <= '9':
return int(c - '0')
case c >= 'A' && c <= 'F':
return int(c - 'A' + 10)
case c >= 'a' && c <= 'f':
return int(c - 'a' + 10)
}
return badHex
}
// decodeSingleUnicodeEscape decodes a single \uXXXX escape sequence. The prefix \u is assumed to be present and
// is not checked.
// In JSON, these escapes can either come alone or as part of "UTF16 surrogate pairs" that must be handled together.
// This function only handles one; decodeUnicodeEscape handles this more complex case.
func decodeSingleUnicodeEscape(in []byte) (rune, bool) {
// We need at least 6 characters total
if len(in) < 6 {
return utf8.RuneError, false
}
// Convert hex to decimal
h1, h2, h3, h4 := h2I(in[2]), h2I(in[3]), h2I(in[4]), h2I(in[5])
if h1 == badHex || h2 == badHex || h3 == badHex || h4 == badHex {
return utf8.RuneError, false
}
// Compose the hex digits
return rune(h1<<12 + h2<<8 + h3<<4 + h4), true
}
// isUTF16EncodedRune checks if a rune is in the range for non-BMP characters,
// which is used to describe UTF16 chars.
// Source: https://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane
func isUTF16EncodedRune(r rune) bool {
return highSurrogateOffset <= r && r <= basicMultilingualPlaneReservedOffset
}
func decodeUnicodeEscape(in []byte) (rune, int) {
if r, ok := decodeSingleUnicodeEscape(in); !ok {
// Invalid Unicode escape
return utf8.RuneError, -1
} else if r <= basicMultilingualPlaneOffset && !isUTF16EncodedRune(r) {
// Valid Unicode escape in Basic Multilingual Plane
return r, 6
} else if r2, ok := decodeSingleUnicodeEscape(in[6:]); !ok { // Note: previous decodeSingleUnicodeEscape success guarantees at least 6 bytes remain
// UTF16 "high surrogate" without manditory valid following Unicode escape for the "low surrogate"
return utf8.RuneError, -1
} else if r2 < lowSurrogateOffset {
// Invalid UTF16 "low surrogate"
return utf8.RuneError, -1
} else {
// Valid UTF16 surrogate pair
return combineUTF16Surrogates(r, r2), 12
}
}
// backslashCharEscapeTable: when '\X' is found for some byte X, it is to be replaced with backslashCharEscapeTable[X]
var backslashCharEscapeTable = [...]byte{
'"': '"',
'\\': '\\',
'/': '/',
'b': '\b',
'f': '\f',
'n': '\n',
'r': '\r',
't': '\t',
}
// unescapeToUTF8 unescapes the single escape sequence starting at 'in' into 'out' and returns
// how many characters were consumed from 'in' and emitted into 'out'.
// If a valid escape sequence does not appear as a prefix of 'in', (-1, -1) to signal the error.
func unescapeToUTF8(in, out []byte) (inLen int, outLen int) {
if len(in) < 2 || in[0] != '\\' {
// Invalid escape due to insufficient characters for any escape or no initial backslash
return -1, -1
}
// https://tools.ietf.org/html/rfc7159#section-7
switch e := in[1]; e {
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
// Valid basic 2-character escapes (use lookup table)
out[0] = backslashCharEscapeTable[e]
return 2, 1
case 'u':
// Unicode escape
if r, inLen := decodeUnicodeEscape(in); inLen == -1 {
// Invalid Unicode escape
return -1, -1
} else {
// Valid Unicode escape; re-encode as UTF8
outLen := utf8.EncodeRune(out, r)
return inLen, outLen
}
}
return -1, -1
}
// unescape unescapes the string contained in 'in' and returns it as a slice.
// If 'in' contains no escaped characters:
// Returns 'in'.
// Else, if 'out' is of sufficient capacity (guaranteed if cap(out) >= len(in)):
// 'out' is used to build the unescaped string and is returned with no extra allocation
// Else:
// A new slice is allocated and returned.
func Unescape(in, out []byte) ([]byte, error) {
firstBackslash := bytes.IndexByte(in, '\\')
if firstBackslash == -1 {
return in, nil
}
// Get a buffer of sufficient size (allocate if needed)
if cap(out) < len(in) {
out = make([]byte, len(in))
} else {
out = out[0:len(in)]
}
// Copy the first sequence of unescaped bytes to the output and obtain a buffer pointer (subslice)
copy(out, in[:firstBackslash])
in = in[firstBackslash:]
buf := out[firstBackslash:]
for len(in) > 0 {
// Unescape the next escaped character
inLen, bufLen := unescapeToUTF8(in, buf)
if inLen == -1 {
return nil, MalformedStringEscapeError
}
in = in[inLen:]
buf = buf[bufLen:]
// Copy everything up until the next backslash
nextBackslash := bytes.IndexByte(in, '\\')
if nextBackslash == -1 {
copy(buf, in)
buf = buf[len(in):]
break
} else {
copy(buf, in[:nextBackslash])
buf = buf[nextBackslash:]
in = in[nextBackslash:]
}
}
// Trim the out buffer to the amount that was actually emitted
return out[:len(out)-len(buf)], nil
}

9
vendor/github.com/buger/jsonparser/fuzz.go generated vendored Normal file
View File

@@ -0,0 +1,9 @@
package jsonparser
func FuzzParseString(data []byte) int {
r, err := ParseString(data)
if err != nil || r == "" {
return 0
}
return 1
}

1237
vendor/github.com/buger/jsonparser/parser.go generated vendored Normal file

File diff suppressed because it is too large Load Diff

12
vendor/github.com/cention-sany/utf7/.travis.yml generated vendored Normal file
View File

@@ -0,0 +1,12 @@
language: go
go:
- 1.4.2
- 1.7.4
- tip
install:
- go get -v ./...
- go get golang.org/x/text/encoding
- go get golang.org/x/text/transform

29
vendor/github.com/cention-sany/utf7/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,29 @@
Copyright (c) 2013 The Go-IMAP Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the
distribution.
* Neither the name of the go-imap project nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

2
vendor/github.com/cention-sany/utf7/README.md generated vendored Normal file
View File

@@ -0,0 +1,2 @@
# utf7 [![Build Status](https://travis-ci.org/cention-sany/utf7.png?branch=master)](https://travis-ci.org/cention-sany/utf7) [![GoDoc](https://godoc.org/github.com/cention-sany/utf7?status.png)](https://godoc.org/github.com/cention-sany/utf7) [![Exago](https://api.exago.io:443/badge/cov/github.com/cention-sany/utf7)](https://exago.io/project/github.com/cention-sany/utf7) [![Exago](https://api.exago.io:443/badge/rank/github.com/cention-sany/utf7)](https://exago.io/project/github.com/cention-sany/utf7)
RFC 2152 - UTF7 encoding and decoding.

518
vendor/github.com/cention-sany/utf7/utf7.go generated vendored Normal file
View File

@@ -0,0 +1,518 @@
// Copyright 2013 The Go-IMAP Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
/*
This package modified from:
https://github.com/mxk/go-imap/blob/master/imap/utf7.go
https://github.com/mxk/go-imap/blob/master/imap/utf7_test.go
IMAP specification uses modified UTF-7. Following are the differences:
1) Printable US-ASCII except & (0x20 to 0x25 and 0x27 to 0x7e) MUST represent by themselves.
2) '&' is used to shift modified BASE64 instead of '+'.
3) Can NOT use superfluous null shift (&...-&...- should be just &......-).
4) ',' is used in BASE64 code instead of '/'.
5) '&' is represented '&-'. You can have many '&-&-&-&-'.
6) No implicit shift from BASE64 to US-ASCII. All BASE64 must end with '-'.
Actual UTF-7 specification:
Rule 1: direct characters: 62 alphanumeric characters and 9 symbols: ' ( ) , - . / : ?
Rule 2: optional direct characters: all other printable characters in the range
U+0020U+007E except ~ \ + and space. Plus sign (+) may be encoded as +-
(special case). Plus sign (+) mean the start of 'modified Base64 encoded UTF-16'.
The end of this block is indicated by any character not in the modified Base64.
If character after modified Base64 is a '-' then it is consumed.
Example:
"1 + 1 = 2" is encoded as "1 +- 1 +AD0 2" //+AD0 is the '=' sign.
"£1" is encoded as "+AKM-1" //+AKM- is the '£' sign where '-' is consumed.
A "+" character followed immediately by any character other than members
of modified Base64 or "-" is an ill-formed sequence. Convert to Unicode code
point then apply modified BASE64 (rfc2045) to it. Modified BASE64 do not use
padding instead add extra bits. Lines should never be broken in the middle of
a UTF-7 shifted sequence. Rule 3: Space, tab, carriage return and line feed may
also be represented directly as single ASCII bytes. Further content transfer
encoding may be needed if using in email environment.
*/
package utf7
import (
"bytes"
"encoding/base64"
"errors"
"io/ioutil"
"unicode/utf16"
"unicode/utf8"
"golang.org/x/text/encoding"
"golang.org/x/text/transform"
)
const (
uRepl = '\uFFFD' // Unicode replacement code point
u7min = 0x20 // Minimum self-representing UTF-7 value
u7max = 0x7E // Maximum self-representing UTF-7 value
)
// copy from golang.org/x/text/encoding/internal
type simpleEncoding struct {
Decoder transform.Transformer
Encoder transform.Transformer
}
func (e *simpleEncoding) NewDecoder() *encoding.Decoder {
return &encoding.Decoder{Transformer: e.Decoder}
}
func (e *simpleEncoding) NewEncoder() *encoding.Encoder {
return &encoding.Encoder{Transformer: e.Encoder}
}
var (
UTF7 encoding.Encoding = &simpleEncoding{
utf7Decoder{},
utf7Encoder{},
}
)
// ErrBadUTF7 is returned to indicate invalid modified UTF-7 encoding.
var ErrBadUTF7 = errors.New("utf7: bad utf-7 encoding")
// Base64 codec for code points outside of the 0x20-0x7E range.
const modifiedbase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
var u7enc = base64.NewEncoding(modifiedbase64)
func isModifiedBase64(r byte) bool {
if r >= 'A' && r <= 'Z' {
return true
} else if r >= 'a' && r <= 'z' {
return true
} else if r >= '0' && r <= '9' {
return true
} else if r == '+' || r == '/' {
return true
}
return false
// bs := []byte(modifiedbase64)
// for _, b := range bs {
// if b == r {
// return true
// }
// }
// return false
}
type utf7Decoder struct {
transform.NopResetter
}
func (d utf7Decoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
var implicit bool
var tmp int
nd, n := len(dst), len(src)
if n == 0 && !atEOF {
return 0, 0, transform.ErrShortSrc
}
for ; nSrc < n; nSrc++ {
if nDst >= nd {
return nDst, nSrc, transform.ErrShortDst
}
if c := src[nSrc]; ((c < u7min || c > u7max) &&
c != '\t' && c != '\r' && c != '\n') ||
c == '~' || c == '\\' {
return nDst, nSrc, ErrBadUTF7 // Illegal code point in ASCII mode
} else if c != '+' {
dst[nDst] = c // character can self represent
nDst++
continue
}
// found '+'
start := nSrc + 1
tmp = nSrc // nSrc remain pointing to '+', tmp point to end of BASE64
// Find the end of the Base64 or "+-" segment
implicit = false
for tmp++; tmp < n && src[tmp] != '-'; tmp++ {
if !isModifiedBase64(src[tmp]) {
if tmp == start {
return nDst, tmp, ErrBadUTF7 // '+' next char must modified base64
}
// implicit shift back to ASCII - no need '-' character
implicit = true
break
}
}
if tmp == start {
if tmp == n {
// did not find '-' sign and '+' is last character
// total nSrc no include '+'
if atEOF {
return nDst, nSrc, ErrBadUTF7 // '+' can not at the end
}
// '+' can not at the end, so get more data
return nDst, nSrc, transform.ErrShortSrc
}
dst[nDst] = '+' // Escape sequence "+-"
nDst++
} else if tmp == n && !atEOF {
// no end of BASE64 marker and still has data
// probably the marker at next block of data
// so go get more data.
return nDst, nSrc, transform.ErrShortSrc
} else if b := utf7dec(src[start:tmp]); len(b) > 0 {
if len(b)+nDst > nd {
// need more space on dst for the decoded modified BASE64 unicode
// total nSrc no include '+'
return nDst, nSrc, transform.ErrShortDst
}
copy(dst[nDst:], b) // Control or non-ASCII code points in Base64
nDst += len(b)
if implicit {
if nDst >= nd {
return nDst, tmp, transform.ErrShortDst
}
dst[nDst] = src[tmp] // implicit shift
nDst++
}
if tmp == n {
return nDst, tmp, nil
}
} else {
return nDst, nSrc, ErrBadUTF7 // bad encoding
}
nSrc = tmp
}
return
}
type utf7Encoder struct {
transform.NopResetter
}
func calcExpectedSize(runeSize int) (round int) {
numerator := runeSize * 17
round = numerator / 12
remain := numerator % 12
if remain >= 6 {
round++
}
return
}
func (e utf7Encoder) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
var c byte
var b []byte
var endminus, needMoreSrc, needMoreDst, foundASCII, hasRuneStart bool
var tmp, compare, lastRuneStart int
var currentSize, maxRuneStart int
var rn rune
nd, n := len(dst), len(src)
if n == 0 {
if !atEOF {
return 0, 0, transform.ErrShortSrc
} else {
return 0, 0, nil
}
}
for nSrc = 0; nSrc < n; {
if nDst >= nd {
return nDst, nSrc, transform.ErrShortDst
}
c = src[nSrc]
if canSelf(c) {
nSrc++
dst[nDst] = c
nDst++
continue
} else if c == '+' {
if nDst+2 > nd {
return nDst, nSrc, transform.ErrShortDst
}
nSrc++
dst[nDst], dst[nDst+1] = '+', '-'
nDst += 2
continue
}
start := nSrc
tmp = nSrc // nSrc still point to first non-ASCII
currentSize = 0
maxRuneStart = nSrc
needMoreDst = false
if utf8.RuneStart(src[nSrc]) {
hasRuneStart = true
} else {
hasRuneStart = false
}
foundASCII = true
for tmp++; tmp < n && !canSelf(src[tmp]) && src[tmp] != '+'; tmp++ {
// if next printable ASCII code point found the loop stop
if utf8.RuneStart(src[tmp]) {
hasRuneStart = true
lastRuneStart = tmp
rn, _ = utf8.DecodeRune(src[maxRuneStart:tmp])
if rn >= 0x10000 {
currentSize += 4
} else {
currentSize += 2
}
if calcExpectedSize(currentSize)+2 > nd-nDst {
needMoreDst = true
} else {
maxRuneStart = tmp
}
}
}
// following to adjust tmp to right pointer as now tmp can not
// find any good ending (searching end with no result). Adjustment
// base on another earlier feasible valid rune position.
needMoreSrc = false
if tmp == n {
foundASCII = false
if !atEOF {
if !hasRuneStart {
return nDst, nSrc, transform.ErrShortSrc
} else {
//re-adjust tmp to good position to encode
if !utf8.Valid(src[maxRuneStart:]) {
if maxRuneStart == start {
return nDst, nSrc, transform.ErrShortSrc
}
needMoreSrc = true
tmp = maxRuneStart
}
}
}
}
endminus = false
if hasRuneStart && !needMoreSrc {
// need check if dst enough buffer for transform
rn, _ = utf8.DecodeRune(src[lastRuneStart:tmp])
if rn >= 0x10000 {
currentSize += 4
} else {
currentSize += 2
}
if calcExpectedSize(currentSize)+2 > nd-nDst {
// can not use tmp value as transofrmed size too
// big for dst
endminus = true
needMoreDst = true
tmp = maxRuneStart
}
}
b = utf7enc(src[start:tmp])
if len(b) < 2 || b[0] != '+' {
return nDst, nSrc, ErrBadUTF7 // Illegal code point in ASCII mode
}
if foundASCII {
// printable ASCII found - check if BASE64 type
if isModifiedBase64(src[tmp]) || src[tmp] == '-' {
endminus = true
}
} else {
endminus = true
}
compare = nDst + len(b)
if endminus {
compare++
}
if compare > nd {
return nDst, nSrc, transform.ErrShortDst
}
copy(dst[nDst:], b)
nDst += len(b)
if endminus {
dst[nDst] = '-'
nDst++
}
nSrc = tmp
if needMoreDst {
return nDst, nSrc, transform.ErrShortDst
}
if needMoreSrc {
return nDst, nSrc, transform.ErrShortSrc
}
}
return
}
// UTF7Encode converts a string from UTF-8 encoding to modified UTF-7. This
// encoding is used by the Mailbox International Naming Convention (RFC 3501
// section 5.1.3). Invalid UTF-8 byte sequences are replaced by the Unicode
// replacement code point (U+FFFD).
func UTF7Encode(s string) string {
return string(UTF7EncodeBytes([]byte(s)))
}
const (
setD = iota
setO
setRule3
setInvalid
)
// get the set of characters group.
func getSetType(c byte) int {
if (c >= 44 && c <= ':') || c == '?' {
return setD
} else if c == 39 || c == '(' || c == ')' {
return setD
} else if c >= 'A' && c <= 'Z' {
return setD
} else if c >= 'a' && c <= 'z' {
return setD
} else if c == '+' || c == '\\' {
return setInvalid
} else if c > ' ' && c < '~' {
return setO
} else if c == ' ' || c == '\t' ||
c == '\r' || c == '\n' {
return setRule3
}
return setInvalid
}
// Check if can represent by themselves.
func canSelf(c byte) bool {
t := getSetType(c)
if t == setInvalid {
return false
}
return true
}
// UTF7EncodeBytes converts a byte slice from UTF-8 encoding to modified UTF-7.
func UTF7EncodeBytes(s []byte) []byte {
input := bytes.NewReader(s)
reader := transform.NewReader(input, UTF7.NewEncoder())
output, err := ioutil.ReadAll(reader)
if err != nil {
return nil
}
return output
}
// utf7enc converts string s from UTF-8 to UTF-16-BE, encodes the result as
// Base64, removes the padding, and adds UTF-7 shifts.
func utf7enc(s []byte) []byte {
// len(s) is sufficient for UTF-8 to UTF-16 conversion if there are no
// control code points (see table below).
b := make([]byte, 0, len(s)+4)
for len(s) > 0 {
r, size := utf8.DecodeRune(s)
if r > utf8.MaxRune {
r, size = utf8.RuneError, 1 // Bug fix (issue 3785)
}
s = s[size:]
if r1, r2 := utf16.EncodeRune(r); r1 != uRepl {
//log.Println("surrogate triggered")
b = append(b, byte(r1>>8), byte(r1))
r = r2
}
b = append(b, byte(r>>8), byte(r))
}
// Encode as Base64
//n := u7enc.EncodedLen(len(b)) + 2 // plus 2 for prefix '+' and suffix '-'
n := u7enc.EncodedLen(len(b)) + 1 // plus for prefix '+'
b64 := make([]byte, n)
u7enc.Encode(b64[1:], b)
// Strip padding
n -= 2 - (len(b)+2)%3
b64 = b64[:n]
// Add UTF-7 shifts
b64[0] = '+'
//b64[n-1] = '-'
return b64
}
// UTF7Decode converts a string from modified UTF-7 encoding to UTF-8.
func UTF7Decode(u string) (s string, err error) {
b, err := UTF7DecodeBytes([]byte(u))
s = string(b)
return
}
// UTF7DecodeBytes converts a byte slice from modified UTF-7 encoding to UTF-8.
func UTF7DecodeBytes(u []byte) ([]byte, error) {
input := bytes.NewReader([]byte(u))
reader := transform.NewReader(input, UTF7.NewDecoder())
output, err := ioutil.ReadAll(reader)
if err != nil {
return nil, err
}
return output, nil
}
// utf7dec extracts UTF-16-BE bytes from Base64 data and converts them to UTF-8.
// A nil slice is returned if the encoding is invalid.
func utf7dec(b64 []byte) []byte {
var b []byte
// Allocate a single block of memory large enough to store the Base64 data
// (if padding is required), UTF-16-BE bytes, and decoded UTF-8 bytes.
// Since a 2-byte UTF-16 sequence may expand into a 3-byte UTF-8 sequence,
// double the space allocation for UTF-8.
if n := len(b64); b64[n-1] == '=' {
return nil
} else if n&3 == 0 {
b = make([]byte, u7enc.DecodedLen(n)*3)
} else {
n += 4 - n&3
b = make([]byte, n+u7enc.DecodedLen(n)*3)
copy(b[copy(b, b64):n], []byte("=="))
b64, b = b[:n], b[n:]
}
// Decode Base64 into the first 1/3rd of b
n, err := u7enc.Decode(b, b64)
if err != nil || n&1 == 1 {
return nil
}
// Decode UTF-16-BE into the remaining 2/3rds of b
b, s := b[:n], b[n:]
j := 0
for i := 0; i < n; i += 2 {
r := rune(b[i])<<8 | rune(b[i+1])
if utf16.IsSurrogate(r) {
if i += 2; i == n {
//log.Println("surrogate error1!")
return nil
}
r2 := rune(b[i])<<8 | rune(b[i+1])
//log.Printf("surrogate! 0x%04X 0x%04X\n", r, r2)
if r = utf16.DecodeRune(r, r2); r == uRepl {
return nil
}
}
j += utf8.EncodeRune(s[j:], r)
}
return s[:j]
}
/*
The following table shows the number of bytes required to encode each code point
in the specified range using UTF-8 and UTF-16 representations:
+-----------------+-------+--------+
| Code points | UTF-8 | UTF-16 |
+-----------------+-------+--------+
| 000000 - 00007F | 1 | 2 |
| 000080 - 0007FF | 2 | 2 |
| 000800 - 00FFFF | 3 | 2 |
| 010000 - 10FFFF | 4 | 4 |
+-----------------+-------+--------+
Source: http://en.wikipedia.org/wiki/Comparison_of_Unicode_encodings
*/

21
vendor/github.com/emersion/go-msgauth/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 emersion
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

204
vendor/github.com/emersion/go-msgauth/dkim/canonical.go generated vendored Normal file
View File

@@ -0,0 +1,204 @@
package dkim
import (
"io"
"regexp"
"strings"
)
var rxReduceWS = regexp.MustCompile(`[ \t\r\n]+`)
// Canonicalization is a canonicalization algorithm.
type Canonicalization string
const (
CanonicalizationSimple Canonicalization = "simple"
CanonicalizationRelaxed = "relaxed"
)
type canonicalizer interface {
CanonicalizeHeader(s string) string
CanonicalizeBody(w io.Writer) io.WriteCloser
}
var canonicalizers = map[Canonicalization]canonicalizer{
CanonicalizationSimple: new(simpleCanonicalizer),
CanonicalizationRelaxed: new(relaxedCanonicalizer),
}
// crlfFixer fixes any lone LF without a preceding CR.
type crlfFixer struct {
cr bool
}
func (cf *crlfFixer) Fix(b []byte) []byte {
res := make([]byte, 0, len(b))
for _, ch := range b {
prevCR := cf.cr
cf.cr = false
switch ch {
case '\r':
cf.cr = true
case '\n':
if !prevCR {
res = append(res, '\r')
}
}
res = append(res, ch)
}
return res
}
type simpleCanonicalizer struct{}
func (c *simpleCanonicalizer) CanonicalizeHeader(s string) string {
return s
}
type simpleBodyCanonicalizer struct {
w io.Writer
crlfBuf []byte
crlfFixer crlfFixer
}
func (c *simpleBodyCanonicalizer) Write(b []byte) (int, error) {
written := len(b)
b = append(c.crlfBuf, b...)
b = c.crlfFixer.Fix(b)
end := len(b)
// If it ends with \r, maybe the next write will begin with \n
if end > 0 && b[end-1] == '\r' {
end--
}
// Keep all \r\n sequences
for end >= 2 {
prev := b[end-2]
cur := b[end-1]
if prev != '\r' || cur != '\n' {
break
}
end -= 2
}
c.crlfBuf = b[end:]
var err error
if end > 0 {
_, err = c.w.Write(b[:end])
}
return written, err
}
func (c *simpleBodyCanonicalizer) Close() error {
// Flush crlfBuf if it ends with a single \r (without a matching \n)
if len(c.crlfBuf) > 0 && c.crlfBuf[len(c.crlfBuf)-1] == '\r' {
if _, err := c.w.Write(c.crlfBuf); err != nil {
return err
}
}
c.crlfBuf = nil
if _, err := c.w.Write([]byte(crlf)); err != nil {
return err
}
return nil
}
func (c *simpleCanonicalizer) CanonicalizeBody(w io.Writer) io.WriteCloser {
return &simpleBodyCanonicalizer{w: w}
}
type relaxedCanonicalizer struct{}
func (c *relaxedCanonicalizer) CanonicalizeHeader(s string) string {
kv := strings.SplitN(s, ":", 2)
k := strings.TrimSpace(strings.ToLower(kv[0]))
var v string
if len(kv) > 1 {
v = rxReduceWS.ReplaceAllString(kv[1], " ")
v = strings.TrimSpace(v)
}
return k + ":" + v + crlf
}
type relaxedBodyCanonicalizer struct {
w io.Writer
crlfBuf []byte
wsp bool
written bool
crlfFixer crlfFixer
}
func (c *relaxedBodyCanonicalizer) Write(b []byte) (int, error) {
written := len(b)
b = c.crlfFixer.Fix(b)
canonical := make([]byte, 0, len(b))
for _, ch := range b {
if ch == ' ' || ch == '\t' {
c.wsp = true
} else if ch == '\r' || ch == '\n' {
c.wsp = false
c.crlfBuf = append(c.crlfBuf, ch)
} else {
if len(c.crlfBuf) > 0 {
canonical = append(canonical, c.crlfBuf...)
c.crlfBuf = c.crlfBuf[:0]
}
if c.wsp {
canonical = append(canonical, ' ')
c.wsp = false
}
canonical = append(canonical, ch)
}
}
if !c.written && len(canonical) > 0 {
c.written = true
}
_, err := c.w.Write(canonical)
return written, err
}
func (c *relaxedBodyCanonicalizer) Close() error {
if c.written {
if _, err := c.w.Write([]byte(crlf)); err != nil {
return err
}
}
return nil
}
func (c *relaxedCanonicalizer) CanonicalizeBody(w io.Writer) io.WriteCloser {
return &relaxedBodyCanonicalizer{w: w}
}
type limitedWriter struct {
W io.Writer
N int64
}
func (w *limitedWriter) Write(b []byte) (int, error) {
if w.N <= 0 {
return len(b), nil
}
skipped := 0
if int64(len(b)) > w.N {
b = b[:w.N]
skipped = int(int64(len(b)) - w.N)
}
n, err := w.W.Write(b)
w.N -= int64(n)
return n + skipped, err
}

10
vendor/github.com/emersion/go-msgauth/dkim/dkim.go generated vendored Normal file
View File

@@ -0,0 +1,10 @@
// Package dkim creates and verifies DKIM signatures, as specified in RFC 6376.
package dkim
import (
"time"
)
var now = time.Now
const headerFieldName = "DKIM-Signature"

169
vendor/github.com/emersion/go-msgauth/dkim/header.go generated vendored Normal file
View File

@@ -0,0 +1,169 @@
package dkim
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"net/textproto"
"sort"
"strings"
)
const crlf = "\r\n"
type header []string
func readHeader(r *bufio.Reader) (header, error) {
tr := textproto.NewReader(r)
var h header
for {
l, err := tr.ReadLine()
if err != nil {
return h, fmt.Errorf("failed to read header: %v", err)
}
if len(l) == 0 {
break
} else if len(h) > 0 && (l[0] == ' ' || l[0] == '\t') {
// This is a continuation line
h[len(h)-1] += l + crlf
} else {
h = append(h, l+crlf)
}
}
return h, nil
}
func writeHeader(w io.Writer, h header) error {
for _, kv := range h {
if _, err := w.Write([]byte(kv)); err != nil {
return err
}
}
_, err := w.Write([]byte(crlf))
return err
}
func foldHeaderField(kv string) string {
buf := bytes.NewBufferString(kv)
line := make([]byte, 75) // 78 - len("\r\n\s")
first := true
var fold strings.Builder
for len, err := buf.Read(line); err != io.EOF; len, err = buf.Read(line) {
if first {
first = false
} else {
fold.WriteString("\r\n ")
}
fold.Write(line[:len])
}
return fold.String() + crlf
}
func parseHeaderField(s string) (k string, v string) {
kv := strings.SplitN(s, ":", 2)
k = strings.TrimSpace(kv[0])
if len(kv) > 1 {
v = strings.TrimSpace(kv[1])
}
return
}
func parseHeaderParams(s string) (map[string]string, error) {
pairs := strings.Split(s, ";")
params := make(map[string]string)
for _, s := range pairs {
kv := strings.SplitN(s, "=", 2)
if len(kv) != 2 {
if strings.TrimSpace(s) == "" {
continue
}
return params, errors.New("dkim: malformed header params")
}
params[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
return params, nil
}
func formatHeaderParams(headerFieldName string, params map[string]string) string {
keys, bvalue, bfound := sortParams(params)
s := headerFieldName + ":"
var line string
for _, k := range keys {
v := params[k]
nextLength := 3 + len(line) + len(v) + len(k)
if nextLength > 75 {
s += line + crlf
line = ""
}
line = fmt.Sprintf("%v %v=%v;", line, k, v)
}
if line != "" {
s += line
}
if bfound {
bfiled := foldHeaderField(" b=" + bvalue)
s += crlf + bfiled
}
return s
}
func sortParams(params map[string]string) ([]string, string, bool) {
keys := make([]string, 0, len(params))
bfound := false
var bvalue string
for k := range params {
if k == "b" {
bvalue = params["b"]
bfound = true
} else {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys, bvalue, bfound
}
type headerPicker struct {
h header
picked map[string]int
}
func newHeaderPicker(h header) *headerPicker {
return &headerPicker{
h: h,
picked: make(map[string]int),
}
}
func (p *headerPicker) Pick(key string) string {
at := p.picked[key]
for i := len(p.h) - 1; i >= 0; i-- {
kv := p.h[i]
k, _ := parseHeaderField(kv)
if !strings.EqualFold(k, key) {
continue
}
if at == 0 {
p.picked[key]++
return kv
}
at--
}
return ""
}

177
vendor/github.com/emersion/go-msgauth/dkim/query.go generated vendored Normal file
View File

@@ -0,0 +1,177 @@
package dkim
import (
"crypto"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"net"
"strings"
"golang.org/x/crypto/ed25519"
)
type verifier interface {
Public() crypto.PublicKey
Verify(hash crypto.Hash, hashed []byte, sig []byte) error
}
type rsaVerifier struct {
*rsa.PublicKey
}
func (v rsaVerifier) Public() crypto.PublicKey {
return v.PublicKey
}
func (v rsaVerifier) Verify(hash crypto.Hash, hashed, sig []byte) error {
return rsa.VerifyPKCS1v15(v.PublicKey, hash, hashed, sig)
}
type ed25519Verifier struct {
ed25519.PublicKey
}
func (v ed25519Verifier) Public() crypto.PublicKey {
return v.PublicKey
}
func (v ed25519Verifier) Verify(hash crypto.Hash, hashed, sig []byte) error {
if !ed25519.Verify(v.PublicKey, hashed, sig) {
return errors.New("dkim: invalid Ed25519 signature")
}
return nil
}
type queryResult struct {
Verifier verifier
KeyAlgo string
HashAlgos []string
Notes string
Services []string
Flags []string
}
// QueryMethod is a DKIM query method.
type QueryMethod string
const (
// DNS TXT resource record (RR) lookup algorithm
QueryMethodDNSTXT QueryMethod = "dns/txt"
)
type txtLookupFunc func(domain string) ([]string, error)
type queryFunc func(domain, selector string, txtLookup txtLookupFunc) (*queryResult, error)
var queryMethods = map[QueryMethod]queryFunc{
QueryMethodDNSTXT: queryDNSTXT,
}
func queryDNSTXT(domain, selector string, txtLookup txtLookupFunc) (*queryResult, error) {
var txts []string
var err error
if txtLookup != nil {
txts, err = txtLookup(selector + "._domainkey." + domain)
} else {
txts, err = net.LookupTXT(selector + "._domainkey." + domain)
}
if netErr, ok := err.(net.Error); ok && netErr.Temporary() {
return nil, tempFailError("key unavailable: " + err.Error())
} else if err != nil {
return nil, permFailError("no key for signature: " + err.Error())
}
// Long keys are split in multiple parts
txt := strings.Join(txts, "")
return parsePublicKey(txt)
}
func parsePublicKey(s string) (*queryResult, error) {
params, err := parseHeaderParams(s)
if err != nil {
return nil, permFailError("key syntax error: " + err.Error())
}
res := new(queryResult)
if v, ok := params["v"]; ok && v != "DKIM1" {
return nil, permFailError("incompatible public key version")
}
p, ok := params["p"]
if !ok {
return nil, permFailError("key syntax error: missing public key data")
}
if p == "" {
return nil, permFailError("key revoked")
}
p = strings.ReplaceAll(p, " ", "")
b, err := base64.StdEncoding.DecodeString(p)
if err != nil {
return nil, permFailError("key syntax error: " + err.Error())
}
switch params["k"] {
case "rsa", "":
pub, err := x509.ParsePKIXPublicKey(b)
if err != nil {
// RFC 6376 is inconsistent about whether RSA public keys should
// be formatted as RSAPublicKey or SubjectPublicKeyInfo.
// Erratum 3017 (https://www.rfc-editor.org/errata/eid3017) proposes
// allowing both.
pub, err = x509.ParsePKCS1PublicKey(b)
if err != nil {
return nil, permFailError("key syntax error: " + err.Error())
}
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, permFailError("key syntax error: not an RSA public key")
}
// RFC 8301 section 3.2: verifiers MUST NOT consider signatures using
// RSA keys of less than 1024 bits as valid signatures.
if rsaPub.Size()*8 < 1024 {
return nil, permFailError(fmt.Sprintf("key is too short: want 1024 bits, has %v bits", rsaPub.Size()*8))
}
res.Verifier = rsaVerifier{rsaPub}
res.KeyAlgo = "rsa"
case "ed25519":
if len(b) != ed25519.PublicKeySize {
return nil, permFailError(fmt.Sprintf("invalid Ed25519 public key size: %v bytes", len(b)))
}
ed25519Pub := ed25519.PublicKey(b)
res.Verifier = ed25519Verifier{ed25519Pub}
res.KeyAlgo = "ed25519"
default:
return nil, permFailError("unsupported key algorithm")
}
if hashesStr, ok := params["h"]; ok {
res.HashAlgos = parseTagList(hashesStr)
}
if notes, ok := params["n"]; ok {
res.Notes = notes
}
if servicesStr, ok := params["s"]; ok {
services := parseTagList(servicesStr)
hasWildcard := false
for _, s := range services {
if s == "*" {
hasWildcard = true
break
}
}
if !hasWildcard {
res.Services = services
}
}
if flagsStr, ok := params["t"]; ok {
res.Flags = parseTagList(flagsStr)
}
return res, nil
}

346
vendor/github.com/emersion/go-msgauth/dkim/sign.go generated vendored Normal file
View File

@@ -0,0 +1,346 @@
package dkim
import (
"bufio"
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"io"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ed25519"
)
var randReader io.Reader = rand.Reader
// SignOptions is used to configure Sign. Domain, Selector and Signer are
// mandatory.
type SignOptions struct {
// The SDID claiming responsibility for an introduction of a message into the
// mail stream. Hence, the SDID value is used to form the query for the public
// key. The SDID MUST correspond to a valid DNS name under which the DKIM key
// record is published.
//
// This can't be empty.
Domain string
// The selector subdividing the namespace for the domain.
//
// This can't be empty.
Selector string
// The Agent or User Identifier (AUID) on behalf of which the SDID is taking
// responsibility.
//
// This is optional.
Identifier string
// The key used to sign the message.
//
// Supported Signer.Public() values are *rsa.PublicKey and
// ed25519.PublicKey.
Signer crypto.Signer
// The hash algorithm used to sign the message. If zero, a default hash will
// be chosen.
//
// The only supported hash algorithm is crypto.SHA256.
Hash crypto.Hash
// Header and body canonicalization algorithms.
//
// If empty, CanonicalizationSimple is used.
HeaderCanonicalization Canonicalization
BodyCanonicalization Canonicalization
// A list of header fields to include in the signature. If nil, all headers
// will be included. If not nil, "From" MUST be in the list.
//
// See RFC 6376 section 5.4.1 for recommended header fields.
HeaderKeys []string
// The expiration time. A zero value means no expiration.
Expiration time.Time
// A list of query methods used to retrieve the public key.
//
// If nil, it is implicitly defined as QueryMethodDNSTXT.
QueryMethods []QueryMethod
}
// Signer generates a DKIM signature.
//
// The whole message header and body must be written to the Signer. Close should
// always be called (either after the whole message has been written, or after
// an error occured and the signer won't be used anymore). Close may return an
// error in case signing fails.
//
// After a successful Close, Signature can be called to retrieve the
// DKIM-Signature header field that the caller should prepend to the message.
type Signer struct {
pw *io.PipeWriter
done <-chan error
sigParams map[string]string // only valid after done received nil
}
// NewSigner creates a new signer. It returns an error if SignOptions is
// invalid.
func NewSigner(options *SignOptions) (*Signer, error) {
if options == nil {
return nil, fmt.Errorf("dkim: no options specified")
}
if options.Domain == "" {
return nil, fmt.Errorf("dkim: no domain specified")
}
if options.Selector == "" {
return nil, fmt.Errorf("dkim: no selector specified")
}
if options.Signer == nil {
return nil, fmt.Errorf("dkim: no signer specified")
}
headerCan := options.HeaderCanonicalization
if headerCan == "" {
headerCan = CanonicalizationSimple
}
if _, ok := canonicalizers[headerCan]; !ok {
return nil, fmt.Errorf("dkim: unknown header canonicalization %q", headerCan)
}
bodyCan := options.BodyCanonicalization
if bodyCan == "" {
bodyCan = CanonicalizationSimple
}
if _, ok := canonicalizers[bodyCan]; !ok {
return nil, fmt.Errorf("dkim: unknown body canonicalization %q", bodyCan)
}
var keyAlgo string
switch options.Signer.Public().(type) {
case *rsa.PublicKey:
keyAlgo = "rsa"
case ed25519.PublicKey:
keyAlgo = "ed25519"
default:
return nil, fmt.Errorf("dkim: unsupported key algorithm %T", options.Signer.Public())
}
hash := options.Hash
var hashAlgo string
switch options.Hash {
case 0: // sha256 is the default
hash = crypto.SHA256
fallthrough
case crypto.SHA256:
hashAlgo = "sha256"
case crypto.SHA1:
return nil, fmt.Errorf("dkim: hash algorithm too weak: sha1")
default:
return nil, fmt.Errorf("dkim: unsupported hash algorithm")
}
if options.HeaderKeys != nil {
ok := false
for _, k := range options.HeaderKeys {
if strings.EqualFold(k, "From") {
ok = true
break
}
}
if !ok {
return nil, fmt.Errorf("dkim: the From header field must be signed")
}
}
done := make(chan error, 1)
pr, pw := io.Pipe()
s := &Signer{
pw: pw,
done: done,
}
closeReadWithError := func(err error) {
pr.CloseWithError(err)
done <- err
}
go func() {
defer close(done)
// Read header
br := bufio.NewReader(pr)
h, err := readHeader(br)
if err != nil {
closeReadWithError(err)
return
}
// Hash body
hasher := hash.New()
can := canonicalizers[bodyCan].CanonicalizeBody(hasher)
if _, err := io.Copy(can, br); err != nil {
closeReadWithError(err)
return
}
if err := can.Close(); err != nil {
closeReadWithError(err)
return
}
bodyHashed := hasher.Sum(nil)
params := map[string]string{
"v": "1",
"a": keyAlgo + "-" + hashAlgo,
"bh": base64.StdEncoding.EncodeToString(bodyHashed),
"c": string(headerCan) + "/" + string(bodyCan),
"d": options.Domain,
//"l": "", // TODO
"s": options.Selector,
"t": formatTime(now()),
//"z": "", // TODO
}
var headerKeys []string
if options.HeaderKeys != nil {
headerKeys = options.HeaderKeys
} else {
for _, kv := range h {
k, _ := parseHeaderField(kv)
headerKeys = append(headerKeys, k)
}
}
params["h"] = formatTagList(headerKeys)
if options.Identifier != "" {
params["i"] = options.Identifier
}
if options.QueryMethods != nil {
methods := make([]string, len(options.QueryMethods))
for i, method := range options.QueryMethods {
methods[i] = string(method)
}
params["q"] = formatTagList(methods)
}
if !options.Expiration.IsZero() {
params["x"] = formatTime(options.Expiration)
}
// Hash and sign headers
hasher.Reset()
picker := newHeaderPicker(h)
for _, k := range headerKeys {
kv := picker.Pick(k)
if kv == "" {
// The Signer MAY include more instances of a header field name
// in "h=" than there are actual corresponding header fields so
// that the signature will not verify if additional header
// fields of that name are added.
continue
}
kv = canonicalizers[headerCan].CanonicalizeHeader(kv)
if _, err := io.WriteString(hasher, kv); err != nil {
closeReadWithError(err)
return
}
}
params["b"] = ""
sigField := formatSignature(params)
sigField = canonicalizers[headerCan].CanonicalizeHeader(sigField)
sigField = strings.TrimRight(sigField, crlf)
if _, err := io.WriteString(hasher, sigField); err != nil {
closeReadWithError(err)
return
}
hashed := hasher.Sum(nil)
// Don't pass Hash to Sign for ed25519 as it doesn't support it
// and will return an error ("ed25519: cannot sign hashed message").
if keyAlgo == "ed25519" {
hash = crypto.Hash(0)
}
sig, err := options.Signer.Sign(randReader, hashed, hash)
if err != nil {
closeReadWithError(err)
return
}
params["b"] = base64.StdEncoding.EncodeToString(sig)
s.sigParams = params
closeReadWithError(nil)
}()
return s, nil
}
// Write implements io.WriteCloser.
func (s *Signer) Write(b []byte) (n int, err error) {
return s.pw.Write(b)
}
// Close implements io.WriteCloser. The error return by Close must be checked.
func (s *Signer) Close() error {
if err := s.pw.Close(); err != nil {
return err
}
return <-s.done
}
// Signature returns the whole DKIM-Signature header field. It can only be
// called after a successful Signer.Close call.
//
// The returned value contains both the header field name, its value and the
// final CRLF.
func (s *Signer) Signature() string {
if s.sigParams == nil {
panic("dkim: Signer.Signature must only be called after a succesful Signer.Close")
}
return formatSignature(s.sigParams)
}
// Sign signs a message. It reads it from r and writes the signed version to w.
func Sign(w io.Writer, r io.Reader, options *SignOptions) error {
s, err := NewSigner(options)
if err != nil {
return err
}
defer s.Close()
// We need to keep the message in a buffer so we can write the new DKIM
// header field before the rest of the message
var b bytes.Buffer
mw := io.MultiWriter(&b, s)
if _, err := io.Copy(mw, r); err != nil {
return err
}
if err := s.Close(); err != nil {
return err
}
if _, err := io.WriteString(w, s.Signature()); err != nil {
return err
}
_, err = io.Copy(w, &b)
return err
}
func formatSignature(params map[string]string) string {
sig := formatHeaderParams(headerFieldName, params)
return sig
}
func formatTagList(l []string) string {
return strings.Join(l, ":")
}
func formatTime(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}

462
vendor/github.com/emersion/go-msgauth/dkim/verify.go generated vendored Normal file
View File

@@ -0,0 +1,462 @@
package dkim
import (
"bufio"
"crypto"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"io"
"io/ioutil"
"regexp"
"strconv"
"strings"
"time"
"unicode"
)
type permFailError string
func (err permFailError) Error() string {
return "dkim: " + string(err)
}
// IsPermFail returns true if the error returned by Verify is a permanent
// failure. A permanent failure is for instance a missing required field or a
// malformed header.
func IsPermFail(err error) bool {
_, ok := err.(permFailError)
return ok
}
type tempFailError string
func (err tempFailError) Error() string {
return "dkim: " + string(err)
}
// IsTempFail returns true if the error returned by Verify is a temporary
// failure.
func IsTempFail(err error) bool {
_, ok := err.(tempFailError)
return ok
}
type failError string
func (err failError) Error() string {
return "dkim: " + string(err)
}
// isFail returns true if the error returned by Verify is a signature error.
func isFail(err error) bool {
_, ok := err.(failError)
return ok
}
// ErrTooManySignatures is returned by Verify when the message exceeds the
// maximum number of signatures.
var ErrTooManySignatures = errors.New("dkim: too many signatures")
var requiredTags = []string{"v", "a", "b", "bh", "d", "h", "s"}
// A Verification is produced by Verify when it checks if one signature is
// valid. If the signature is valid, Err is nil.
type Verification struct {
// The SDID claiming responsibility for an introduction of a message into the
// mail stream.
Domain string
// The Agent or User Identifier (AUID) on behalf of which the SDID is taking
// responsibility.
Identifier string
// The list of signed header fields.
HeaderKeys []string
// The time that this signature was created. If unknown, it's set to zero.
Time time.Time
// The expiration time. If the signature doesn't expire, it's set to zero.
Expiration time.Time
// Err is nil if the signature is valid.
Err error
}
type signature struct {
i int
v string
}
// VerifyOptions allows to customize the default signature verification
// behavior.
type VerifyOptions struct {
// LookupTXT returns the DNS TXT records for the given domain name. If nil,
// net.LookupTXT is used.
LookupTXT func(domain string) ([]string, error)
// MaxVerifications controls the maximum number of signature verifications
// to perform. If more signatures are present, the first MaxVerifications
// signatures are verified, the rest are ignored and ErrTooManySignatures
// is returned. If zero, there is no maximum.
MaxVerifications int
}
// Verify checks if a message's signatures are valid. It returns one
// verification per signature.
//
// There is no guarantee that the reader will be completely consumed.
func Verify(r io.Reader) ([]*Verification, error) {
return VerifyWithOptions(r, nil)
}
// VerifyWithOptions performs the same task as Verify, but allows specifying
// verification options.
func VerifyWithOptions(r io.Reader, options *VerifyOptions) ([]*Verification, error) {
// Read header
bufr := bufio.NewReader(r)
h, err := readHeader(bufr)
if err != nil {
return nil, err
}
// Scan header fields for signatures
var signatures []*signature
for i, kv := range h {
k, v := parseHeaderField(kv)
if strings.EqualFold(k, headerFieldName) {
signatures = append(signatures, &signature{i, v})
}
}
tooManySignatures := false
if options != nil && options.MaxVerifications > 0 && len(signatures) > options.MaxVerifications {
tooManySignatures = true
signatures = signatures[:options.MaxVerifications]
}
var verifs []*Verification
if len(signatures) == 1 {
// If there is only one signature - just verify it.
v, err := verify(h, bufr, h[signatures[0].i], signatures[0].v, options)
if err != nil && !IsTempFail(err) && !IsPermFail(err) && !isFail(err) {
return nil, err
}
v.Err = err
verifs = []*Verification{v}
} else {
verifs, err = parallelVerify(bufr, h, signatures, options)
if err != nil {
return nil, err
}
}
if tooManySignatures {
return verifs, ErrTooManySignatures
}
return verifs, nil
}
func parallelVerify(r io.Reader, h header, signatures []*signature, options *VerifyOptions) ([]*Verification, error) {
pipeWriters := make([]*io.PipeWriter, len(signatures))
// We can't pass pipeWriter to io.MultiWriter directly,
// we need a slice of io.Writer, but we also need *io.PipeWriter
// to call Close on it.
writers := make([]io.Writer, len(signatures))
chans := make([]chan *Verification, len(signatures))
for i, sig := range signatures {
// Be careful with loop variables and goroutines.
i, sig := i, sig
chans[i] = make(chan *Verification, 1)
pr, pw := io.Pipe()
writers[i] = pw
pipeWriters[i] = pw
go func() {
v, err := verify(h, pr, h[sig.i], sig.v, options)
// Make sure we consume the whole reader, otherwise io.Copy on
// other side can block forever.
io.Copy(ioutil.Discard, pr)
v.Err = err
chans[i] <- v
}()
}
if _, err := io.Copy(io.MultiWriter(writers...), r); err != nil {
return nil, err
}
for _, wr := range pipeWriters {
wr.Close()
}
verifications := make([]*Verification, len(signatures))
for i, ch := range chans {
verifications[i] = <-ch
}
// Return unexpected failures as a separate error.
for _, v := range verifications {
err := v.Err
if err != nil && !IsTempFail(err) && !IsPermFail(err) && !isFail(err) {
v.Err = nil
return verifications, err
}
}
return verifications, nil
}
func verify(h header, r io.Reader, sigField, sigValue string, options *VerifyOptions) (*Verification, error) {
verif := new(Verification)
params, err := parseHeaderParams(sigValue)
if err != nil {
return verif, permFailError("malformed signature tags: " + err.Error())
}
if params["v"] != "1" {
return verif, permFailError("incompatible signature version")
}
verif.Domain = stripWhitespace(params["d"])
for _, tag := range requiredTags {
if _, ok := params[tag]; !ok {
return verif, permFailError("signature missing required tag")
}
}
if i, ok := params["i"]; ok {
verif.Identifier = stripWhitespace(i)
if !strings.HasSuffix(verif.Identifier, "@"+verif.Domain) && !strings.HasSuffix(verif.Identifier, "."+verif.Domain) {
return verif, permFailError("domain mismatch")
}
} else {
verif.Identifier = "@" + verif.Domain
}
headerKeys := parseTagList(params["h"])
ok := false
for _, k := range headerKeys {
if strings.EqualFold(k, "from") {
ok = true
break
}
}
if !ok {
return verif, permFailError("From field not signed")
}
verif.HeaderKeys = headerKeys
if timeStr, ok := params["t"]; ok {
t, err := parseTime(timeStr)
if err != nil {
return verif, permFailError("malformed time: " + err.Error())
}
verif.Time = t
}
if expiresStr, ok := params["x"]; ok {
t, err := parseTime(expiresStr)
if err != nil {
return verif, permFailError("malformed expiration time: " + err.Error())
}
verif.Expiration = t
if now().After(t) {
return verif, permFailError("signature has expired")
}
}
// Query public key
// TODO: compute hash in parallel
methods := []string{string(QueryMethodDNSTXT)}
if methodsStr, ok := params["q"]; ok {
methods = parseTagList(methodsStr)
}
var res *queryResult
for _, method := range methods {
if query, ok := queryMethods[QueryMethod(method)]; ok {
if options != nil {
res, err = query(verif.Domain, stripWhitespace(params["s"]), options.LookupTXT)
} else {
res, err = query(verif.Domain, stripWhitespace(params["s"]), nil)
}
break
}
}
if err != nil {
return verif, err
} else if res == nil {
return verif, permFailError("unsupported public key query method")
}
// Parse algos
algos := strings.SplitN(stripWhitespace(params["a"]), "-", 2)
if len(algos) != 2 {
return verif, permFailError("malformed algorithm name")
}
keyAlgo := algos[0]
hashAlgo := algos[1]
// Check hash algo
if res.HashAlgos != nil {
ok := false
for _, algo := range res.HashAlgos {
if algo == hashAlgo {
ok = true
break
}
}
if !ok {
return verif, permFailError("inappropriate hash algorithm")
}
}
var hash crypto.Hash
switch hashAlgo {
case "sha1":
// RFC 8301 section 3.1: rsa-sha1 MUST NOT be used for signing or
// verifying.
return verif, permFailError(fmt.Sprintf("hash algorithm too weak: %v", hashAlgo))
case "sha256":
hash = crypto.SHA256
default:
return verif, permFailError("unsupported hash algorithm")
}
// Check key algo
if res.KeyAlgo != keyAlgo {
return verif, permFailError("inappropriate key algorithm")
}
if res.Services != nil {
ok := false
for _, s := range res.Services {
if s == "email" {
ok = true
break
}
}
if !ok {
return verif, permFailError("inappropriate service")
}
}
headerCan, bodyCan := parseCanonicalization(params["c"])
if _, ok := canonicalizers[headerCan]; !ok {
return verif, permFailError("unsupported header canonicalization algorithm")
}
if _, ok := canonicalizers[bodyCan]; !ok {
return verif, permFailError("unsupported body canonicalization algorithm")
}
// The body length "l" parameter is insecure, because it allows parts of
// the message body to not be signed. Reject messages which have it set.
if _, ok := params["l"]; ok {
// TODO: technically should be policyError
return verif, failError("message contains an insecure body length tag")
}
// Parse body hash and signature
bodyHashed, err := decodeBase64String(params["bh"])
if err != nil {
return verif, permFailError("malformed body hash: " + err.Error())
}
sig, err := decodeBase64String(params["b"])
if err != nil {
return verif, permFailError("malformed signature: " + err.Error())
}
// Check body hash
hasher := hash.New()
wc := canonicalizers[bodyCan].CanonicalizeBody(hasher)
if _, err := io.Copy(wc, r); err != nil {
return verif, err
}
if err := wc.Close(); err != nil {
return verif, err
}
if subtle.ConstantTimeCompare(hasher.Sum(nil), bodyHashed) != 1 {
return verif, failError("body hash did not verify")
}
// Compute data hash
hasher.Reset()
picker := newHeaderPicker(h)
for _, key := range headerKeys {
kv := picker.Pick(key)
if kv == "" {
// The field MAY contain names of header fields that do not exist
// when signed; nonexistent header fields do not contribute to the
// signature computation
continue
}
kv = canonicalizers[headerCan].CanonicalizeHeader(kv)
if _, err := hasher.Write([]byte(kv)); err != nil {
return verif, err
}
}
canSigField := removeSignature(sigField)
canSigField = canonicalizers[headerCan].CanonicalizeHeader(canSigField)
canSigField = strings.TrimRight(canSigField, "\r\n")
if _, err := hasher.Write([]byte(canSigField)); err != nil {
return verif, err
}
hashed := hasher.Sum(nil)
// Check signature
if err := res.Verifier.Verify(hash, hashed, sig); err != nil {
return verif, failError("signature did not verify: " + err.Error())
}
return verif, nil
}
func parseTagList(s string) []string {
tags := strings.Split(s, ":")
for i, t := range tags {
tags[i] = stripWhitespace(t)
}
return tags
}
func parseCanonicalization(s string) (headerCan, bodyCan Canonicalization) {
headerCan = CanonicalizationSimple
bodyCan = CanonicalizationSimple
cans := strings.SplitN(stripWhitespace(s), "/", 2)
if cans[0] != "" {
headerCan = Canonicalization(cans[0])
}
if len(cans) > 1 {
bodyCan = Canonicalization(cans[1])
}
return
}
func parseTime(s string) (time.Time, error) {
sec, err := strconv.ParseInt(stripWhitespace(s), 10, 64)
if err != nil {
return time.Time{}, err
}
return time.Unix(sec, 0), nil
}
func decodeBase64String(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(stripWhitespace(s))
}
func stripWhitespace(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, s)
}
func removeSignature(s string) string {
return regexp.MustCompile(`(b\s*=)[^;]+`).ReplaceAllString(s, "$1")
}

19
vendor/github.com/emersion/go-sasl/.build.yml generated vendored Normal file
View File

@@ -0,0 +1,19 @@
image: alpine/latest
packages:
- go
# Required by codecov
- bash
- findutils
sources:
- https://github.com/emersion/go-sasl
tasks:
- build: |
cd go-sasl
go build -v ./...
- test: |
cd go-sasl
go test -coverprofile=coverage.txt -covermode=atomic ./...
- upload-coverage: |
cd go-sasl
export CODECOV_TOKEN=3f257f71-a128-4834-8f68-2b534e9f4cb1
curl -s https://codecov.io/bash | bash

24
vendor/github.com/emersion/go-sasl/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,24 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof

21
vendor/github.com/emersion/go-sasl/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 emersion
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

17
vendor/github.com/emersion/go-sasl/README.md generated vendored Normal file
View File

@@ -0,0 +1,17 @@
# go-sasl
[![GoDoc](https://godoc.org/github.com/emersion/go-sasl?status.svg)](https://godoc.org/github.com/emersion/go-sasl)
[![Build Status](https://travis-ci.org/emersion/go-sasl.svg?branch=master)](https://travis-ci.org/emersion/go-sasl)
A [SASL](https://tools.ietf.org/html/rfc4422) library written in Go.
Implemented mechanisms:
* [ANONYMOUS](https://tools.ietf.org/html/rfc4505)
* [EXTERNAL](https://tools.ietf.org/html/rfc4422#appendix-A)
* [LOGIN](https://tools.ietf.org/html/draft-murchison-sasl-login-00) (obsolete, use PLAIN instead)
* [PLAIN](https://tools.ietf.org/html/rfc4616)
* [OAUTHBEARER](https://tools.ietf.org/html/rfc7628)
## License
MIT

56
vendor/github.com/emersion/go-sasl/anonymous.go generated vendored Normal file
View File

@@ -0,0 +1,56 @@
package sasl
// The ANONYMOUS mechanism name.
const Anonymous = "ANONYMOUS"
type anonymousClient struct {
Trace string
}
func (c *anonymousClient) Start() (mech string, ir []byte, err error) {
mech = Anonymous
ir = []byte(c.Trace)
return
}
func (c *anonymousClient) Next(challenge []byte) (response []byte, err error) {
return nil, ErrUnexpectedServerChallenge
}
// A client implementation of the ANONYMOUS authentication mechanism, as
// described in RFC 4505.
func NewAnonymousClient(trace string) Client {
return &anonymousClient{trace}
}
// Get trace information from clients logging in anonymously.
type AnonymousAuthenticator func(trace string) error
type anonymousServer struct {
done bool
authenticate AnonymousAuthenticator
}
func (s *anonymousServer) Next(response []byte) (challenge []byte, done bool, err error) {
if s.done {
err = ErrUnexpectedClientResponse
return
}
// No initial response, send an empty challenge
if response == nil {
return []byte{}, false, nil
}
s.done = true
err = s.authenticate(string(response))
done = true
return
}
// A server implementation of the ANONYMOUS authentication mechanism, as
// described in RFC 4505.
func NewAnonymousServer(authenticator AnonymousAuthenticator) Server {
return &anonymousServer{authenticate: authenticator}
}

26
vendor/github.com/emersion/go-sasl/external.go generated vendored Normal file
View File

@@ -0,0 +1,26 @@
package sasl
// The EXTERNAL mechanism name.
const External = "EXTERNAL"
type externalClient struct {
Identity string
}
func (a *externalClient) Start() (mech string, ir []byte, err error) {
mech = External
ir = []byte(a.Identity)
return
}
func (a *externalClient) Next(challenge []byte) (response []byte, err error) {
return nil, ErrUnexpectedServerChallenge
}
// An implementation of the EXTERNAL authentication mechanism, as described in
// RFC 4422. Authorization identity may be left blank to indicate that the
// client is requesting to act as the identity associated with the
// authentication credentials.
func NewExternalClient(identity string) Client {
return &externalClient{identity}
}

89
vendor/github.com/emersion/go-sasl/login.go generated vendored Normal file
View File

@@ -0,0 +1,89 @@
package sasl
import (
"bytes"
)
// The LOGIN mechanism name.
const Login = "LOGIN"
var expectedChallenge = []byte("Password:")
type loginClient struct {
Username string
Password string
}
func (a *loginClient) Start() (mech string, ir []byte, err error) {
mech = "LOGIN"
ir = []byte(a.Username)
return
}
func (a *loginClient) Next(challenge []byte) (response []byte, err error) {
if bytes.Compare(challenge, expectedChallenge) != 0 {
return nil, ErrUnexpectedServerChallenge
} else {
return []byte(a.Password), nil
}
}
// A client implementation of the LOGIN authentication mechanism for SMTP,
// as described in http://www.iana.org/go/draft-murchison-sasl-login
//
// It is considered obsolete, and should not be used when other mechanisms are
// available. For plaintext password authentication use PLAIN mechanism.
func NewLoginClient(username, password string) Client {
return &loginClient{username, password}
}
// Authenticates users with an username and a password.
type LoginAuthenticator func(username, password string) error
type loginState int
const (
loginNotStarted loginState = iota
loginWaitingUsername
loginWaitingPassword
)
type loginServer struct {
state loginState
username, password string
authenticate LoginAuthenticator
}
// A server implementation of the LOGIN authentication mechanism, as described
// in https://tools.ietf.org/html/draft-murchison-sasl-login-00.
//
// LOGIN is obsolete and should only be enabled for legacy clients that cannot
// be updated to use PLAIN.
func NewLoginServer(authenticator LoginAuthenticator) Server {
return &loginServer{authenticate: authenticator}
}
func (a *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
switch a.state {
case loginNotStarted:
// Check for initial response field, as per RFC4422 section 3
if response == nil {
challenge = []byte("Username:")
break
}
a.state++
fallthrough
case loginWaitingUsername:
a.username = string(response)
challenge = []byte("Password:")
case loginWaitingPassword:
a.password = string(response)
err = a.authenticate(a.username, a.password)
done = true
default:
err = ErrUnexpectedClientResponse
}
a.state++
return
}

191
vendor/github.com/emersion/go-sasl/oauthbearer.go generated vendored Normal file
View File

@@ -0,0 +1,191 @@
package sasl
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
// The OAUTHBEARER mechanism name.
const OAuthBearer = "OAUTHBEARER"
type OAuthBearerError struct {
Status string `json:"status"`
Schemes string `json:"schemes"`
Scope string `json:"scope"`
}
type OAuthBearerOptions struct {
Username string
Token string
Host string
Port int
}
// Implements error
func (err *OAuthBearerError) Error() string {
return fmt.Sprintf("OAUTHBEARER authentication error (%v)", err.Status)
}
type oauthBearerClient struct {
OAuthBearerOptions
}
func (a *oauthBearerClient) Start() (mech string, ir []byte, err error) {
mech = OAuthBearer
var str = "n,a=" + a.Username + ","
if a.Host != "" {
str += "\x01host=" + a.Host
}
if a.Port != 0 {
str += "\x01port=" + strconv.Itoa(a.Port)
}
str += "\x01auth=Bearer " + a.Token + "\x01\x01"
ir = []byte(str)
return
}
func (a *oauthBearerClient) Next(challenge []byte) ([]byte, error) {
authBearerErr := &OAuthBearerError{}
if err := json.Unmarshal(challenge, authBearerErr); err != nil {
return nil, err
} else {
return nil, authBearerErr
}
}
// An implementation of the OAUTHBEARER authentication mechanism, as
// described in RFC 7628.
func NewOAuthBearerClient(opt *OAuthBearerOptions) Client {
return &oauthBearerClient{*opt}
}
type OAuthBearerAuthenticator func(opts OAuthBearerOptions) *OAuthBearerError
type oauthBearerServer struct {
done bool
failErr error
authenticate OAuthBearerAuthenticator
}
func (a *oauthBearerServer) fail(descr string) ([]byte, bool, error) {
blob, err := json.Marshal(OAuthBearerError{
Status: "invalid_request",
Schemes: "bearer",
})
if err != nil {
panic(err) // wtf
}
a.failErr = errors.New(descr)
return blob, false, nil
}
func (a *oauthBearerServer) Next(response []byte) (challenge []byte, done bool, err error) {
// Per RFC, we cannot just send an error, we need to return JSON-structured
// value as a challenge and then after getting dummy response from the
// client stop the exchange.
if a.failErr != nil {
// Server libraries (go-smtp, go-imap) will not call Next on
// protocol-specific SASL cancel response ('*'). However, GS2 (and
// indirectly OAUTHBEARER) defines a protocol-independent way to do so
// using 0x01.
if len(response) != 1 && response[0] != 0x01 {
return nil, true, errors.New("unexpected response")
}
return nil, true, a.failErr
}
if a.done {
err = ErrUnexpectedClientResponse
return
}
// Generate empty challenge.
if response == nil {
return []byte{}, false, nil
}
a.done = true
// Cut n,a=username,\x01host=...\x01auth=...
// into
// n
// a=username
// \x01host=...\x01auth=...\x01\x01
parts := bytes.SplitN(response, []byte{','}, 3)
if len(parts) != 3 {
return a.fail("Invalid response")
}
if !bytes.Equal(parts[0], []byte{'n'}) {
return a.fail("Invalid response, missing 'n'")
}
opts := OAuthBearerOptions{}
if !bytes.HasPrefix(parts[1], []byte("a=")) {
return a.fail("Invalid response, missing 'a'")
}
opts.Username = string(bytes.TrimPrefix(parts[1], []byte("a=")))
// Cut \x01host=...\x01auth=...\x01\x01
// into
// *empty*
// host=...
// auth=...
// *empty*
//
// Note that this code does not do a lot of checks to make sure the input
// follows the exact format specified by RFC.
params := bytes.Split(parts[2], []byte{0x01})
for _, p := range params {
// Skip empty fields (one at start and end).
if len(p) == 0 {
continue
}
pParts := bytes.SplitN(p, []byte{'='}, 2)
if len(pParts) != 2 {
return a.fail("Invalid response, missing '='")
}
switch string(pParts[0]) {
case "host":
opts.Host = string(pParts[1])
case "port":
port, err := strconv.ParseUint(string(pParts[1]), 10, 16)
if err != nil {
return a.fail("Invalid response, malformed 'port' value")
}
opts.Port = int(port)
case "auth":
const prefix = "bearer "
strValue := string(pParts[1])
// Token type is case-insensitive.
if !strings.HasPrefix(strings.ToLower(strValue), prefix) {
return a.fail("Unsupported token type")
}
opts.Token = strValue[len(prefix):]
default:
return a.fail("Invalid response, unknown parameter: " + string(pParts[0]))
}
}
authzErr := a.authenticate(opts)
if authzErr != nil {
blob, err := json.Marshal(authzErr)
if err != nil {
panic(err) // wtf
}
a.failErr = authzErr
return blob, false, nil
}
return nil, true, nil
}
func NewOAuthBearerServer(auth OAuthBearerAuthenticator) Server {
return &oauthBearerServer{authenticate: auth}
}

77
vendor/github.com/emersion/go-sasl/plain.go generated vendored Normal file
View File

@@ -0,0 +1,77 @@
package sasl
import (
"bytes"
"errors"
)
// The PLAIN mechanism name.
const Plain = "PLAIN"
type plainClient struct {
Identity string
Username string
Password string
}
func (a *plainClient) Start() (mech string, ir []byte, err error) {
mech = "PLAIN"
ir = []byte(a.Identity + "\x00" + a.Username + "\x00" + a.Password)
return
}
func (a *plainClient) Next(challenge []byte) (response []byte, err error) {
return nil, ErrUnexpectedServerChallenge
}
// A client implementation of the PLAIN authentication mechanism, as described
// in RFC 4616. Authorization identity may be left blank to indicate that it is
// the same as the username.
func NewPlainClient(identity, username, password string) Client {
return &plainClient{identity, username, password}
}
// Authenticates users with an identity, a username and a password. If the
// identity is left blank, it indicates that it is the same as the username.
// If identity is not empty and the server doesn't support it, an error must be
// returned.
type PlainAuthenticator func(identity, username, password string) error
type plainServer struct {
done bool
authenticate PlainAuthenticator
}
func (a *plainServer) Next(response []byte) (challenge []byte, done bool, err error) {
if a.done {
err = ErrUnexpectedClientResponse
return
}
// No initial response, send an empty challenge
if response == nil {
return []byte{}, false, nil
}
a.done = true
parts := bytes.Split(response, []byte("\x00"))
if len(parts) != 3 {
err = errors.New("Invalid response")
return
}
identity := string(parts[0])
username := string(parts[1])
password := string(parts[2])
err = a.authenticate(identity, username, password)
done = true
return
}
// A server implementation of the PLAIN authentication mechanism, as described
// in RFC 4616.
func NewPlainServer(authenticator PlainAuthenticator) Server {
return &plainServer{authenticate: authenticator}
}

Some files were not shown because too many files have changed in this diff Show More