Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bebfa6df92 | ||
|
|
8bdd46fb32 | ||
|
|
da41bd31fb | ||
|
|
7fbb279830 | ||
|
|
816db6f409 | ||
|
|
e2f5f4c731 | ||
|
|
6be4891165 | ||
|
|
18f1113d33 | ||
|
|
b413e5871a | ||
|
|
8f3a74d46c | ||
|
|
480c99cf79 | ||
|
|
74defa85e4 | ||
|
|
3bb1f3ecba | ||
|
|
e3557f5522 | ||
|
|
c4576869ab | ||
|
|
8545ce80e4 | ||
|
|
60b4386dd8 | ||
|
|
e90925eceb | ||
|
|
f2432270e5 | ||
|
|
d71937a087 | ||
|
|
429645c3a9 | ||
|
|
f9d05d94c9 | ||
|
|
4c11919a46 | ||
|
|
2bdb8ca635 | ||
|
|
a6b20a75ab | ||
|
|
9bcc2d462f | ||
|
|
81c6d5abf1 | ||
|
|
ee8d8680ac | ||
|
|
84102d5b5b | ||
|
|
e8ade4173f | ||
|
|
321060d2d6 | ||
|
|
2879b10625 | ||
|
|
01b15b7ac4 | ||
|
|
3e0ecc1c02 | ||
|
|
19e2047a2b | ||
|
|
dbe4a73174 | ||
|
|
a7d5207484 | ||
|
|
0f7af734e5 | ||
|
|
7d0d8cd2e6 | ||
|
|
6d55ee40ed | ||
|
|
dc82d97aaa | ||
|
|
12d2fee2d4 | ||
|
|
ddf2460dbd | ||
|
|
3f1fd00fb6 | ||
|
|
ac9c27aa32 | ||
|
|
1e9558c1fc | ||
|
|
174930fc90 | ||
|
|
0559978fa2 | ||
|
|
f54b87c1f7 | ||
|
|
2ac6c64d13 | ||
|
|
fcd6110790 | ||
|
|
8d6c4aeafe | ||
|
|
14bad9f479 | ||
|
|
4a76a3269d | ||
|
|
351f0fca77 | ||
|
|
363ba313e0 | ||
|
|
3115373118 | ||
|
|
0701f8c9c3 | ||
|
|
b4d6d992ac | ||
|
|
21772d7360 | ||
|
|
a5edaaea78 | ||
|
|
6ddb894577 | ||
|
|
117736dcf3 | ||
|
|
bb7cf4aa7a | ||
|
|
8007f77535 | ||
|
|
ced98e818e | ||
|
|
9d25b9455f | ||
|
|
1bcf9bb050 | ||
|
|
128d2b595a | ||
|
|
8aac16aca8 | ||
|
|
5fe8603506 | ||
|
|
052fd5bb25 | ||
|
|
9e532a6007 | ||
|
|
ad83eab930 | ||
|
|
3ef6d2698e | ||
|
|
0f2683bcd0 | ||
|
|
e38d4b2fc5 | ||
|
|
2e712e0a67 | ||
|
|
aba1a6521d | ||
|
|
66bd1a4fab | ||
|
|
99a89ef87a | ||
|
|
225ba2ee9b | ||
|
|
fce6593cd7 | ||
|
|
7457f0436e | ||
|
|
8ebe80bc4f | ||
|
|
15b90e9e4c | ||
|
|
d0fa75b215 | ||
|
|
86cda29729 | ||
|
|
c1d33fe3cb | ||
|
|
14751cbf3a | ||
|
|
919ee46ba4 | ||
|
|
ebe9606aa9 | ||
|
|
f3be3aeabb | ||
|
|
24e9fb8a59 | ||
|
|
ec266e9108 | ||
|
|
7c59ff4b2e | ||
|
|
e7be9c6fad | ||
|
|
70cd8bd155 | ||
|
|
e68d419da4 | ||
|
|
4ef139f875 | ||
|
|
a8780a32c1 | ||
|
|
eb07bc1ac7 | ||
|
|
ce1599d8a3 | ||
|
|
d5f2a6b75f | ||
|
|
94b1d13eb7 | ||
|
|
b9cf336a6d | ||
|
|
519c44e998 | ||
|
|
29cd6c4dcb | ||
|
|
0c01987c93 | ||
|
|
f835a7560d | ||
|
|
19dec770b9 | ||
|
|
307aca7f23 | ||
|
|
e6722dd5e8 | ||
|
|
9cfe0a6d4f | ||
|
|
710e49f4cc | ||
|
|
15d5afe90f | ||
|
|
8954a7801a | ||
|
|
ebb648807d | ||
|
|
0e10f7caba | ||
|
|
2c47bc7e14 | ||
|
|
8e11c3da83 | ||
|
|
84af8f8e13 | ||
|
|
01e3dfc6cf | ||
|
|
b8c9d28324 | ||
|
|
45ff7597ed | ||
|
|
a1feaff350 | ||
|
|
a4ade439a6 | ||
|
|
841f5dfcfa | ||
|
|
4dd09dacb4 | ||
|
|
42cb5221a1 | ||
|
|
1bc4d1188c | ||
|
|
590182c272 | ||
|
|
b79fcceb3d | ||
|
|
8c2ed1b496 | ||
|
|
6f4da59387 | ||
|
|
7a438bd761 | ||
|
|
cae3ea04d0 | ||
|
|
4ec51b64eb | ||
|
|
c6049a7451 | ||
|
|
d575552237 | ||
|
|
1dd996e430 | ||
|
|
0767e7d0c3 | ||
|
|
99e509ea3a | ||
|
|
6f8e850103 | ||
|
|
70ef60c934 | ||
|
|
6598e884c4 | ||
|
|
d6b6a5dc44 | ||
|
|
4c6b7c2c1a | ||
|
|
267f5cb949 | ||
|
|
f3c5c47e76 | ||
|
|
8c2a383421 | ||
|
|
ed5765b42a | ||
|
|
f585e6ba06 |
@@ -4,25 +4,25 @@ stages:
|
||||
|
||||
lint:
|
||||
stage: test
|
||||
image: registry.gitlab.com/etke.cc/base
|
||||
image: registry.gitlab.com/etke.cc/base/build
|
||||
script:
|
||||
- make lint
|
||||
- just lint
|
||||
|
||||
unit:
|
||||
stage: test
|
||||
image: registry.gitlab.com/etke.cc/base
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
run:
|
||||
concurrency: 4
|
||||
timeout: 5m
|
||||
timeout: 30m
|
||||
issues-exit-code: 1
|
||||
tests: true
|
||||
build-tags: []
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,20 +1,16 @@
|
||||
FROM registry.gitlab.com/etke.cc/base AS builder
|
||||
FROM registry.gitlab.com/etke.cc/base/build AS builder
|
||||
|
||||
WORKDIR /postmoogle
|
||||
COPY . .
|
||||
RUN make build
|
||||
RUN just build
|
||||
|
||||
FROM alpine:latest
|
||||
FROM registry.gitlab.com/etke.cc/base/app
|
||||
|
||||
ENV POSTMOOGLE_DB_DSN /data/postmoogle.db
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata olm && \
|
||||
adduser -D -g '' postmoogle && \
|
||||
mkdir /data && chown -R postmoogle /data
|
||||
|
||||
COPY --from=builder /postmoogle/postmoogle /bin/postmoogle
|
||||
|
||||
USER postmoogle
|
||||
USER app
|
||||
|
||||
ENTRYPOINT ["/bin/postmoogle"]
|
||||
|
||||
|
||||
53
Makefile
53
Makefile
@@ -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/arm/v7,linux/arm64/v8,linux/amd64 --push -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} .
|
||||
294
README.md
294
README.md
@@ -13,27 +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
|
||||
- [ ] Reply to matrix thread sends reply into email thread
|
||||
- [x] Send a message to matrix room with special format to send a new email, even to multiple email addresses at once
|
||||
- [x] Reply to matrix thread sends reply into email thread
|
||||
- [x] Email signatures
|
||||
- [x] Email autoreply / autoresponder for new email threads
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -42,26 +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_NOENCRYPTION** - disable encryption support
|
||||
* **POSTMOOGLE_DATA_SECRET** - secure key (password) to encrypt account data, must be 16, 24, or 32 bytes long
|
||||
* **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)
|
||||
|
||||
@@ -69,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
|
||||
|
||||
@@ -232,29 +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 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 dkim** - Get DKIM signature
|
||||
* **!pm users** - Get or set allowed users patterns
|
||||
* **!pm mailboxes** - Show the list of all mailboxes
|
||||
* **!pm delete** <mailbox> - Delete specific mailbox
|
||||
#### 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)
|
||||
|
||||
---
|
||||
|
||||
#### 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>
|
||||
|
||||
|
||||
166
bot/access.go
166
bot/access.go
@@ -2,8 +2,10 @@ package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/raja/argon2pw"
|
||||
"gitlab.com/etke.cc/go/mxidwc"
|
||||
@@ -38,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(context.Background(), targetRoomID, "failed to retrieve settings: %v", err)
|
||||
b.Error(context.Background(), "failed to retrieve settings: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -61,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(context.Background(), 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
54
bot/activation.go
Normal 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
|
||||
}
|
||||
104
bot/bot.go
104
bot/bot.go
@@ -6,51 +6,68 @@ import (
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"git.sr.ht/~xn/cache/v2"
|
||||
"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
|
||||
botcfg cache.Cache[botSettings]
|
||||
cfg cache.Cache[roomSettings]
|
||||
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{},
|
||||
botcfg: cache.NewLRU[botSettings](1),
|
||||
cfg: cache.NewLRU[roomSettings](1000),
|
||||
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 {
|
||||
@@ -74,42 +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...)
|
||||
|
||||
sentry.GetHubFromContext(ctx).CaptureException(err)
|
||||
if roomID != "" {
|
||||
b.SendError(ctx, roomID, err.Error())
|
||||
b.log.Error().Err(err).Msg(err.Error())
|
||||
if evt == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -117,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()
|
||||
}
|
||||
|
||||
462
bot/command.go
462
bot/command.go
@@ -6,20 +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"
|
||||
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 (
|
||||
@@ -59,83 +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: roomOptionNoSubject,
|
||||
key: config.RoomNoRecipient,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - hide recipient; `false` - show recipient)",
|
||||
config.RoomNoRecipient,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
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.allowAdmin}, // delimiter
|
||||
{
|
||||
key: botOptionUsers,
|
||||
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: 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: 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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
@@ -144,6 +268,23 @@ func (b *Bot) initCommands() commandList {
|
||||
description: "Get DKIM signature",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: commandCatchAll,
|
||||
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",
|
||||
@@ -154,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
|
||||
}
|
||||
|
||||
@@ -182,10 +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:
|
||||
@@ -210,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")
|
||||
|
||||
@@ -219,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
|
||||
@@ -244,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("* **`")
|
||||
@@ -252,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"+
|
||||
@@ -294,37 +567,64 @@ func (b *Bot) runSend(ctx context.Context) {
|
||||
"on as many lines\n"+
|
||||
"as you want.\n"+
|
||||
"```",
|
||||
b.prefix))
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.AddressValid(to) {
|
||||
b.Error(ctx, evt.RoomID, "email address is not valid")
|
||||
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
|
||||
}
|
||||
|
||||
from := mailbox + "@" + b.domain
|
||||
ID := fmt.Sprintf("<%s@%s>", evt.ID, b.domain)
|
||||
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: %v", err)
|
||||
return
|
||||
signature := cfg.Signature()
|
||||
if signature != "" {
|
||||
body += "\n\n---\n" + signature
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "Email has been sent")
|
||||
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 !email.AddressValid(to) {
|
||||
b.Error(ctx, "email address is not valid")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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.lp.SendNotice(evt.RoomID, "All emails were sent.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,23 +149,373 @@ 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.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")
|
||||
}
|
||||
msg.WriteString("`\n\n")
|
||||
msg.WriteString("Usage: `")
|
||||
msg.WriteString(b.prefix)
|
||||
msg.WriteString(" catch-all MAILBOX`")
|
||||
msg.WriteString("where mailbox is valid and existing mailbox name\n")
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
mailbox := utils.Mailbox(commandSlice[1])
|
||||
_, ok := b.GetMapping(mailbox)
|
||||
if !ok {
|
||||
b.lp.SendNotice(evt.RoomID, "mailbox does not exist, kupo.", linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Set(config.BotCatchAll, mailbox)
|
||||
err := b.cfg.SetBot(cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot save bot options: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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
110
bot/config/bot.go
Normal 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
69
bot/config/lists.go
Normal 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
116
bot/config/manager.go
Normal 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
206
bot/config/room.go
Normal 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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
116
bot/data.go
116
bot/data.go
@@ -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 {
|
||||
b.log.Warn("cannot get %s settings: %v", roomID, err)
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
613
bot/email.go
613
bot/email.go
@@ -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,23 +23,62 @@ 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)
|
||||
}
|
||||
|
||||
// GetMapping returns mapping of mailbox = room
|
||||
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
|
||||
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) {
|
||||
v, ok := b.rooms.Load(mailbox)
|
||||
if !ok {
|
||||
return "", ok
|
||||
}
|
||||
|
||||
roomID, ok := v.(id.RoomID)
|
||||
if !ok {
|
||||
return "", ok
|
||||
@@ -45,204 +87,515 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
|
||||
return roomID, ok
|
||||
}
|
||||
|
||||
// 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))
|
||||
// GetMapping returns mapping of mailbox = room
|
||||
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
|
||||
roomID, ok := b.getMapping(mailbox)
|
||||
if !ok {
|
||||
catchAll := b.cfg.GetBot().CatchAll()
|
||||
if catchAll == "" {
|
||||
return roomID, ok
|
||||
}
|
||||
return b.getMapping(catchAll)
|
||||
}
|
||||
|
||||
return roomID, ok
|
||||
}
|
||||
|
||||
// GetIFOptions returns incoming email filtering options (room settings)
|
||||
func (b *Bot) GetIFOptions(roomID id.RoomID) email.IncomingFilteringOptions {
|
||||
cfg, err := b.cfg.GetRoom(roomID)
|
||||
if err != nil {
|
||||
b.log.Error().Err(err).Msg("cannot retrieve room settings")
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// 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()
|
||||
resp, err := b.lp.GetClient().UploadMedia(req)
|
||||
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)
|
||||
continue
|
||||
}
|
||||
_, err = b.lp.Send(roomID, &event.MessageEventContent{
|
||||
MsgType: file.MsgType,
|
||||
Body: req.FileName,
|
||||
URL: resp.ContentURI.CUString(),
|
||||
RelatesTo: utils.RelatesTo(!noThreads, parentID),
|
||||
})
|
||||
if err != nil {
|
||||
b.Error(ctx, roomID, "cannot send uploaded file %s: %v", req.FileName, err)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
26
bot/mutex.go
26
bot/mutex.go
@@ -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
79
bot/queue/manager.go
Normal 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
101
bot/queue/queue.go
Normal 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
44
bot/reaction.go
Normal 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)})
|
||||
}
|
||||
}
|
||||
@@ -1,96 +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"
|
||||
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}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
cfg := b.botcfg.Get(acBotSettingsKey)
|
||||
if cfg != nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
config := botSettings{}
|
||||
err := b.lp.GetClient().GetAccountData(acBotSettingsKey, &config)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "M_NOT_FOUND") {
|
||||
err = nil
|
||||
} else {
|
||||
b.log.Error("cannot get bot settings: %v", utils.UnwrapError(err))
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
b.botcfg.Set(acBotSettingsKey, config)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func (b *Bot) setBotSettings(cfg botSettings) error {
|
||||
b.botcfg.Set(acBotSettingsKey, cfg)
|
||||
return utils.UnwrapError(b.lp.GetClient().SetAccountData(acBotSettingsKey, cfg))
|
||||
}
|
||||
@@ -1,117 +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"
|
||||
roomOptionNoSubject = "nosubject"
|
||||
roomOptionNoHTML = "nohtml"
|
||||
roomOptionNoThreads = "nothreads"
|
||||
roomOptionNoFiles = "nofiles"
|
||||
roomOptionPassword = "password"
|
||||
)
|
||||
|
||||
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) 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))
|
||||
}
|
||||
|
||||
// ContentOptions converts room display settings to content options
|
||||
func (s roomSettings) ContentOptions() *utils.ContentOptions {
|
||||
return &utils.ContentOptions{
|
||||
HTML: !s.NoHTML(),
|
||||
Sender: !s.NoSender(),
|
||||
Subject: !s.NoSubject(),
|
||||
Threads: !s.NoThreads(),
|
||||
|
||||
FromKey: eventFromKey,
|
||||
SubjectKey: eventSubjectKey,
|
||||
MessageIDKey: eventMessageIDkey,
|
||||
InReplyToKey: eventInReplyToKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) getRoomSettings(roomID id.RoomID) (roomSettings, error) {
|
||||
cfg := b.cfg.Get(roomID.String())
|
||||
if cfg != nil {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
config := roomSettings{}
|
||||
err := b.lp.GetClient().GetRoomAccountData(roomID, acRoomSettingsKey, &config)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "M_NOT_FOUND") {
|
||||
// Suppress `M_NOT_FOUND (HTTP 404): Room account data not found` errors.
|
||||
// Until some settings are explicitly set, we don't store any.
|
||||
// In such cases, just return a default (empty) settings object.
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
b.cfg.Set(roomID.String(), config)
|
||||
}
|
||||
|
||||
return config, utils.UnwrapError(err)
|
||||
}
|
||||
|
||||
func (b *Bot) setRoomSettings(roomID id.RoomID, cfg roomSettings) error {
|
||||
b.cfg.Set(roomID.String(), cfg)
|
||||
return utils.UnwrapError(b.lp.GetClient().SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
|
||||
}
|
||||
52
bot/sync.go
52
bot/sync.go
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
159
cmd/cmd.go
159
cmd/cmd.go
@@ -2,112 +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{
|
||||
Homeserver: cfg.Homeserver,
|
||||
Login: cfg.Login,
|
||||
Password: cfg.Password,
|
||||
DB: db,
|
||||
Dialect: cfg.DB.Dialect,
|
||||
NoEncryption: cfg.NoEncryption,
|
||||
LPLogger: mxlog,
|
||||
APILogger: logger.New("api.", cfg.LogLevel),
|
||||
StoreLogger: logger.New("store.", cfg.LogLevel),
|
||||
CryptoLogger: logger.New("olm.", cfg.LogLevel),
|
||||
|
||||
lp, err := linkpearl.New(&linkpearl.Config{
|
||||
Homeserver: cfg.Homeserver,
|
||||
Login: cfg.Login,
|
||||
Password: cfg.Password,
|
||||
SharedSecret: cfg.SharedSecret,
|
||||
DB: db,
|
||||
Dialect: cfg.DB.Dialect,
|
||||
AccountDataSecret: cfg.DataSecret,
|
||||
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)
|
||||
@@ -121,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitlab.com/etke.cc/go/env"
|
||||
)
|
||||
|
||||
@@ -14,28 +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)...)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
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
|
||||
DataSecret string
|
||||
// NoEncryption disabled encryption support
|
||||
NoEncryption bool
|
||||
// Prefix for commands
|
||||
@@ -22,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
|
||||
|
||||
@@ -31,8 +41,10 @@ type Config struct {
|
||||
// TLS config
|
||||
TLS TLS
|
||||
|
||||
// Sentry config
|
||||
Sentry Sentry
|
||||
// Monitoring config
|
||||
Monitoring Monitoring
|
||||
|
||||
Relay Relay
|
||||
}
|
||||
|
||||
// DB config
|
||||
@@ -45,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
159
docs/dns.md
Normal 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
25
docs/mailboxes.md
Normal 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
42
docs/tricks.md
Normal 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.
|
||||
2
e2e/send
2
e2e/send
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
ssmtp -v test@localhost < $1
|
||||
ssmtp -v test+sub@localhost < $1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
249
email/email.go
Normal 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
31
email/options.go
Normal 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
66
email/utils.go
Normal 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)
|
||||
}
|
||||
50
go.mod
50
go.mod
@@ -2,50 +2,60 @@ module gitlab.com/etke.cc/postmoogle
|
||||
|
||||
go 1.18
|
||||
|
||||
// replace gitlab.com/etke.cc/linkpearl => ../linkpearl
|
||||
|
||||
require (
|
||||
git.sr.ht/~xn/cache/v2 v2.0.0
|
||||
github.com/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.6
|
||||
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/linkpearl v0.0.0-20220921080011-9407dc599571
|
||||
golang.org/x/net v0.0.0-20220920203100-d0c6ba3f52d9
|
||||
maunium.net/go/mautrix v0.12.1
|
||||
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/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.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/yuin/goldmark v1.4.13 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 // indirect
|
||||
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // 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
|
||||
)
|
||||
|
||||
102
go.sum
102
go.sum
@@ -1,9 +1,13 @@
|
||||
git.sr.ht/~xn/cache/v2 v2.0.0 h1:aYzwGDyVIzjCl2yqcxZjprnu++Q3BmUQeK2agqvcQt8=
|
||||
git.sr.ht/~xn/cache/v2 v2.0.0/go.mod h1:HIPSMiDudQ483tRDup586e0YZdwMySIZFWXMPwYMuV8=
|
||||
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=
|
||||
@@ -18,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=
|
||||
@@ -29,31 +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/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.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
|
||||
github.com/lib/pq v1.10.6/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,44 +76,56 @@ 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 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||
github.com/yuin/goldmark v1.4.13/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/linkpearl v0.0.0-20220921080011-9407dc599571 h1:ool1wnAnnIhZjwPMd0LUebpfxqXZcVhRli2UDhay0bA=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20220921080011-9407dc599571/go.mod h1:4qbyfbuJSj89jFW7F+YjIbYrwJTrALQf4Otw0KGkIWE=
|
||||
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-20220919173607-35f4265a4bc0 h1:a5Yg6ylndHHYJqIPrdq0AhvR6KTvDTAvgBtaidhEevY=
|
||||
golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/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-20220920203100-d0c6ba3f52d9 h1:asZqf0wXastQr+DudYagQS8uBO8bHKeYD1vbAvGmFL8=
|
||||
golang.org/x/net v0.0.0-20220920203100-d0c6ba3f52d9/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=
|
||||
@@ -111,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-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc=
|
||||
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/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.1 h1:MLfkWkpRIgUn7lueDSlPwYOeIuGF+NrAHW0hInIuVAw=
|
||||
maunium.net/go/mautrix v0.12.1/go.mod h1:/jxQFIipObSsjZPH6o3xyUi8uoULz3Hfr/8p9loqpYE=
|
||||
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
45
justfile
Normal 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
109
smtp/client.go
Normal 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
87
smtp/listener.go
Normal 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
44
smtp/logger.go
Normal 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
215
smtp/manager.go
Normal 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
|
||||
}
|
||||
48
smtp/msa.go
48
smtp/msa.go
@@ -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
|
||||
}
|
||||
@@ -1,104 +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/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)
|
||||
|
||||
if s.incoming {
|
||||
if utils.Hostname(to) != s.domain {
|
||||
s.log.Debug("wrong domain of %s", to)
|
||||
return smtp.ErrAuthRequired
|
||||
}
|
||||
|
||||
_, ok := s.bot.GetMapping(utils.Mailbox(to))
|
||||
if !ok {
|
||||
s.log.Debug("mapping for %s not found", to)
|
||||
return smtp.ErrAuthRequired
|
||||
}
|
||||
}
|
||||
|
||||
s.to = to
|
||||
s.log.Debug("mail to %s", to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *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) 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
|
||||
}
|
||||
133
smtp/mta.go
133
smtp/mta.go
@@ -1,133 +0,0 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// Bot interface to send emails into matrix
|
||||
type Bot interface {
|
||||
AllowAuth(string, string) bool
|
||||
GetMapping(string) (id.RoomID, bool)
|
||||
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
|
||||
}
|
||||
|
||||
// SMTPAddrs priority list
|
||||
var SMTPAddrs = []string{":25", ":587", ":465"}
|
||||
|
||||
func NewMTA(loglevel string) utils.MTA {
|
||||
return &mta{
|
||||
log: logger.New("smtp/mta.", loglevel),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mta) Send(from, to, data string) error {
|
||||
m.log.Debug("Sending email from %s to %s", from, to)
|
||||
conn, err := m.connect(from, to)
|
||||
if err != nil {
|
||||
m.log.Error("cannot connect to SMTP server of %s: %v", to, err)
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
err = conn.Mail(from)
|
||||
if err != nil {
|
||||
m.log.Error("cannot call MAIL command: %v", err)
|
||||
return err
|
||||
}
|
||||
err = conn.Rcpt(to)
|
||||
if err != nil {
|
||||
m.log.Error("cannot send RCPT command: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var w io.WriteCloser
|
||||
w, err = conn.Data()
|
||||
if err != nil {
|
||||
m.log.Error("cannot send DATA command: %v", err)
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
m.log.Debug("sending DATA:\n%s", data)
|
||||
_, err = strings.NewReader(data).WriteTo(w)
|
||||
if err != nil {
|
||||
m.log.Debug("cannot write DATA: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
m.log.Debug("email has been sent")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mta) tryServer(localname, mxhost, addr string) *smtp.Client {
|
||||
m.log.Debug("trying SMTP connection to %s%s", mxhost, addr)
|
||||
conn, err := smtp.Dial(mxhost + addr)
|
||||
if err != nil {
|
||||
m.log.Warn("cannot connect to the %s%s: %v", mxhost, addr, err)
|
||||
return nil
|
||||
}
|
||||
err = conn.Hello(localname)
|
||||
if err != nil {
|
||||
m.log.Warn("cannot call HELLO command of the %s%s: %v", mxhost, addr, err)
|
||||
return nil
|
||||
}
|
||||
if ok, _ := conn.Extension("STARTTLS"); ok {
|
||||
m.log.Debug("%s supports STARTTLS", mxhost)
|
||||
config := &tls.Config{ServerName: mxhost}
|
||||
err = conn.StartTLS(config)
|
||||
if err != nil {
|
||||
m.log.Warn("STARTTLS connection to the %s failed: %v", mxhost, err)
|
||||
}
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func (m *mta) connect(from, to string) (*smtp.Client, error) {
|
||||
localname := strings.SplitN(from, "@", 2)[1]
|
||||
hostname := strings.SplitN(to, "@", 2)[1]
|
||||
|
||||
m.log.Debug("performing MX lookup of %s", hostname)
|
||||
mxs, err := net.LookupMX(hostname)
|
||||
if err != nil {
|
||||
m.log.Error("cannot perform MX lookup: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, mx := range mxs {
|
||||
for _, addr := range SMTPAddrs {
|
||||
client := m.tryServer(localname, strings.TrimSuffix(mx.Host, "."), addr)
|
||||
if client != nil {
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no MX records, according to https://datatracker.ietf.org/doc/html/rfc5321#section-5.1,
|
||||
// we're supposed to try talking directly to the host.
|
||||
if len(mxs) == 0 {
|
||||
for _, addr := range SMTPAddrs {
|
||||
client := m.tryServer(localname, hostname, addr)
|
||||
if client != nil {
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("target SMTP server not found")
|
||||
}
|
||||
185
smtp/server.go
185
smtp/server.go
@@ -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
250
smtp/session.go
Normal 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)
|
||||
}
|
||||
@@ -10,12 +10,12 @@ var ErrInvalidArgs = fmt.Errorf("invalid arguments")
|
||||
|
||||
// ParseSend parses "!pm send" command, returns to, subject, body, err
|
||||
func ParseSend(commandSlice []string) (string, string, string, error) {
|
||||
if len(commandSlice) < 3 {
|
||||
message := strings.Join(commandSlice, " ")
|
||||
lines := strings.Split(message, "\n")
|
||||
if len(lines) < 3 {
|
||||
return "", "", "", ErrInvalidArgs
|
||||
}
|
||||
|
||||
message := strings.Join(commandSlice, " ")
|
||||
lines := strings.Split(message, "\n")
|
||||
commandSlice = strings.Split(lines[0], " ")
|
||||
to := commandSlice[1]
|
||||
subject := lines[1]
|
||||
|
||||
193
utils/email.go
193
utils/email.go
@@ -1,193 +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
|
||||
}
|
||||
|
||||
// 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
|
||||
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\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("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()
|
||||
}
|
||||
@@ -31,8 +31,8 @@ func NewFile(name string, content []byte) *File {
|
||||
return file
|
||||
}
|
||||
|
||||
func (f *File) Convert() mautrix.ReqUploadMedia {
|
||||
return mautrix.ReqUploadMedia{
|
||||
func (f *File) Convert() *mautrix.ReqUploadMedia {
|
||||
return &mautrix.ReqUploadMedia{
|
||||
ContentBytes: f.Content,
|
||||
Content: bytes.NewReader(f.Content),
|
||||
ContentLength: int64(f.Length),
|
||||
|
||||
@@ -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
78
utils/mail.go
Normal 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
70
utils/mail_test.go
Normal 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
32
utils/mutex.go
Normal 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)
|
||||
}
|
||||
131
utils/utils.go
131
utils/utils.go
@@ -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
|
||||
@@ -33,3 +54,93 @@ func Bool(str string) bool {
|
||||
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 == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
str = strings.TrimSpace(str)
|
||||
if strings.IndexByte(str, ',') == -1 {
|
||||
return []string{str}
|
||||
}
|
||||
|
||||
parts := strings.Split(str, ",")
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.TrimSpace(part)
|
||||
}
|
||||
|
||||
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
10
vendor/blitiri.com.ar/go/spf/.gitignore
generated
vendored
Normal 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
27
vendor/blitiri.com.ar/go/spf/LICENSE
generated
vendored
Normal 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
49
vendor/blitiri.com.ar/go/spf/README.md
generated
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
# blitiri.com.ar/go/spf
|
||||
|
||||
[](https://pkg.go.dev/blitiri.com.ar/go/spf)
|
||||
[](https://gitlab.com/albertito/spf/-/pipelines)
|
||||
[](https://goreportcard.com/report/github.com/albertito/spf)
|
||||
[](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
58
vendor/blitiri.com.ar/go/spf/fuzz.go
generated
vendored
Normal 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
111
vendor/blitiri.com.ar/go/spf/internal/dnstest/dns.go
generated
vendored
Normal 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
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
1
vendor/github.com/archdx/zerolog-sentry/.gitignore
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
cover.out
|
||||
20
vendor/github.com/archdx/zerolog-sentry/.golangci.yml
generated
vendored
Normal file
20
vendor/github.com/archdx/zerolog-sentry/.golangci.yml
generated
vendored
Normal 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
175
vendor/github.com/archdx/zerolog-sentry/LICENSE
generated
vendored
Normal 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
18
vendor/github.com/archdx/zerolog-sentry/Makefile
generated
vendored
Normal 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
30
vendor/github.com/archdx/zerolog-sentry/README.md
generated
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# zerolog-sentry
|
||||
[](https://github.com/archdx/zerolog-sentry/actions)
|
||||
[](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
260
vendor/github.com/archdx/zerolog-sentry/writer.go
generated
vendored
Normal 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
12
vendor/github.com/buger/jsonparser/.gitignore
generated
vendored
Normal 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
8
vendor/github.com/buger/jsonparser/.travis.yml
generated
vendored
Normal 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
12
vendor/github.com/buger/jsonparser/Dockerfile
generated
vendored
Normal 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
21
vendor/github.com/buger/jsonparser/LICENSE
generated
vendored
Normal 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
36
vendor/github.com/buger/jsonparser/Makefile
generated
vendored
Normal 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
365
vendor/github.com/buger/jsonparser/README.md
generated
vendored
Normal file
@@ -0,0 +1,365 @@
|
||||
[](https://goreportcard.com/report/github.com/buger/jsonparser) 
|
||||
# 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
47
vendor/github.com/buger/jsonparser/bytes.go
generated
vendored
Normal 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
25
vendor/github.com/buger/jsonparser/bytes_safe.go
generated
vendored
Normal 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
42
vendor/github.com/buger/jsonparser/bytes_unsafe.go
generated
vendored
Normal 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
173
vendor/github.com/buger/jsonparser/escape.go
generated
vendored
Normal 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
9
vendor/github.com/buger/jsonparser/fuzz.go
generated
vendored
Normal 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
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
12
vendor/github.com/cention-sany/utf7/.travis.yml
generated
vendored
Normal 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
29
vendor/github.com/cention-sany/utf7/LICENSE
generated
vendored
Normal 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
2
vendor/github.com/cention-sany/utf7/README.md
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# utf7 [](https://travis-ci.org/cention-sany/utf7) [](https://godoc.org/github.com/cention-sany/utf7) [](https://exago.io/project/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
518
vendor/github.com/cention-sany/utf7/utf7.go
generated
vendored
Normal 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+0020–U+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
21
vendor/github.com/emersion/go-msgauth/LICENSE
generated
vendored
Normal 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
204
vendor/github.com/emersion/go-msgauth/dkim/canonical.go
generated
vendored
Normal 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
10
vendor/github.com/emersion/go-msgauth/dkim/dkim.go
generated
vendored
Normal 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
169
vendor/github.com/emersion/go-msgauth/dkim/header.go
generated
vendored
Normal 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
177
vendor/github.com/emersion/go-msgauth/dkim/query.go
generated
vendored
Normal 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
346
vendor/github.com/emersion/go-msgauth/dkim/sign.go
generated
vendored
Normal 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
462
vendor/github.com/emersion/go-msgauth/dkim/verify.go
generated
vendored
Normal 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
19
vendor/github.com/emersion/go-sasl/.build.yml
generated
vendored
Normal 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
24
vendor/github.com/emersion/go-sasl/.gitignore
generated
vendored
Normal 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
21
vendor/github.com/emersion/go-sasl/LICENSE
generated
vendored
Normal 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
17
vendor/github.com/emersion/go-sasl/README.md
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# go-sasl
|
||||
|
||||
[](https://godoc.org/github.com/emersion/go-sasl)
|
||||
[](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
56
vendor/github.com/emersion/go-sasl/anonymous.go
generated
vendored
Normal 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
26
vendor/github.com/emersion/go-sasl/external.go
generated
vendored
Normal 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}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user