Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
|
||||
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} .
|
||||
205
README.md
205
README.md
@@ -13,28 +13,28 @@ 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] 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
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -45,23 +45,29 @@ 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_DOMAINS** - space separated list of SMTP domains to listen for new emails. The first domain acts as the default domain, all other as aliases
|
||||
|
||||
<details>
|
||||
<summary>other optional config parameters</summary>
|
||||
|
||||
* **POSTMOOGLE_PORT** - SMTP port to listen for new emails
|
||||
* **POSTMOOGLE_PROXIES** - space separated list of IP addresses considered as trusted proxies, thus never banned
|
||||
* **POSTMOOGLE_TLS_PORT** - secure SMTP port to listen for new emails. Requires valid cert and key as well
|
||||
* **POSTMOOGLE_TLS_CERT** - path to your SSL certificate (chain)
|
||||
* **POSTMOOGLE_TLS_KEY** - path to your SSL certificate's private key
|
||||
* **POSTMOOGLE_TLS_CERT** - space separated list of paths to the SSL certificates (chain) of your domains, note that position in the cert list must match the position of the cert's key in the key list
|
||||
* **POSTMOOGLE_TLS_KEY** - space separated list of paths to the SSL certificates' private keys of your domains, note that position on the key list must match the position of cert in the cert list
|
||||
* **POSTMOOGLE_TLS_REQUIRED** - require TLS connection, **even** on the non-TLS port (`POSTMOOGLE_PORT`). TLS connections are always required on the TLS port (`POSTMOOGLE_TLS_PORT`) regardless of this setting.
|
||||
* **POSTMOOGLE_DATA_SECRET** - secure key (password) to encrypt account data, must be 16, 24, or 32 bytes long
|
||||
* **POSTMOOGLE_NOENCRYPTION** - disable matrix encryption (libolm) support
|
||||
* **POSTMOOGLE_STATUSMSG** - presence status message
|
||||
* **POSTMOOGLE_SENTRY_DSN** - sentry DSN
|
||||
* **POSTMOOGLE_MONITORING_SENTRY_DSN** - sentry DSN
|
||||
* **POSTMOOGLE_MONITORING_SENTRY_RATE** - sentry sample rate, from 0 to 100 (default: 20)
|
||||
* **POSTMOOGLE_MONITORING_HEALTHCHECKS_UUID** - healthchecks.io UUID
|
||||
* **POSTMOOGLE_MONITORING_HEALTHCHECKS_DURATION** - heathchecks.io duration between pings in secods (default: 5)
|
||||
* **POSTMOOGLE_LOGLEVEL** - log level
|
||||
* **POSTMOOGLE_DB_DSN** - database connection string
|
||||
* **POSTMOOGLE_DB_DIALECT** - database dialect (postgres, sqlite3)
|
||||
* **POSTMOOGLE_MAILBOXES_RESERVED** - space separated list of reserved mailboxes, [docs/mailboxes.md](docs/mailboxes.md)
|
||||
* **POSTMOOGLE_MAILBOXES_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
|
||||
|
||||
@@ -71,155 +77,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
|
||||
|
||||
@@ -240,6 +98,7 @@ If you want to change them - check available options in the help message (`!pm h
|
||||
---
|
||||
|
||||
* **!pm mailbox** - Get or set mailbox of the room
|
||||
* **!pm 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
|
||||
|
||||
@@ -247,6 +106,7 @@ If you want to change them - check available options in the help message (`!pm h
|
||||
|
||||
* **!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)
|
||||
@@ -255,19 +115,30 @@ If you want to change them - check available options in the help message (`!pm h
|
||||
---
|
||||
|
||||
* **!pm spamcheck:mx** - only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)
|
||||
* **!pm spamcheck:spf** - only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable)
|
||||
* **!pm spamcheck:dkim** - only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`true` - enable, `false` - disable)
|
||||
* **!pm spamcheck:smtp** - only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)
|
||||
* **!pm spamlist:emails** - Get or set `spamlist:emails` of the room (comma-separated list), eg: `spammer@example.com,sspam@example.org`
|
||||
* **!pm spamlist:hosts** - Get or set `spamlist:hosts` of the room (comma-separated list), eg: `spammer.com,scammer.com,morespam.com`
|
||||
* **!pm spamlist:mailboxes** - Get or set `spamlist:mailboxes` of the room (comma-separated list), eg: `notspam,noreply,no-reply`
|
||||
* **!pm spamlist** - Get or set `spamlist` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,noreply@*`
|
||||
|
||||
---
|
||||
|
||||
* **!pm adminroom** - Get or set admin room
|
||||
* **!pm dkim** - Get DKIM signature
|
||||
* **!pm catch-all** - Configure 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 users** - Get or set allowed users patterns
|
||||
* **!pm mailboxes** - Show the list of all mailboxes
|
||||
* **!pm delete** <mailbox> - Delete specific mailbox
|
||||
|
||||
---
|
||||
|
||||
* **!pm greylist** - Set automatic greylisting duration in minutes (0 - disabled)
|
||||
* **!pm banlist** - Enable/disable banlist and show current values
|
||||
* **!pm banlist:add** - Ban an IP
|
||||
* **!pm banlist:remove** - Unban an IP
|
||||
* **!pm banlist:reset** - Reset banlist
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
100
bot/access.go
100
bot/access.go
@@ -2,8 +2,10 @@ package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/raja/argon2pw"
|
||||
@@ -39,7 +41,7 @@ func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
if !b.allowUsers(actorID) {
|
||||
return false
|
||||
}
|
||||
cfg, err := b.getRoomSettings(targetRoomID)
|
||||
cfg, err := b.cfg.GetRoom(targetRoomID)
|
||||
if err != nil {
|
||||
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
|
||||
return false
|
||||
@@ -62,7 +64,7 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
cfg, err := b.getRoomSettings(targetRoomID)
|
||||
cfg, err := b.cfg.GetRoom(targetRoomID)
|
||||
if err != nil {
|
||||
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
|
||||
return false
|
||||
@@ -71,25 +73,103 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
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) 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
|
||||
}
|
||||
|
||||
roomID, ok := b.GetMapping(utils.Mailbox(email))
|
||||
greylist := b.cfg.GetGreylist()
|
||||
greylistedAt, ok := greylist.Get(addr)
|
||||
if !ok {
|
||||
return false
|
||||
b.log.Debug("greylisting %s", addr.String())
|
||||
greylist.Add(addr)
|
||||
err := b.cfg.SetGreylist(greylist)
|
||||
if err != nil {
|
||||
b.log.Error("cannot update greylist with %s: %v", addr.String(), err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
cfg, err := b.getRoomSettings(roomID)
|
||||
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("address %s is trusted", ip)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Ban an address
|
||||
func (b *Bot) Ban(addr net.Addr) {
|
||||
if !b.cfg.BanlistEnalbed() {
|
||||
return
|
||||
}
|
||||
if b.IsTrusted(addr) {
|
||||
return
|
||||
}
|
||||
b.log.Debug("attempting to ban %s", addr.String())
|
||||
banlist := b.cfg.GetBanlist()
|
||||
banlist.Add(addr)
|
||||
err := b.cfg.SetBanlist(banlist)
|
||||
if err != nil {
|
||||
b.log.Error("cannot update banlist with %s: %v", addr.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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("failed to retrieve settings: %v", err)
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
|
||||
if cfg.NoSend() {
|
||||
b.log.Warn("trying to send email from %q (%q), but it's receive-only", email, roomID)
|
||||
return "", false
|
||||
}
|
||||
|
||||
allow, err := argon2pw.CompareHashWithPassword(cfg.Password(), password)
|
||||
if err != nil {
|
||||
b.log.Warn("Password for %s is not valid: %v", email, err)
|
||||
}
|
||||
return allow
|
||||
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("activating mailbox %q (%q) of %q through flow 'none'", mailbox, roomID, ownerID)
|
||||
b.rooms.Store(mailbox, roomID)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Bot) activateNotify(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
|
||||
b.log.Debug("activating mailbox %q (%q) of %q through flow 'notify'", mailbox, roomID, ownerID)
|
||||
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("cannot send mailbox activation notification to the admin room %q", adminRoom)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return true
|
||||
}
|
||||
48
bot/bot.go
48
bot/bot.go
@@ -13,39 +13,61 @@ import (
|
||||
"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
|
||||
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
|
||||
commands commandList
|
||||
rooms sync.Map
|
||||
mta utils.MTA
|
||||
proxies []string
|
||||
sendmail func(string, string, string) error
|
||||
cfg *config.Manager
|
||||
log *logger.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,
|
||||
cfg *config.Manager,
|
||||
proxies []string,
|
||||
prefix string,
|
||||
domain string,
|
||||
domains []string,
|
||||
admins []string,
|
||||
mbxc MBXConfig,
|
||||
) (*Bot, error) {
|
||||
b := &Bot{
|
||||
prefix: prefix,
|
||||
domain: domain,
|
||||
rooms: sync.Map{},
|
||||
log: log,
|
||||
lp: lp,
|
||||
mu: map[id.RoomID]*sync.Mutex{},
|
||||
domains: domains,
|
||||
prefix: prefix,
|
||||
rooms: sync.Map{},
|
||||
adminRooms: []id.RoomID{},
|
||||
proxies: proxies,
|
||||
mbxc: mbxc,
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
lp: lp,
|
||||
mu: utils.NewMutex(),
|
||||
q: q,
|
||||
}
|
||||
users, err := b.initBotUsers()
|
||||
if err != nil {
|
||||
@@ -88,9 +110,9 @@ func (b *Bot) SendError(ctx context.Context, roomID id.RoomID, message string) {
|
||||
|
||||
// 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)
|
||||
parsed := format.RenderMarkdown(message, true, true)
|
||||
parsed.MsgType = event.MsgNotice
|
||||
_, err := b.lp.Send(roomID, &event.Content{Parsed: &parsed})
|
||||
if err != nil {
|
||||
sentry.GetHubFromContext(ctx).CaptureException(err)
|
||||
}
|
||||
|
||||
282
bot/command.go
282
bot/command.go
@@ -7,20 +7,29 @@ import (
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
commandHelp = "help"
|
||||
commandStop = "stop"
|
||||
commandSend = "send"
|
||||
commandDKIM = "dkim"
|
||||
commandCatchAll = botOptionCatchAll
|
||||
commandUsers = botOptionUsers
|
||||
commandDelete = "delete"
|
||||
commandMailboxes = "mailboxes"
|
||||
commandHelp = "help"
|
||||
commandStop = "stop"
|
||||
commandSend = "send"
|
||||
commandDKIM = "dkim"
|
||||
commandCatchAll = config.BotCatchAll
|
||||
commandUsers = config.BotUsers
|
||||
commandQueueBatch = config.BotQueueBatch
|
||||
commandQueueRetries = config.BotQueueRetries
|
||||
commandDelete = "delete"
|
||||
commandBanlist = "banlist"
|
||||
commandBanlistAdd = "banlist:add"
|
||||
commandBanlistRemove = "banlist:remove"
|
||||
commandBanlistReset = "banlist:reset"
|
||||
commandMailboxes = "mailboxes"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -60,132 +69,146 @@ 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.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.RoomNoSender,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - hide email sender; `false` - show email sender)",
|
||||
roomOptionNoSender,
|
||||
config.RoomNoSender,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoRecipient,
|
||||
key: config.RoomNoRecipient,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - hide recipient; `false` - show recipient)",
|
||||
roomOptionNoRecipient,
|
||||
config.RoomNoRecipient,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoSubject,
|
||||
key: config.RoomNoCC,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - hide CC; `false` - show CC)",
|
||||
config.RoomNoCC,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: config.RoomNoSubject,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - hide email subject; `false` - show email subject)",
|
||||
roomOptionNoSubject,
|
||||
config.RoomNoSubject,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoHTML,
|
||||
key: config.RoomNoHTML,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)",
|
||||
roomOptionNoHTML,
|
||||
config.RoomNoHTML,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoThreads,
|
||||
key: config.RoomNoThreads,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)",
|
||||
roomOptionNoThreads,
|
||||
config.RoomNoThreads,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoFiles,
|
||||
key: config.RoomNoFiles,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - ignore email attachments; `false` - upload email attachments)",
|
||||
roomOptionNoFiles,
|
||||
config.RoomNoFiles,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowOwner}, // delimiter
|
||||
{allowed: b.allowOwner, description: "mailbox antispam"}, // delimiter
|
||||
{
|
||||
key: roomOptionSpamcheckMX,
|
||||
key: config.RoomSpamcheckMX,
|
||||
description: "only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)",
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionSpamcheckSMTP,
|
||||
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,
|
||||
},
|
||||
{
|
||||
key: roomOptionSpamlistEmails,
|
||||
key: config.RoomSpamlist,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (comma-separated list), eg: `spammer@example.com,sspam@example.org`",
|
||||
roomOptionSpamlistEmails,
|
||||
"Get or set `%s` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,spam@*`",
|
||||
config.RoomSpamlist,
|
||||
),
|
||||
sanitizer: utils.SanitizeStringSlice,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowAdmin, description: "server options"}, // delimiter
|
||||
{
|
||||
key: roomOptionSpamlistHosts,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (comma-separated list), eg: `spammer.com,scammer.com,morespam.com`",
|
||||
roomOptionSpamlistHosts,
|
||||
),
|
||||
sanitizer: utils.SanitizeStringSlice,
|
||||
allowed: b.allowOwner,
|
||||
key: config.BotAdminRoom,
|
||||
description: "Get or set admin room",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: roomOptionSpamlistLocalparts,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (comma-separated list), eg: `notspam,noreply,no-reply`",
|
||||
roomOptionSpamlistLocalparts,
|
||||
),
|
||||
sanitizer: utils.SanitizeStringSlice,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowAdmin}, // delimiter
|
||||
{
|
||||
key: botOptionUsers,
|
||||
key: config.BotUsers,
|
||||
description: "Get or set allowed users",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
@@ -199,6 +222,18 @@ func (b *Bot) initCommands() commandList {
|
||||
description: "Get or set catch-all mailbox",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: commandQueueBatch,
|
||||
description: "max amount of emails to process on each queue check",
|
||||
sanitizer: utils.SanitizeIntString,
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: commandQueueRetries,
|
||||
description: "max amount of tries per email in queue before removal",
|
||||
sanitizer: utils.SanitizeIntString,
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: commandMailboxes,
|
||||
description: "Show the list of all mailboxes",
|
||||
@@ -209,15 +244,65 @@ 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: 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("cannot send read receipt: %v", err)
|
||||
}
|
||||
|
||||
content := evt.Content.AsMessage()
|
||||
if content == nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot read message")
|
||||
return
|
||||
}
|
||||
// ignore any type apart from text (e.g. reactions, redactions, notices, etc)
|
||||
if content.MsgType != event.MsgText {
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(content.Body)
|
||||
commandSlice := b.parseCommand(message, true)
|
||||
if commandSlice == nil {
|
||||
if utils.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)
|
||||
}
|
||||
@@ -237,12 +322,24 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
|
||||
b.runSend(ctx)
|
||||
case commandDKIM:
|
||||
b.runDKIM(ctx, commandSlice)
|
||||
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 commandBanlistAdd:
|
||||
b.runBanlistAdd(ctx, commandSlice)
|
||||
case commandBanlistRemove:
|
||||
b.runBanlistRemove(ctx, commandSlice)
|
||||
case commandBanlistReset:
|
||||
b.runBanlistReset(ctx)
|
||||
case commandMailboxes:
|
||||
b.sendMailboxes(ctx)
|
||||
default:
|
||||
@@ -276,11 +373,11 @@ 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())
|
||||
@@ -289,7 +386,7 @@ func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
|
||||
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)
|
||||
}
|
||||
@@ -301,7 +398,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("* **`")
|
||||
@@ -317,9 +417,10 @@ func (b *Bot) sendHelp(ctx context.Context) {
|
||||
case true:
|
||||
msg.WriteString("(currently `")
|
||||
msg.WriteString(value)
|
||||
if cmd.key == roomOptionMailbox {
|
||||
msg.WriteString("@")
|
||||
msg.WriteString(b.domain)
|
||||
if cmd.key == config.RoomMailbox {
|
||||
msg.WriteString(" (")
|
||||
msg.WriteString(utils.EmailsList(value, cfg.Domain()))
|
||||
msg.WriteString(")")
|
||||
}
|
||||
msg.WriteString("`)")
|
||||
}
|
||||
@@ -334,6 +435,7 @@ func (b *Bot) sendHelp(ctx context.Context) {
|
||||
b.SendNotice(ctx, evt.RoomID, msg.String())
|
||||
}
|
||||
|
||||
//nolint:gocognit // TODO
|
||||
func (b *Bot) runSend(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
if !b.allowSend(evt.Sender, evt.RoomID) {
|
||||
@@ -355,12 +457,7 @@ func (b *Bot) runSend(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.AddressValid(to) {
|
||||
b.Error(ctx, evt.RoomID, "email address is not valid")
|
||||
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 room settings: %v", err)
|
||||
return
|
||||
@@ -372,16 +469,47 @@ func (b *Bot) runSend(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
var htmlBody string
|
||||
if !cfg.NoHTML() {
|
||||
htmlBody = format.RenderMarkdown(body, true, true).FormattedBody
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "Email has been sent")
|
||||
tos := strings.Split(to, ",")
|
||||
// validate first
|
||||
for _, to := range tos {
|
||||
if !email.AddressValid(to) {
|
||||
b.Error(ctx, evt.RoomID, "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 := 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)
|
||||
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
|
||||
if data == "" {
|
||||
b.SendError(ctx, evt.RoomID, "email body is empty")
|
||||
return
|
||||
}
|
||||
queued, err := b.Sendmail(evt.ID, from, to, data)
|
||||
if queued {
|
||||
b.log.Error("cannot send email: %v", err)
|
||||
b.saveSentMetadata(ctx, queued, evt.ID, recipients, eml, cfg)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err)
|
||||
continue
|
||||
}
|
||||
b.saveSentMetadata(ctx, false, evt.ID, recipients, eml, cfg)
|
||||
}
|
||||
if len(tos) > 1 {
|
||||
b.SendNotice(ctx, evt.RoomID, "All emails were sent.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,21 @@ package bot
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/etke.cc/go/secgen"
|
||||
"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,7 +35,7 @@ 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)
|
||||
}
|
||||
@@ -53,9 +56,7 @@ 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")
|
||||
@@ -80,7 +81,7 @@ func (b *Bot) runDelete(ctx context.Context, commandSlice []string) {
|
||||
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)
|
||||
return
|
||||
@@ -91,7 +92,7 @@ func (b *Bot) runDelete(ctx context.Context, commandSlice []string) {
|
||||
|
||||
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()
|
||||
@@ -122,9 +123,9 @@ func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
|
||||
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)
|
||||
}
|
||||
@@ -134,10 +135,10 @@ func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
|
||||
|
||||
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()
|
||||
@@ -149,9 +150,9 @@ func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
|
||||
b.Error(ctx, evt.RoomID, "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)
|
||||
return
|
||||
@@ -160,7 +161,7 @@ func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf(
|
||||
"DKIM signature is: `%s`.\n"+
|
||||
"You need to add it to your DNS records (if not already):\n"+
|
||||
"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`",
|
||||
@@ -169,12 +170,15 @@ func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
|
||||
|
||||
func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg := b.getBotSettings()
|
||||
cfg := b.cfg.GetBot()
|
||||
if len(commandSlice) < 2 {
|
||||
var msg strings.Builder
|
||||
msg.WriteString("Currently: `")
|
||||
if cfg.CatchAll() != "" {
|
||||
msg.WriteString(cfg.CatchAll())
|
||||
msg.WriteString(" (")
|
||||
msg.WriteString(utils.EmailsList(cfg.CatchAll(), ""))
|
||||
msg.WriteString(")")
|
||||
} else {
|
||||
msg.WriteString("not set")
|
||||
}
|
||||
@@ -195,12 +199,197 @@ func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Set(botOptionCatchAll, mailbox)
|
||||
err := b.setBotSettings(cfg)
|
||||
cfg.Set(config.BotCatchAll, mailbox)
|
||||
err := b.cfg.SetBot(cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s@%s`.", mailbox, b.domain))
|
||||
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s` (%s).", mailbox, utils.EmailsList(mailbox, "")))
|
||||
}
|
||||
|
||||
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.SendNotice(ctx, evt.RoomID, msg.String())
|
||||
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, evt.RoomID, "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.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Admin Room is set to: `%s`.", roomID))
|
||||
}
|
||||
|
||||
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.SendNotice(ctx, roomID, msg.String())
|
||||
}
|
||||
|
||||
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, evt.RoomID, "cannot set bot config: %v", err)
|
||||
}
|
||||
b.SendNotice(ctx, evt.RoomID, "greylist duration has been updated")
|
||||
}
|
||||
|
||||
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(" hosts (`")
|
||||
msg.WriteString(strings.Join(banlist.Slice(), "`, `"))
|
||||
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")
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, msg.String())
|
||||
return
|
||||
}
|
||||
value := utils.SanitizeBoolString(commandSlice[1])
|
||||
cfg.Set(config.BotBanlistEnabled, value)
|
||||
err := b.cfg.SetBot(cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err)
|
||||
}
|
||||
b.SendNotice(ctx, evt.RoomID, "banlist has been updated")
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
if len(commandSlice) < 2 {
|
||||
b.runBanlist(ctx, commandSlice)
|
||||
return
|
||||
}
|
||||
banlist := b.cfg.GetBanlist()
|
||||
|
||||
ips := commandSlice[1:]
|
||||
for _, ip := range ips {
|
||||
addr, err := net.ResolveIPAddr("ip", ip)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot add %s to banlist: %v", ip, err)
|
||||
return
|
||||
}
|
||||
banlist.Add(addr)
|
||||
}
|
||||
|
||||
err := b.cfg.SetBanlist(banlist)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "banlist has been updated, kupo")
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
if len(commandSlice) < 2 {
|
||||
b.runBanlist(ctx, commandSlice)
|
||||
return
|
||||
}
|
||||
banlist := b.cfg.GetBanlist()
|
||||
|
||||
ips := commandSlice[1:]
|
||||
for _, ip := range ips {
|
||||
addr, err := net.ResolveIPAddr("ip", ip)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot remove %s from banlist: %v", ip, err)
|
||||
return
|
||||
}
|
||||
banlist.Remove(addr)
|
||||
}
|
||||
|
||||
err := b.cfg.SetBanlist(banlist)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "banlist has been updated, kupo")
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlistReset(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
|
||||
err := b.cfg.SetBanlist(config.List{})
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "banlist has been reset, kupo")
|
||||
}
|
||||
|
||||
@@ -3,19 +3,23 @@ package bot
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/raja/argon2pw"
|
||||
|
||||
"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)
|
||||
return
|
||||
}
|
||||
|
||||
mailbox := cfg.Get(roomOptionMailbox)
|
||||
mailbox := cfg.Get(config.RoomMailbox)
|
||||
if mailbox == "" {
|
||||
b.SendNotice(ctx, evt.RoomID, "that room is not configured yet")
|
||||
return
|
||||
@@ -23,7 +27,7 @@ func (b *Bot) runStop(ctx context.Context) {
|
||||
|
||||
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)
|
||||
return
|
||||
@@ -42,7 +46,7 @@ func (b *Bot) handleOption(ctx context.Context, cmd []string) {
|
||||
|
||||
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)
|
||||
return
|
||||
@@ -57,14 +61,14 @@ func (b *Bot) getOption(ctx context.Context, name string) {
|
||||
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, "+
|
||||
@@ -82,21 +86,26 @@ 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))
|
||||
// ignore request
|
||||
if name == config.RoomActive {
|
||||
return
|
||||
}
|
||||
if name == config.RoomMailbox {
|
||||
existingID, ok := b.getMapping(value)
|
||||
if (ok && existingID != "" && existingID != evt.RoomID) || b.isReserved(value) {
|
||||
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Mailbox `%s` (%s) already taken, kupo", value, utils.EmailsList(value, "")))
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if name == roomOptionPassword {
|
||||
if name == config.RoomPassword {
|
||||
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, evt.RoomID, "failed to hash password: %v", err)
|
||||
@@ -107,23 +116,24 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
|
||||
old := cfg.Get(name)
|
||||
cfg.Set(name, value)
|
||||
|
||||
if name == roomOptionMailbox {
|
||||
cfg.Set(roomOptionOwner, evt.Sender.String())
|
||||
if name == config.RoomMailbox {
|
||||
cfg.Set(config.RoomOwner, evt.Sender.String())
|
||||
if old != "" {
|
||||
b.rooms.Delete(old)
|
||||
}
|
||||
b.rooms.Store(value, evt.RoomID)
|
||||
value = fmt.Sprintf("%s@%s", value, b.domain)
|
||||
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.setRoomSettings(evt.RoomID, cfg)
|
||||
err = b.cfg.SetRoom(evt.RoomID, cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("`%s` of this room set to `%s`", name, value)
|
||||
if name == roomOptionPassword {
|
||||
if name == config.RoomPassword {
|
||||
msg = "SMTP password has been set"
|
||||
}
|
||||
b.SendNotice(ctx, evt.RoomID, msg)
|
||||
|
||||
92
bot/config/bot.go
Normal file
92
bot/config/bot.go
Normal file
@@ -0,0 +1,92 @@
|
||||
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"
|
||||
BotGreylist = "greylist"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
129
bot/config/manager.go
Normal file
129
bot/config/manager.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// Manager of configs
|
||||
type Manager struct {
|
||||
bl List
|
||||
ble bool
|
||||
mu utils.Mutex
|
||||
log *logger.Logger
|
||||
lp *linkpearl.Linkpearl
|
||||
}
|
||||
|
||||
// New config manager
|
||||
func New(lp *linkpearl.Linkpearl, log *logger.Logger) *Manager {
|
||||
m := &Manager{
|
||||
mu: utils.NewMutex(),
|
||||
bl: make(List, 0),
|
||||
lp: lp,
|
||||
log: log,
|
||||
}
|
||||
m.ble = m.GetBot().BanlistEnabled()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// BanlistEnalbed or not
|
||||
func (m *Manager) BanlistEnalbed() bool {
|
||||
return m.ble
|
||||
}
|
||||
|
||||
// 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("cannot get bot settings: %v", utils.UnwrapError(err))
|
||||
}
|
||||
if config == nil {
|
||||
config = make(Bot, 0)
|
||||
return config
|
||||
}
|
||||
m.ble = config.BanlistEnabled()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// SetBot config
|
||||
func (m *Manager) SetBot(cfg Bot) error {
|
||||
m.ble = cfg.BanlistEnabled()
|
||||
return utils.UnwrapError(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, utils.UnwrapError(err)
|
||||
}
|
||||
|
||||
// SetRoom config
|
||||
func (m *Manager) SetRoom(roomID id.RoomID, cfg Room) error {
|
||||
return utils.UnwrapError(m.lp.SetRoomAccountData(roomID, acRoomKey, cfg))
|
||||
}
|
||||
|
||||
// GetBanlist config
|
||||
func (m *Manager) GetBanlist() List {
|
||||
if len(m.bl) > 0 || !m.ble {
|
||||
return m.bl
|
||||
}
|
||||
|
||||
m.mu.Lock("banlist")
|
||||
defer m.mu.Unlock("banlist")
|
||||
config, err := m.lp.GetAccountData(acBanlistKey)
|
||||
if err != nil {
|
||||
m.log.Error("cannot get banlist: %v", utils.UnwrapError(err))
|
||||
}
|
||||
if config == nil {
|
||||
config = make(List, 0)
|
||||
return config
|
||||
}
|
||||
m.bl = config
|
||||
return config
|
||||
}
|
||||
|
||||
// SetBanlist config
|
||||
func (m *Manager) SetBanlist(cfg List) error {
|
||||
if !m.ble {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.Lock("banlist")
|
||||
if cfg == nil {
|
||||
cfg = make(List, 0)
|
||||
}
|
||||
m.bl = cfg
|
||||
defer m.mu.Unlock("banlist")
|
||||
|
||||
return utils.UnwrapError(m.lp.SetAccountData(acBanlistKey, cfg))
|
||||
}
|
||||
|
||||
// GetGreylist config
|
||||
func (m *Manager) GetGreylist() List {
|
||||
config, err := m.lp.GetAccountData(acGreylistKey)
|
||||
if err != nil {
|
||||
m.log.Error("cannot get banlist: %v", utils.UnwrapError(err))
|
||||
}
|
||||
if config == nil {
|
||||
config = make(List, 0)
|
||||
return config
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// SetGreylist config
|
||||
func (m *Manager) SetGreylist(cfg List) error {
|
||||
return utils.UnwrapError(m.lp.SetAccountData(acGreylistKey, cfg))
|
||||
}
|
||||
183
bot/config/room.go
Normal file
183
bot/config/room.go
Normal file
@@ -0,0 +1,183 @@
|
||||
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"
|
||||
RoomNoSend = "nosend"
|
||||
RoomNoCC = "nocc"
|
||||
RoomNoSender = "nosender"
|
||||
RoomNoRecipient = "norecipient"
|
||||
RoomNoSubject = "nosubject"
|
||||
RoomNoHTML = "nohtml"
|
||||
RoomNoThreads = "nothreads"
|
||||
RoomNoFiles = "nofiles"
|
||||
RoomPassword = "password"
|
||||
RoomSpamcheckDKIM = "spamcheck:dkim"
|
||||
RoomSpamcheckSMTP = "spamcheck:smtp"
|
||||
RoomSpamcheckSPF = "spamcheck:spf"
|
||||
RoomSpamcheckMX = "spamcheck:mx"
|
||||
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) NoSend() bool {
|
||||
return utils.Bool(s.Get(RoomNoSend))
|
||||
}
|
||||
|
||||
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) 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, strings.Join(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",
|
||||
}
|
||||
}
|
||||
65
bot/data.go
65
bot/data.go
@@ -1,5 +1,11 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
)
|
||||
|
||||
var migrations = []string{}
|
||||
|
||||
func (b *Bot) migrate() error {
|
||||
@@ -32,21 +38,74 @@ func (b *Bot) migrate() error {
|
||||
}
|
||||
|
||||
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("cannot retrieve room settings: %v", err)
|
||||
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("cannot migrate room settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
459
bot/email.go
459
bot/email.go
@@ -3,12 +3,14 @@ package bot
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
@@ -20,15 +22,40 @@ 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)
|
||||
}
|
||||
|
||||
// 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 strings.HasPrefix(err.Error(), "4") {
|
||||
b.log.Info("email %s (from=%s to=%s) was added to the queue: %v", eventID, from, to, err)
|
||||
return true, b.q.Add(eventID.String(), from, to, data)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetDKIMprivkey returns DKIM private key
|
||||
func (b *Bot) GetDKIMprivkey() string {
|
||||
return b.cfg.GetBot().DKIMPrivateKey()
|
||||
}
|
||||
|
||||
func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
|
||||
@@ -49,7 +76,7 @@ func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
|
||||
func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
|
||||
roomID, ok := b.getMapping(mailbox)
|
||||
if !ok {
|
||||
catchAll := b.getBotSettings().CatchAll()
|
||||
catchAll := b.cfg.GetBot().CatchAll()
|
||||
if catchAll == "" {
|
||||
return roomID, ok
|
||||
}
|
||||
@@ -60,37 +87,32 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
|
||||
}
|
||||
|
||||
// GetIFOptions returns incoming email filtering options (room settings)
|
||||
func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions {
|
||||
cfg, err := b.getRoomSettings(roomID)
|
||||
func (b *Bot) GetIFOptions(roomID id.RoomID) email.IncomingFilteringOptions {
|
||||
cfg, err := b.cfg.GetRoom(roomID)
|
||||
if err != nil {
|
||||
b.log.Error("cannot retrieve room settings: %v", err)
|
||||
return roomSettings{}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Send email to matrix room
|
||||
func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) error {
|
||||
roomID, ok := b.GetMapping(email.Mailbox(incoming))
|
||||
// IncomingEmail sends incoming email to matrix room
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
if email.InReplyTo != "" || email.References != "" {
|
||||
threadID = b.getThreadID(roomID, email.InReplyTo, email.References)
|
||||
if threadID != "" {
|
||||
b.setThreadID(roomID, email.MessageID, threadID)
|
||||
}
|
||||
@@ -100,162 +122,341 @@ func (b *Bot) Send2Matrix(ctx context.Context, email *utils.Email, incoming bool
|
||||
if serr != nil {
|
||||
return utils.UnwrapError(serr)
|
||||
}
|
||||
|
||||
if threadID == "" && !cfg.NoThreads() {
|
||||
b.setThreadID(roomID, email.MessageID, eventID)
|
||||
if threadID == "" {
|
||||
threadID = eventID
|
||||
}
|
||||
|
||||
b.setThreadID(roomID, email.MessageID, threadID)
|
||||
b.setLastEventID(roomID, threadID, eventID)
|
||||
|
||||
if !cfg.NoFiles() {
|
||||
b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID)
|
||||
}
|
||||
|
||||
if !incoming {
|
||||
email.MessageID = fmt.Sprintf("<%s@%s>", eventID, b.domain)
|
||||
return b.mta.Send(email.From, email.To, email.Compose(b.getBotSettings().DKIMPrivateKey()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) getParentEmail(evt *event.Event) (string, string, string) {
|
||||
content := evt.Content.AsMessage()
|
||||
parentID := utils.EventParent(evt.ID, content)
|
||||
if parentID == evt.ID {
|
||||
return "", "", ""
|
||||
}
|
||||
parentID = b.getLastEventID(evt.RoomID, parentID)
|
||||
parentEvt, err := b.lp.GetClient().GetEvent(evt.RoomID, parentID)
|
||||
if err != nil {
|
||||
b.log.Error("cannot get parent event: %v", err)
|
||||
return "", "", ""
|
||||
}
|
||||
if parentEvt.Content.Parsed == nil {
|
||||
perr := parentEvt.Content.ParseRaw(event.EventMessage)
|
||||
if perr != nil {
|
||||
b.log.Error("cannot parse event content: %v", perr)
|
||||
return "", "", ""
|
||||
}
|
||||
}
|
||||
|
||||
to := utils.EventField[string](&parentEvt.Content, eventFromKey)
|
||||
inReplyTo := utils.EventField[string](&parentEvt.Content, eventMessageIDkey)
|
||||
if inReplyTo == "" {
|
||||
inReplyTo = parentID.String()
|
||||
}
|
||||
|
||||
subject := utils.EventField[string](&parentEvt.Content, eventSubjectKey)
|
||||
if subject != "" {
|
||||
subject = "Re: " + subject
|
||||
} else {
|
||||
subject = strings.SplitN(content.Body, "\n", 1)[0]
|
||||
}
|
||||
|
||||
return to, inReplyTo, subject
|
||||
}
|
||||
|
||||
// Send2Email sends message to email
|
||||
// TODO rewrite to thread replies only
|
||||
func (b *Bot) Send2Email(ctx context.Context, to, subject, body string) error {
|
||||
var inReplyTo string
|
||||
// SendEmailReply sends replies from matrix thread to email thread
|
||||
func (b *Bot) SendEmailReply(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg, err := b.getRoomSettings(evt.RoomID)
|
||||
if !b.allowSend(evt.Sender, evt.RoomID) {
|
||||
return
|
||||
}
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
return err
|
||||
b.Error(ctx, evt.RoomID, "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, evt.RoomID, "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, evt.RoomID, "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)
|
||||
}
|
||||
content := evt.Content.AsMessage()
|
||||
if meta.Subject == "" {
|
||||
meta.Subject = strings.SplitN(content.Body, "\n", 1)[0]
|
||||
}
|
||||
body := content.Body
|
||||
var htmlBody string
|
||||
if !cfg.NoHTML() {
|
||||
htmlBody = content.FormattedBody
|
||||
}
|
||||
|
||||
meta.MessageID = email.MessageID(evt.ID, meta.FromDomain)
|
||||
meta.References = meta.References + " " + meta.MessageID
|
||||
b.log.Info("sending email reply: %+v", meta)
|
||||
eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil)
|
||||
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
|
||||
if data == "" {
|
||||
b.SendError(ctx, evt.RoomID, "email body is empty")
|
||||
return
|
||||
}
|
||||
|
||||
var queued bool
|
||||
var hasErr bool
|
||||
recipients := meta.Recipients
|
||||
for _, to := range recipients {
|
||||
queued, err = b.Sendmail(evt.ID, meta.From, to, data)
|
||||
if queued {
|
||||
b.log.Error("cannot send email: %v", err)
|
||||
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg)
|
||||
hasErr = true
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot send email: %v", err)
|
||||
hasErr = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if !hasErr {
|
||||
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) {
|
||||
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{}{}
|
||||
}
|
||||
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 := utils.EventParent(evt.ID, content)
|
||||
b.log.Debug("looking up for the parent event of %s within thread %s", evt.ID, threadID)
|
||||
if threadID == evt.ID {
|
||||
b.log.Debug("event %s is the thread itself")
|
||||
return threadID, evt
|
||||
}
|
||||
lastEventID := b.getLastEventID(evt.RoomID, threadID)
|
||||
b.log.Debug("the last event of the thread %s (and parent of the %s) is %s", threadID, evt.ID, lastEventID)
|
||||
if lastEventID == evt.ID {
|
||||
return threadID, evt
|
||||
}
|
||||
parentEvt, err := b.lp.GetClient().GetEvent(evt.RoomID, lastEventID)
|
||||
if err != nil {
|
||||
b.log.Error("cannot get parent event: %v", err)
|
||||
return threadID, nil
|
||||
}
|
||||
utils.ParseContent(parentEvt, parentEvt.Type)
|
||||
b.log.Debug("type of the parsed content is: %T", parentEvt.Content.Parsed)
|
||||
|
||||
if !b.lp.GetStore().IsEncrypted(evt.RoomID) {
|
||||
b.log.Debug("found the last event (plaintext) of the thread %s (and parent of the %s): %+v", threadID, evt.ID, parentEvt)
|
||||
return threadID, parentEvt
|
||||
}
|
||||
|
||||
decrypted, err := b.lp.GetMachine().DecryptMegolmEvent(parentEvt)
|
||||
if err != nil {
|
||||
b.log.Error("cannot decrypt parent event: %v", err)
|
||||
return threadID, nil
|
||||
}
|
||||
|
||||
b.log.Debug("found the last event (decrypted) of the thread %s (and parent of the %s): %+v", threadID, evt.ID, parentEvt)
|
||||
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 = utils.EventField[string](&parentEvt.Content, eventFromKey)
|
||||
parent.To = utils.EventField[string](&parentEvt.Content, eventToKey)
|
||||
parent.CC = utils.EventField[string](&parentEvt.Content, eventCcKey)
|
||||
parent.RcptTo = utils.EventField[string](&parentEvt.Content, eventRcptToKey)
|
||||
parent.InReplyTo = utils.EventField[string](&parentEvt.Content, eventMessageIDkey)
|
||||
parent.References = utils.EventField[string](&parentEvt.Content, eventReferencesKey)
|
||||
senderEmail := parent.fixtofrom(newFromMailbox, b.domains)
|
||||
parent.calculateRecipients(senderEmail)
|
||||
parent.MessageID = email.MessageID(parentEvt.ID, parent.FromDomain)
|
||||
if parent.InReplyTo == "" {
|
||||
parent.InReplyTo = parent.MessageID
|
||||
}
|
||||
if parent.References == "" {
|
||||
parent.References = " " + parent.MessageID
|
||||
}
|
||||
|
||||
parent.Subject = utils.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) {
|
||||
addrs := strings.Join(recipients, ", ")
|
||||
text := "Email has been sent to " + addrs
|
||||
if queued {
|
||||
text = "Email to " + addrs + " has been queued"
|
||||
}
|
||||
|
||||
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, evt.RoomID, "cannot parse message")
|
||||
return
|
||||
}
|
||||
msgContent.MsgType = event.MsgNotice
|
||||
msgContent.Body = notice.Body
|
||||
msgContent.FormattedBody = notice.FormattedBody
|
||||
content.Parsed = msgContent
|
||||
msgID, err := b.lp.Send(evt.RoomID, content)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot send notice: %v", err)
|
||||
return
|
||||
}
|
||||
domain := utils.SanitizeDomain(cfg.Domain())
|
||||
b.setThreadID(evt.RoomID, email.MessageID(evt.ID, domain), threadID)
|
||||
b.setThreadID(evt.RoomID, email.MessageID(msgID, domain), threadID)
|
||||
b.setLastEventID(evt.RoomID, threadID, msgID)
|
||||
}
|
||||
|
||||
func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) {
|
||||
for _, file := range files {
|
||||
req := file.Convert()
|
||||
err := b.lp.SendFile(roomID, req, file.MsgType, utils.RelatesTo(!noThreads, parentID))
|
||||
b.Error(ctx, roomID, "cannot upload file %s: %v", req.FileName, err)
|
||||
if err != nil {
|
||||
b.Error(ctx, roomID, "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, " ")...)
|
||||
}
|
||||
|
||||
for _, refID := range refs {
|
||||
key := acMessagePrefix + "." + refID
|
||||
data, err := b.lp.GetRoomAccountData(roomID, key)
|
||||
if err != nil {
|
||||
b.log.Error("cannot retrieve thread ID from %s: %v", key, err)
|
||||
continue
|
||||
}
|
||||
if data["eventID"] != "" {
|
||||
return id.EventID(data["eventID"])
|
||||
}
|
||||
}
|
||||
|
||||
return data["eventID"]
|
||||
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("cannot save thread ID to %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
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("cannot retrieve last event ID from %s: %v", key, err)
|
||||
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("cannot save thread ID to %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"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 = 1
|
||||
defaultQueueRetries = 3
|
||||
)
|
||||
|
||||
// Queue manager
|
||||
type Queue struct {
|
||||
mu utils.Mutex
|
||||
lp *linkpearl.Linkpearl
|
||||
cfg *config.Manager
|
||||
log *logger.Logger
|
||||
sendmail func(string, string, string) error
|
||||
}
|
||||
|
||||
// New queue
|
||||
func New(lp *linkpearl.Linkpearl, cfg *config.Manager, log *logger.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("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("cannot get queue index: %v", err)
|
||||
}
|
||||
|
||||
i := 0
|
||||
for id, itemkey := range index {
|
||||
if i > batchSize {
|
||||
q.log.Debug("finished re-deliveries from queue")
|
||||
return
|
||||
}
|
||||
if dequeue := q.try(itemkey, maxRetries); dequeue {
|
||||
q.log.Info("email %q has been delivered", id)
|
||||
err = q.Remove(id)
|
||||
if err != nil {
|
||||
q.log.Error("cannot dequeue email %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
q.log.Debug("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("cannot enqueue email id=%q: %v", id, err)
|
||||
return err
|
||||
}
|
||||
|
||||
q.mu.Lock(acQueueKey)
|
||||
defer q.mu.Unlock(acQueueKey)
|
||||
queueIndex, err := q.lp.GetAccountData(acQueueKey)
|
||||
if err != nil {
|
||||
q.log.Error("cannot get queue index: %v", err)
|
||||
return err
|
||||
}
|
||||
queueIndex[id] = itemkey
|
||||
err = q.lp.SetAccountData(acQueueKey, queueIndex)
|
||||
if err != nil {
|
||||
q.log.Error("cannot save queue index: %v", err)
|
||||
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("cannot get queue index: %v", err)
|
||||
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("cannot update queue index: %v", err)
|
||||
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("cannot retrieve a queue item %q: %v", itemkey, err)
|
||||
return false
|
||||
}
|
||||
q.log.Debug("processing queue item %+v", item)
|
||||
attempts, err := strconv.Atoi(item["attempts"])
|
||||
if err != nil {
|
||||
q.log.Error("cannot parse attempts of %q: %v", itemkey, err)
|
||||
return false
|
||||
}
|
||||
if attempts > maxRetries {
|
||||
return true
|
||||
}
|
||||
|
||||
err = q.sendmail(item["from"], item["to"], item["data"])
|
||||
if err == nil {
|
||||
q.log.Info("email %q from queue was delivered")
|
||||
return true
|
||||
}
|
||||
|
||||
q.log.Info("attempted to deliver email id=%q, retry=%q, but it's not ready yet: %v", item["id"], item["attempts"], err)
|
||||
attempts++
|
||||
item["attempts"] = strconv.Itoa(attempts)
|
||||
err = q.lp.SetAccountData(itemkey, item)
|
||||
if err != nil {
|
||||
q.log.Error("cannot update attempt count on email %q: %v", itemkey, err)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// account data key
|
||||
const acBotSettingsKey = "cc.etke.postmoogle.config"
|
||||
|
||||
// bot options keys
|
||||
const (
|
||||
botOptionUsers = "users"
|
||||
botOptionCatchAll = "catch-all"
|
||||
botOptionDKIMSignature = "dkim.pub"
|
||||
botOptionDKIMPrivateKey = "dkim.pem"
|
||||
)
|
||||
|
||||
type botSettings map[string]string
|
||||
|
||||
// Get option
|
||||
func (s botSettings) Get(key string) string {
|
||||
return s[strings.ToLower(strings.TrimSpace(key))]
|
||||
}
|
||||
|
||||
// Set option
|
||||
func (s botSettings) Set(key, value string) {
|
||||
s[strings.ToLower(strings.TrimSpace(key))] = value
|
||||
}
|
||||
|
||||
// Users option
|
||||
func (s botSettings) Users() []string {
|
||||
value := s.Get(botOptionUsers)
|
||||
if value == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
if strings.Contains(value, " ") {
|
||||
return strings.Split(value, " ")
|
||||
}
|
||||
|
||||
return []string{value}
|
||||
}
|
||||
|
||||
// CatchAll option
|
||||
func (s botSettings) CatchAll() string {
|
||||
return s.Get(botOptionCatchAll)
|
||||
}
|
||||
|
||||
// DKIMSignature (DNS TXT record)
|
||||
func (s botSettings) DKIMSignature() string {
|
||||
return s.Get(botOptionDKIMSignature)
|
||||
}
|
||||
|
||||
// DKIMPrivateKey keep it secret
|
||||
func (s botSettings) DKIMPrivateKey() string {
|
||||
return s.Get(botOptionDKIMPrivateKey)
|
||||
}
|
||||
|
||||
func (b *Bot) initBotUsers() ([]string, error) {
|
||||
config := b.getBotSettings()
|
||||
cfgUsers := config.Users()
|
||||
if len(cfgUsers) > 0 {
|
||||
return cfgUsers, nil
|
||||
}
|
||||
|
||||
_, homeserver, err := b.lp.GetClient().UserID.Parse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.Set(botOptionUsers, "@*:"+homeserver)
|
||||
return config.Users(), b.setBotSettings(config)
|
||||
}
|
||||
|
||||
func (b *Bot) getBotSettings() botSettings {
|
||||
config, err := b.lp.GetAccountData(acBotSettingsKey)
|
||||
if err != nil {
|
||||
b.log.Error("cannot get bot settings: %v", utils.UnwrapError(err))
|
||||
}
|
||||
if config == nil {
|
||||
config = map[string]string{}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func (b *Bot) setBotSettings(cfg botSettings) error {
|
||||
return utils.UnwrapError(b.lp.SetAccountData(acBotSettingsKey, cfg))
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// account data key
|
||||
const acRoomSettingsKey = "cc.etke.postmoogle.settings"
|
||||
|
||||
// option keys
|
||||
const (
|
||||
roomOptionOwner = "owner"
|
||||
roomOptionMailbox = "mailbox"
|
||||
roomOptionNoSend = "nosend"
|
||||
roomOptionNoSender = "nosender"
|
||||
roomOptionNoRecipient = "norecipient"
|
||||
roomOptionNoSubject = "nosubject"
|
||||
roomOptionNoHTML = "nohtml"
|
||||
roomOptionNoThreads = "nothreads"
|
||||
roomOptionNoFiles = "nofiles"
|
||||
roomOptionPassword = "password"
|
||||
roomOptionSpamcheckSMTP = "spamcheck:smtp"
|
||||
roomOptionSpamcheckMX = "spamcheck:mx"
|
||||
roomOptionSpamlistEmails = "spamlist:emails"
|
||||
roomOptionSpamlistHosts = "spamlist:hosts"
|
||||
roomOptionSpamlistLocalparts = "spamlist:mailboxes"
|
||||
)
|
||||
|
||||
type roomSettings map[string]string
|
||||
|
||||
// Get option
|
||||
func (s roomSettings) Get(key string) string {
|
||||
return s[strings.ToLower(strings.TrimSpace(key))]
|
||||
}
|
||||
|
||||
// Set option
|
||||
func (s roomSettings) Set(key, value string) {
|
||||
s[strings.ToLower(strings.TrimSpace(key))] = value
|
||||
}
|
||||
|
||||
func (s roomSettings) Mailbox() string {
|
||||
return s.Get(roomOptionMailbox)
|
||||
}
|
||||
|
||||
func (s roomSettings) Owner() string {
|
||||
return s.Get(roomOptionOwner)
|
||||
}
|
||||
|
||||
func (s roomSettings) Password() string {
|
||||
return s.Get(roomOptionPassword)
|
||||
}
|
||||
|
||||
func (s roomSettings) NoSend() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoSend))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoSender() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoSender))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoRecipient() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoRecipient))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoSubject() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoSubject))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoHTML() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoHTML))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoThreads() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoThreads))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoFiles() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoFiles))
|
||||
}
|
||||
|
||||
func (s roomSettings) SpamcheckSMTP() bool {
|
||||
return utils.Bool(s.Get(roomOptionSpamcheckSMTP))
|
||||
}
|
||||
|
||||
func (s roomSettings) SpamcheckMX() bool {
|
||||
return utils.Bool(s.Get(roomOptionSpamcheckMX))
|
||||
}
|
||||
|
||||
func (s roomSettings) SpamlistEmails() []string {
|
||||
return utils.StringSlice(s.Get(roomOptionSpamlistEmails))
|
||||
}
|
||||
|
||||
func (s roomSettings) SpamlistHosts() []string {
|
||||
return utils.StringSlice(s.Get(roomOptionSpamlistHosts))
|
||||
}
|
||||
|
||||
func (s roomSettings) SpamlistLocalparts() []string {
|
||||
return utils.StringSlice(s.Get(roomOptionSpamlistLocalparts))
|
||||
}
|
||||
|
||||
// ContentOptions converts room display settings to content options
|
||||
func (s roomSettings) ContentOptions() *utils.ContentOptions {
|
||||
return &utils.ContentOptions{
|
||||
HTML: !s.NoHTML(),
|
||||
Sender: !s.NoSender(),
|
||||
Recipient: !s.NoRecipient(),
|
||||
Subject: !s.NoSubject(),
|
||||
Threads: !s.NoThreads(),
|
||||
|
||||
FromKey: eventFromKey,
|
||||
SubjectKey: eventSubjectKey,
|
||||
MessageIDKey: eventMessageIDkey,
|
||||
InReplyToKey: eventInReplyToKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) getRoomSettings(roomID id.RoomID) (roomSettings, error) {
|
||||
config, err := b.lp.GetRoomAccountData(roomID, acRoomSettingsKey)
|
||||
if config == nil {
|
||||
config = map[string]string{}
|
||||
}
|
||||
|
||||
return config, utils.UnwrapError(err)
|
||||
}
|
||||
|
||||
func (b *Bot) setRoomSettings(roomID id.RoomID, cfg roomSettings) error {
|
||||
return utils.UnwrapError(b.lp.SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
|
||||
}
|
||||
@@ -69,6 +69,10 @@ func (b *Bot) onEncryptedMessage(evt *event.Event) {
|
||||
if evt.Sender == b.lp.GetClient().UserID {
|
||||
return
|
||||
}
|
||||
// ignore encrypted events in noecryption mode
|
||||
if b.lp.GetMachine() == nil {
|
||||
return
|
||||
}
|
||||
ctx := newContext(evt)
|
||||
|
||||
decrypted, err := b.lp.GetMachine().DecryptMegolmEvent(evt)
|
||||
|
||||
93
cmd/cmd.go
93
cmd/cmd.go
@@ -4,25 +4,35 @@ import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mileusna/crontab"
|
||||
"gitlab.com/etke.cc/go/healthchecks"
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"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 *logger.Logger
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -30,6 +40,8 @@ func main() {
|
||||
|
||||
cfg := config.New()
|
||||
log = logger.New("postmoogle.", cfg.LogLevel)
|
||||
utils.SetLogger(log)
|
||||
utils.SetDomains(cfg.Domains)
|
||||
|
||||
log.Info("#############################")
|
||||
log.Info("Postmoogle")
|
||||
@@ -38,14 +50,16 @@ func main() {
|
||||
|
||||
log.Debug("starting internal components...")
|
||||
initSentry(cfg)
|
||||
initBot(cfg)
|
||||
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)
|
||||
}
|
||||
@@ -55,20 +69,35 @@ func main() {
|
||||
|
||||
func initSentry(cfg *config.Config) {
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: cfg.Sentry.DSN,
|
||||
Dsn: cfg.Monitoring.SentryDSN,
|
||||
AttachStacktrace: true,
|
||||
TracesSampleRate: float64(cfg.Monitoring.SentrySampleRate) / 100,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("cannot initialize sentry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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("healthchecks operation %q failed: %v", operation, err)
|
||||
})
|
||||
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)
|
||||
}
|
||||
mxlog := logger.New("matrix.", cfg.LogLevel)
|
||||
cfglog := logger.New("config.", cfg.LogLevel)
|
||||
qlog := logger.New("queue.", cfg.LogLevel)
|
||||
|
||||
lp, err := linkpearl.New(&lpcfg.Config{
|
||||
Homeserver: cfg.Homeserver,
|
||||
Login: cfg.Login,
|
||||
@@ -77,17 +106,24 @@ func initBot(cfg *config.Config) {
|
||||
Dialect: cfg.DB.Dialect,
|
||||
NoEncryption: cfg.NoEncryption,
|
||||
AccountDataSecret: cfg.DataSecret,
|
||||
LPLogger: mxlog,
|
||||
APILogger: logger.New("api.", cfg.LogLevel),
|
||||
StoreLogger: logger.New("store.", cfg.LogLevel),
|
||||
CryptoLogger: logger.New("olm.", cfg.LogLevel),
|
||||
AccountDataLogReplace: map[string]string{
|
||||
"password": "<redacted>",
|
||||
"dkim.pem": "<redacted>",
|
||||
"dkim.pub": "<redacted>",
|
||||
},
|
||||
LPLogger: mxlog,
|
||||
APILogger: logger.New("api.", "INFO"),
|
||||
StoreLogger: logger.New("store.", "INFO"),
|
||||
CryptoLogger: logger.New("olm.", "INFO"),
|
||||
})
|
||||
if err != nil {
|
||||
// nolint // Fatal = panic, not os.Exit()
|
||||
log.Fatal("cannot initialize matrix bot: %v", err)
|
||||
}
|
||||
|
||||
mxb, err = bot.New(lp, mxlog, cfg.Prefix, cfg.Domain, cfg.Admins)
|
||||
mxc = mxconfig.New(lp, cfglog)
|
||||
q = queue.New(lp, mxc, qlog)
|
||||
mxb, err = bot.New(q, lp, mxlog, 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)
|
||||
@@ -96,19 +132,34 @@ func initBot(cfg *config.Config) {
|
||||
}
|
||||
|
||||
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,
|
||||
MaxSize: cfg.MaxSize,
|
||||
Bot: mxb,
|
||||
Callers: []smtp.Caller{mxb, q},
|
||||
})
|
||||
}
|
||||
|
||||
func initCron() {
|
||||
cron = crontab.New()
|
||||
|
||||
err := cron.AddJob("* * * * *", q.Process)
|
||||
if err != nil {
|
||||
log.Error("cannot start queue processing cronjob: %v", err)
|
||||
}
|
||||
|
||||
err = cron.AddJob("*/5 * * * *", mxb.SyncRooms)
|
||||
if err != nil {
|
||||
log.Error("cannot start sync rooms cronjob: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -132,8 +183,13 @@ func startBot(statusMsg string) {
|
||||
|
||||
func shutdown() {
|
||||
log.Info("Shutting down...")
|
||||
smtpserv.Stop()
|
||||
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")
|
||||
@@ -143,8 +199,7 @@ func shutdown() {
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -15,21 +17,29 @@ func New() *Config {
|
||||
Login: env.String("login", defaultConfig.Login),
|
||||
Password: env.String("password", defaultConfig.Password),
|
||||
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"),
|
||||
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{
|
||||
@@ -40,3 +50,13 @@ func New() *Config {
|
||||
|
||||
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,5 +1,7 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Config of Postmoogle
|
||||
type Config struct {
|
||||
// Homeserver url
|
||||
@@ -8,10 +10,12 @@ type Config struct {
|
||||
Login string
|
||||
// Password for login/password auth only
|
||||
Password string
|
||||
// Domain for SMTP
|
||||
Domain string
|
||||
// Domains for SMTP
|
||||
Domains []string
|
||||
// Port for SMTP
|
||||
Port string
|
||||
// Proxies is list of trusted SMTP proxies
|
||||
Proxies []string
|
||||
// RoomID of the admin room
|
||||
LogLevel string
|
||||
// DataSecret is account data secret key (password) to encrypt all account data values
|
||||
@@ -24,6 +28,8 @@ type Config struct {
|
||||
MaxSize int
|
||||
// StatusMsg of the bot
|
||||
StatusMsg string
|
||||
// Mailboxes config
|
||||
Mailboxes Mailboxes
|
||||
// Admins holds list of admin users (wildcards supported), e.g.: @*:example.com, @bot.*:example.com, @admin:*. Empty = no admins
|
||||
Admins []string
|
||||
|
||||
@@ -33,8 +39,8 @@ type Config struct {
|
||||
// TLS config
|
||||
TLS TLS
|
||||
|
||||
// Sentry config
|
||||
Sentry Sentry
|
||||
// Monitoring config
|
||||
Monitoring Monitoring
|
||||
}
|
||||
|
||||
// DB config
|
||||
@@ -47,13 +53,22 @@ 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
|
||||
Activation 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.
|
||||
@@ -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
|
||||
|
||||
225
email/email.go
Normal file
225
email/email.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"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
|
||||
}
|
||||
|
||||
// New constructs Email object
|
||||
func New(messageID, inReplyTo, references, subject, from, to, rcptto, cc, text, html string, files []*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,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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(e.From)
|
||||
}
|
||||
if options.Recipient {
|
||||
text.WriteString(" ➡️ ")
|
||||
text.WriteString(e.To)
|
||||
}
|
||||
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")
|
||||
}
|
||||
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 = utils.RelatesTo(options.Threads, threadID)
|
||||
|
||||
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)
|
||||
}
|
||||
35
go.mod
35
go.mod
@@ -8,48 +8,53 @@ require (
|
||||
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.7
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
github.com/mileusna/crontab v1.2.0
|
||||
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39
|
||||
gitlab.com/etke.cc/go/env v1.0.0
|
||||
gitlab.com/etke.cc/go/healthchecks v1.0.1
|
||||
gitlab.com/etke.cc/go/logger v1.1.0
|
||||
gitlab.com/etke.cc/go/mxidwc v1.0.0
|
||||
gitlab.com/etke.cc/go/secgen v1.1.1
|
||||
gitlab.com/etke.cc/go/trysmtp v1.0.0
|
||||
gitlab.com/etke.cc/go/validator v1.0.1
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20221008191655-865ae3362a01
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af
|
||||
maunium.net/go/mautrix v0.12.1
|
||||
gitlab.com/etke.cc/go/trysmtp v1.1.2
|
||||
gitlab.com/etke.cc/go/validator v1.0.6
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20230213101923-10ee6beb7577
|
||||
maunium.net/go/mautrix v0.13.0
|
||||
)
|
||||
|
||||
require (
|
||||
blitiri.com.ar/go/spf v1.5.1 // 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/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/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.17 // 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/rs/zerolog v1.29.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.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/yuin/goldmark v1.4.13 // indirect
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b // indirect
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
github.com/yuin/goldmark v1.5.4 // indirect
|
||||
golang.org/x/crypto v0.6.0 // indirect
|
||||
golang.org/x/net v0.6.0 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/text v0.7.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.3.2 // indirect
|
||||
)
|
||||
|
||||
73
go.sum
73
go.sum
@@ -1,3 +1,5 @@
|
||||
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/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=
|
||||
@@ -16,6 +18,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=
|
||||
@@ -28,32 +32,37 @@ github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0
|
||||
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/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/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.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
|
||||
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
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.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
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.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=
|
||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ=
|
||||
github.com/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=
|
||||
@@ -67,17 +76,17 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
||||
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/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
||||
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
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.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
@@ -85,30 +94,32 @@ 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.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
|
||||
github.com/yuin/goldmark v1.5.4/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/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/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/mxidwc v1.0.0 h1:6EAlJXvs3nU4RaMegYq6iFlyVvLw7JZYnZmNCGMYQP0=
|
||||
gitlab.com/etke.cc/go/mxidwc v1.0.0/go.mod h1:E/0kh45SAN9+ntTG0cwkAEKdaPxzvxVmnjwivm9nmz8=
|
||||
gitlab.com/etke.cc/go/secgen v1.1.1 h1:RmKOki725HIhWJHzPtAc9X4YvBneczndchpMgoDkE8w=
|
||||
gitlab.com/etke.cc/go/secgen v1.1.1/go.mod h1:3pJqRGeWApzx7qXjABqz2o2SMCNpKSZao/gXVdasqE8=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.0.0 h1:f/7gSmzohKniVeLSLevI+ZsySYcPUGkT9cRlOTwjOr8=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.0.0/go.mod h1:KqRuIB2IPElEEbAxXmFyKtm7S5YiuEb4lxwWthccqyE=
|
||||
gitlab.com/etke.cc/go/validator v1.0.1 h1:xp1tAzgCu9A1pga8rFUo7hODaEcCR1nkkodw96+dYuA=
|
||||
gitlab.com/etke.cc/go/validator v1.0.1/go.mod h1:3vdssRG4LwgdTr9IHz9MjGSEO+3/FO9hXPGMuSeweJ8=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20221008191655-865ae3362a01 h1:rlcxjSCCG18sbNT2CsCRKjtwQ2UjkuTutkRHSCGhhxs=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20221008191655-865ae3362a01/go.mod h1:HkUHUkhbkDueEJVc7h/zBfz2hjhl4xxjQKv9Itrdf9k=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.1.2 h1:Jg+J/zgx77wqJ9J5L5dlKFoQLka6yYTXa2wf+GNhjnQ=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.1.2/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-20230213101923-10ee6beb7577 h1:MXvoTJ9Qp+dWezXR2sXP7HTk9ijXAorEDAy/bxJOxi8=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20230213101923-10ee6beb7577/go.mod h1:yjDzaWyCnr/jiK0ArcOZlynVp2j7nqobOL2m1egkKFI=
|
||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0=
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
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-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4=
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -116,15 +127,17 @@ 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-20221006211917-84dc82d7e875 h1:AzgQNqF+FKwyQ5LbVrVqOcuuFB67N47F9+htZYH0wFM=
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.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.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
|
||||
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.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
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=
|
||||
@@ -133,5 +146,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
|
||||
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
|
||||
maunium.net/go/mautrix v0.12.1 h1:MLfkWkpRIgUn7lueDSlPwYOeIuGF+NrAHW0hInIuVAw=
|
||||
maunium.net/go/mautrix v0.12.1/go.mod h1:/jxQFIipObSsjZPH6o3xyUi8uoULz3Hfr/8p9loqpYE=
|
||||
maunium.net/go/mautrix v0.13.0 h1:CRdpMFc1kDSNnCZMcqahR9/pkDy/vgRbd+fHnSCl6Yg=
|
||||
maunium.net/go/mautrix v0.13.0/go.mod h1:gYMQPsZ9lQpyKlVp+DGwOuc9LIcE/c8GZW2CvKHISgM=
|
||||
|
||||
44
justfile
Normal file
44
justfile
Normal file
@@ -0,0 +1,44 @@
|
||||
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 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 }} .
|
||||
100
smtp/fswatcher.go
Normal file
100
smtp/fswatcher.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
)
|
||||
|
||||
const fsdelay = 100 * time.Millisecond
|
||||
|
||||
type FSWatcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
files []string
|
||||
log *logger.Logger
|
||||
mu sync.Mutex
|
||||
t map[string]*time.Timer
|
||||
}
|
||||
|
||||
func NewFSWatcher(files []string, loglevel string) (*FSWatcher, error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
err = watcher.Add(filepath.Dir(file))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
fswatcher := &FSWatcher{
|
||||
watcher: watcher,
|
||||
files: files,
|
||||
log: logger.New("fs.", loglevel),
|
||||
t: make(map[string]*time.Timer),
|
||||
}
|
||||
|
||||
return fswatcher, nil
|
||||
}
|
||||
|
||||
func (w *FSWatcher) watch(handler func(e fsnotify.Event)) {
|
||||
for {
|
||||
select {
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.log.Error("%v", err)
|
||||
case e, ok := <-w.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
handler(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start watcher
|
||||
func (w *FSWatcher) Start(handler func(e fsnotify.Event)) {
|
||||
w.watch(func(e fsnotify.Event) {
|
||||
var found bool
|
||||
for _, f := range w.files {
|
||||
if f == e.Name {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
t, ok := w.t[e.Name]
|
||||
w.mu.Unlock()
|
||||
if !ok {
|
||||
t = time.AfterFunc(math.MaxInt64, func() {
|
||||
w.log.Info("handling fs event %+v", e)
|
||||
handler(e)
|
||||
})
|
||||
t.Stop()
|
||||
|
||||
w.mu.Lock()
|
||||
w.t[e.Name] = t
|
||||
w.mu.Unlock()
|
||||
}
|
||||
t.Reset(fsdelay)
|
||||
})
|
||||
}
|
||||
|
||||
// Stop watcher
|
||||
func (w *FSWatcher) Stop() {
|
||||
err := w.watcher.Close()
|
||||
if err != nil {
|
||||
w.log.Error("cannot stop fs watcher: %v", err)
|
||||
}
|
||||
}
|
||||
87
smtp/listener.go
Normal file
87
smtp/listener.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
)
|
||||
|
||||
// Listener that rejects connections from banned hosts
|
||||
type Listener struct {
|
||||
log *logger.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 *logger.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("cannot accept connection: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if l.isBanned(conn.RemoteAddr()) {
|
||||
conn.Close()
|
||||
l.log.Info("rejected connection from %q (already banned)", conn.RemoteAddr())
|
||||
continue
|
||||
}
|
||||
|
||||
l.log.Info("accepted connection from %q", conn.RemoteAddr())
|
||||
|
||||
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()
|
||||
}
|
||||
29
smtp/logger.go
Normal file
29
smtp/logger.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// loggerWrapper is a wrapper around logger.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
|
||||
}
|
||||
199
smtp/manager.go
Normal file
199
smtp/manager.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"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
|
||||
|
||||
LogLevel string
|
||||
MaxSize int
|
||||
Bot matrixbot
|
||||
Callers []Caller
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
Listener *Listener
|
||||
Config *tls.Config
|
||||
Certs []string
|
||||
Keys []string
|
||||
Port string
|
||||
Mu sync.Mutex
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
log *logger.Logger
|
||||
bot matrixbot
|
||||
fsw *FSWatcher
|
||||
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
|
||||
Ban(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 {
|
||||
log := logger.New("smtp.", cfg.LogLevel)
|
||||
mailsrv := &mailServer{
|
||||
log: log,
|
||||
bot: cfg.Bot,
|
||||
domains: cfg.Domains,
|
||||
}
|
||||
for _, caller := range cfg.Callers {
|
||||
caller.SetSendmail(mailsrv.SendEmail)
|
||||
}
|
||||
|
||||
s := smtp.NewServer(mailsrv)
|
||||
s.ErrorLog = loggerWrapper{func(s string, i ...interface{}) { log.Error(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]
|
||||
}
|
||||
if log.GetLevel() == "INFO" || log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
|
||||
s.Debug = loggerWriter{func(s string) { log.Info(s) }}
|
||||
}
|
||||
|
||||
fsw, err := NewFSWatcher(append(cfg.TLSCerts, cfg.TLSKeys...), cfg.LogLevel)
|
||||
if err != nil {
|
||||
log.Error("cannot start FS watcher: %v", err)
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
smtp: s,
|
||||
bot: cfg.Bot,
|
||||
log: log,
|
||||
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() {
|
||||
m.fsw.Stop()
|
||||
err := m.smtp.Close()
|
||||
if err != nil {
|
||||
m.log.Error("cannot stop SMTP server properly: %v", err)
|
||||
}
|
||||
m.log.Info("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("cannot start listener on %s: %v", port, err)
|
||||
m.errs <- err
|
||||
return
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
m.tls.Listener = lwrapper
|
||||
}
|
||||
m.log.Info("Starting SMTP server on port %s", port)
|
||||
|
||||
err = m.smtp.Serve(lwrapper)
|
||||
if err != nil {
|
||||
m.log.Error("cannot start SMTP server on %s: %v", port, err)
|
||||
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("(re)loading TLS config")
|
||||
if len(m.tls.Certs) == 0 || len(m.tls.Keys) == 0 {
|
||||
m.log.Warn("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("cannot load SSL certificate: %v", err)
|
||||
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,125 +0,0 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"gitlab.com/etke.cc/go/validator"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// msasession represents an SMTP-submission session.
|
||||
// This can be used in 2 directions:
|
||||
// - receiving emails from remote servers, in which case: `incoming = true`
|
||||
// - sending emails from local users, in which case: `incoming = false`
|
||||
type msasession struct {
|
||||
log *logger.Logger
|
||||
bot Bot
|
||||
mta utils.MTA
|
||||
domain string
|
||||
|
||||
ctx context.Context
|
||||
incoming bool
|
||||
to string
|
||||
from string
|
||||
}
|
||||
|
||||
func (s *msasession) Mail(from string, opts smtp.MailOptions) error {
|
||||
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
|
||||
if !utils.AddressValid(from) {
|
||||
return errors.New("please, provide email address")
|
||||
}
|
||||
if s.incoming {
|
||||
s.from = from
|
||||
s.log.Debug("mail from %s, options: %+v", from, opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *msasession) Rcpt(to string) error {
|
||||
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
|
||||
s.to = to
|
||||
|
||||
if s.incoming {
|
||||
if utils.Hostname(to) != s.domain {
|
||||
s.log.Debug("wrong domain of %s", to)
|
||||
return smtp.ErrAuthRequired
|
||||
}
|
||||
|
||||
roomID, ok := s.bot.GetMapping(utils.Mailbox(to))
|
||||
if !ok {
|
||||
s.log.Debug("mapping for %s not found", to)
|
||||
return smtp.ErrAuthRequired
|
||||
}
|
||||
|
||||
validations := s.bot.GetIFOptions(roomID)
|
||||
if !s.validate(validations) {
|
||||
return smtp.ErrAuthRequired
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Debug("mail to %s", to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *msasession) parseAttachments(parts []*enmime.Part) []*utils.File {
|
||||
files := make([]*utils.File, 0, len(parts))
|
||||
for _, attachment := range parts {
|
||||
for _, err := range attachment.Errors {
|
||||
s.log.Warn("attachment error: %v", err)
|
||||
}
|
||||
file := utils.NewFile(attachment.FileName, attachment.Content)
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func (s *msasession) validate(options utils.IncomingFilteringOptions) bool {
|
||||
spam := validator.Spam{
|
||||
Emails: options.SpamlistEmails(),
|
||||
Hosts: options.SpamlistHosts(),
|
||||
Localparts: options.SpamlistLocalparts(),
|
||||
}
|
||||
enforce := validator.Enforce{
|
||||
MX: options.SpamcheckMX(),
|
||||
SMTP: options.SpamcheckMX(),
|
||||
}
|
||||
v := validator.New(spam, enforce, s.to, s.log)
|
||||
|
||||
return v.Email(s.from)
|
||||
}
|
||||
|
||||
func (s *msasession) Data(r io.Reader) error {
|
||||
parser := enmime.NewParser()
|
||||
eml, err := parser.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files := s.parseAttachments(eml.Attachments)
|
||||
|
||||
email := utils.NewEmail(
|
||||
eml.GetHeader("Message-Id"),
|
||||
eml.GetHeader("In-Reply-To"),
|
||||
eml.GetHeader("Subject"),
|
||||
s.from,
|
||||
s.to,
|
||||
eml.Text,
|
||||
eml.HTML,
|
||||
files)
|
||||
|
||||
return s.bot.Send2Matrix(s.ctx, email, s.incoming)
|
||||
}
|
||||
|
||||
func (s *msasession) Reset() {}
|
||||
|
||||
func (s *msasession) Logout() error {
|
||||
return nil
|
||||
}
|
||||
60
smtp/mta.go
60
smtp/mta.go
@@ -1,60 +0,0 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"gitlab.com/etke.cc/go/trysmtp"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// Bot interface to send emails into matrix
|
||||
type Bot interface {
|
||||
AllowAuth(string, string) bool
|
||||
GetMapping(string) (id.RoomID, bool)
|
||||
GetIFOptions(id.RoomID) utils.IncomingFilteringOptions
|
||||
Send2Matrix(ctx context.Context, email *utils.Email, incoming bool) error
|
||||
SetMTA(mta utils.MTA)
|
||||
}
|
||||
|
||||
// mta is Mail Transfer Agent
|
||||
type mta struct {
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
func NewMTA(loglevel string) utils.MTA {
|
||||
return &mta{
|
||||
log: logger.New("smtp/mta.", loglevel),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mta) Send(from, to, data string) error {
|
||||
m.log.Debug("Sending email from %s to %s", from, to)
|
||||
conn, err := trysmtp.Connect(from, to)
|
||||
if err != nil {
|
||||
m.log.Error("cannot connect to SMTP server of %s: %v", to, err)
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var w io.WriteCloser
|
||||
w, err = conn.Data()
|
||||
if err != nil {
|
||||
m.log.Error("cannot send DATA command: %v", err)
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
m.log.Debug("sending DATA:\n%s", data)
|
||||
_, err = strings.NewReader(data).WriteTo(w)
|
||||
if err != nil {
|
||||
m.log.Debug("cannot write DATA: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
m.log.Debug("email has been sent")
|
||||
return nil
|
||||
}
|
||||
200
smtp/server.go
200
smtp/server.go
@@ -1,128 +1,126 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/getsentry/sentry-go"
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"gitlab.com/etke.cc/go/trysmtp"
|
||||
|
||||
"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 *logger.Logger
|
||||
domains []string
|
||||
}
|
||||
|
||||
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("Login state=%+v username=%+v", state, username)
|
||||
if m.bot.IsBanned(state.RemoteAddr) {
|
||||
return nil, ErrBanned
|
||||
}
|
||||
|
||||
port string
|
||||
tlsPort string
|
||||
tlsCfg *tls.Config
|
||||
if !email.AddressValid(username) {
|
||||
m.log.Debug("address %s is invalid", username)
|
||||
m.bot.Ban(state.RemoteAddr)
|
||||
return nil, ErrBanned
|
||||
}
|
||||
|
||||
roomID, allow := m.bot.AllowAuth(username, password)
|
||||
if !allow {
|
||||
m.log.Debug("username=%s or password=<redacted> is invalid", username)
|
||||
m.bot.Ban(state.RemoteAddr)
|
||||
return nil, ErrBanned
|
||||
}
|
||||
|
||||
return &outgoingSession{
|
||||
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
|
||||
sendmail: m.SendEmail,
|
||||
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("AnonymousLogin state=%+v", state)
|
||||
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.Ban,
|
||||
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)
|
||||
// SendEmail to external mail server
|
||||
func (m *mailServer) SendEmail(from, to, data string) error {
|
||||
m.log.Debug("Sending email from %s to %s", from, to)
|
||||
conn, err := trysmtp.Connect(from, to)
|
||||
if conn == nil {
|
||||
m.log.Error("cannot connect to SMTP server of %s: %v", to, err)
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Error("cannot start listener on %s: %v", port, err)
|
||||
s.errs <- err
|
||||
return
|
||||
m.log.Warn("connection to the SMTP server of %s returned the following non-fatal error(-s): %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
s.log.Info("Starting SMTP server on port %s", port)
|
||||
|
||||
err = s.msa.Serve(l)
|
||||
var w io.WriteCloser
|
||||
w, err = conn.Data()
|
||||
if err != nil {
|
||||
s.log.Error("cannot start SMTP server on %s: %v", port, err)
|
||||
s.errs <- err
|
||||
close(s.errs)
|
||||
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 (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"
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"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 *logger.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("address %s is invalid", from)
|
||||
s.ban(s.addr)
|
||||
return ErrBanned
|
||||
}
|
||||
s.from = from
|
||||
s.log.Debug("mail from %s, options: %+v", from, opts)
|
||||
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("wrong domain of %s", to)
|
||||
return ErrNoUser
|
||||
}
|
||||
|
||||
var ok bool
|
||||
s.roomID, ok = s.getRoomID(utils.Mailbox(to))
|
||||
if !ok {
|
||||
s.log.Debug("mapping for %s not found", to)
|
||||
return ErrNoUser
|
||||
}
|
||||
|
||||
s.log.Debug("mail to %s", to)
|
||||
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("real address: %s", realAddr.String())
|
||||
return realAddr
|
||||
}
|
||||
|
||||
func (s *incomingSession) Data(r io.Reader) error {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
s.log.Error("cannot read DATA: %v", err)
|
||||
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("cannot verify DKIM: %v", verr)
|
||||
return verr
|
||||
}
|
||||
for _, result := range results {
|
||||
if result.Err != nil {
|
||||
s.log.Info("DKIM verification of %q failed: %v", result.Domain, result.Err)
|
||||
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 *logger.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("wrong domain of %s", from)
|
||||
return ErrNoUser
|
||||
}
|
||||
|
||||
roomID, ok := s.getRoomID(utils.Mailbox(from))
|
||||
if !ok {
|
||||
s.log.Debug("mapping for %s not found", from)
|
||||
return ErrNoUser
|
||||
}
|
||||
if s.fromRoom != roomID {
|
||||
s.log.Warn("sender from %q tries to impersonate %q", s.fromRoom, roomID)
|
||||
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("mail to %s", to)
|
||||
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 *logger.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, log)
|
||||
|
||||
return v.Email(from, sender)
|
||||
}
|
||||
211
utils/email.go
211
utils/email.go
@@ -1,211 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// MTA is mail transfer agent
|
||||
type MTA interface {
|
||||
Send(from, to, data string) error
|
||||
}
|
||||
|
||||
// IncomingFilteringOptions for incoming mail
|
||||
type IncomingFilteringOptions interface {
|
||||
SpamcheckSMTP() bool
|
||||
SpamcheckMX() bool
|
||||
SpamlistEmails() []string
|
||||
SpamlistHosts() []string
|
||||
SpamlistLocalparts() []string
|
||||
}
|
||||
|
||||
// Email object
|
||||
type Email struct {
|
||||
Date string
|
||||
MessageID string
|
||||
InReplyTo string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Text string
|
||||
HTML string
|
||||
Files []*File
|
||||
}
|
||||
|
||||
// ContentOptions represents settings that specify how an email is to be converted to a Matrix message
|
||||
type ContentOptions struct {
|
||||
// On/Off
|
||||
Sender bool
|
||||
Recipient bool
|
||||
Subject bool
|
||||
HTML bool
|
||||
Threads bool
|
||||
|
||||
// Keys
|
||||
MessageIDKey string
|
||||
InReplyToKey string
|
||||
SubjectKey string
|
||||
FromKey string
|
||||
}
|
||||
|
||||
// AddressValid checks if email address is valid
|
||||
func AddressValid(email string) bool {
|
||||
_, err := mail.ParseAddress(email)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// NewEmail constructs Email object
|
||||
func NewEmail(messageID, inReplyTo, subject, from, to, text, html string, files []*File) *Email {
|
||||
email := &Email{
|
||||
Date: time.Now().UTC().Format(time.RFC1123Z),
|
||||
MessageID: messageID,
|
||||
InReplyTo: inReplyTo,
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Text: text,
|
||||
HTML: html,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
if html != "" {
|
||||
var err error
|
||||
html, err = StripHTMLTag(html, "style")
|
||||
if err == nil {
|
||||
email.HTML = html
|
||||
}
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
// Mailbox returns postmoogle's mailbox, parsing it from FROM (if incoming=false) or TO (incoming=true)
|
||||
func (e *Email) Mailbox(incoming bool) string {
|
||||
if incoming {
|
||||
return Mailbox(e.To)
|
||||
}
|
||||
return Mailbox(e.From)
|
||||
}
|
||||
|
||||
// Content converts the email object to a Matrix event content
|
||||
func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Content {
|
||||
var text strings.Builder
|
||||
if options.Sender {
|
||||
text.WriteString("From: ")
|
||||
text.WriteString(e.From)
|
||||
text.WriteString("\n")
|
||||
}
|
||||
if options.Recipient {
|
||||
text.WriteString("To: ")
|
||||
text.WriteString(e.To)
|
||||
text.WriteString("\n")
|
||||
}
|
||||
if options.Subject {
|
||||
text.WriteString("# ")
|
||||
text.WriteString(e.Subject)
|
||||
text.WriteString("\n\n")
|
||||
}
|
||||
if e.HTML != "" && options.HTML {
|
||||
text.WriteString(format.HTMLToMarkdown(e.HTML))
|
||||
} else {
|
||||
text.WriteString(e.Text)
|
||||
}
|
||||
|
||||
parsed := format.RenderMarkdown(text.String(), true, true)
|
||||
parsed.RelatesTo = RelatesTo(options.Threads, threadID)
|
||||
|
||||
content := event.Content{
|
||||
Raw: map[string]interface{}{
|
||||
options.MessageIDKey: e.MessageID,
|
||||
options.InReplyToKey: e.InReplyTo,
|
||||
options.SubjectKey: e.Subject,
|
||||
options.FromKey: e.From,
|
||||
},
|
||||
Parsed: parsed,
|
||||
}
|
||||
return &content
|
||||
}
|
||||
|
||||
// Compose converts the email object to a string (to be used for delivery via SMTP) and possibly DKIM-signs it
|
||||
func (e *Email) Compose(privkey string) string {
|
||||
var data strings.Builder
|
||||
|
||||
domain := strings.SplitN(e.From, "@", 2)[1]
|
||||
|
||||
data.WriteString("Content-Type: text/plain; charset=\"UTF-8\"")
|
||||
data.WriteString("\r\n")
|
||||
|
||||
data.WriteString("Content-Transfer-Encoding: 8BIT")
|
||||
data.WriteString("\r\n")
|
||||
|
||||
data.WriteString("From: ")
|
||||
data.WriteString(e.From)
|
||||
data.WriteString("\r\n")
|
||||
|
||||
data.WriteString("To: ")
|
||||
data.WriteString(e.To)
|
||||
data.WriteString("\r\n")
|
||||
|
||||
data.WriteString("Message-Id: ")
|
||||
data.WriteString(e.MessageID)
|
||||
data.WriteString("\r\n")
|
||||
|
||||
data.WriteString("Date: ")
|
||||
data.WriteString(e.Date)
|
||||
data.WriteString("\r\n")
|
||||
|
||||
if e.InReplyTo != "" {
|
||||
data.WriteString("In-Reply-To: ")
|
||||
data.WriteString(e.InReplyTo)
|
||||
data.WriteString("\r\n")
|
||||
}
|
||||
|
||||
data.WriteString("Subject: ")
|
||||
data.WriteString(e.Subject)
|
||||
data.WriteString("\r\n")
|
||||
|
||||
data.WriteString("\r\n")
|
||||
|
||||
data.WriteString(e.Text)
|
||||
data.WriteString("\r\n")
|
||||
|
||||
return e.sign(domain, privkey, data)
|
||||
}
|
||||
|
||||
func (e *Email) sign(domain, privkey string, data strings.Builder) string {
|
||||
if privkey == "" {
|
||||
return data.String()
|
||||
}
|
||||
pemblock, _ := pem.Decode([]byte(privkey))
|
||||
if pemblock == nil {
|
||||
return data.String()
|
||||
}
|
||||
parsedkey, err := x509.ParsePKCS8PrivateKey(pemblock.Bytes)
|
||||
if err != nil {
|
||||
return data.String()
|
||||
}
|
||||
signer := parsedkey.(crypto.Signer)
|
||||
|
||||
options := &dkim.SignOptions{
|
||||
Domain: domain,
|
||||
Selector: "postmoogle",
|
||||
Signer: signer,
|
||||
}
|
||||
|
||||
var msg strings.Builder
|
||||
err = dkim.Sign(&msg, strings.NewReader(data.String()), options)
|
||||
if err != nil {
|
||||
return data.String()
|
||||
}
|
||||
|
||||
return msg.String()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
41
utils/mail.go
Normal file
41
utils/mail.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package utils
|
||||
|
||||
import "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]
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Hostname returns hostname part from email address
|
||||
func Hostname(email string) string {
|
||||
return email[strings.LastIndex(email, "@")+1:]
|
||||
}
|
||||
@@ -32,16 +32,17 @@ func EventParent(currentID id.EventID, content *event.MessageEventContent) id.Ev
|
||||
return currentID
|
||||
}
|
||||
|
||||
if content.GetRelatesTo() == nil {
|
||||
relation := content.OptionalGetRelatesTo()
|
||||
if relation == nil {
|
||||
return currentID
|
||||
}
|
||||
|
||||
threadParent := content.RelatesTo.GetThreadParent()
|
||||
threadParent := relation.GetThreadParent()
|
||||
if threadParent != "" {
|
||||
return threadParent
|
||||
}
|
||||
|
||||
replyParent := content.RelatesTo.GetReplyTo()
|
||||
replyParent := relation.GetReplyTo()
|
||||
if replyParent != "" {
|
||||
return replyParent
|
||||
}
|
||||
@@ -50,7 +51,7 @@ func EventParent(currentID id.EventID, content *event.MessageEventContent) id.Ev
|
||||
}
|
||||
|
||||
// EventField returns field value from raw event content
|
||||
func EventField[T comparable](content *event.Content, field string) T {
|
||||
func EventField[T any](content *event.Content, field string) T {
|
||||
var zero T
|
||||
raw := content.Raw[field]
|
||||
if raw == nil {
|
||||
@@ -65,6 +66,16 @@ func EventField[T comparable](content *event.Content, field string) T {
|
||||
return v
|
||||
}
|
||||
|
||||
func ParseContent(evt *event.Event, eventType event.Type) {
|
||||
if evt.Content.Parsed != nil {
|
||||
return
|
||||
}
|
||||
perr := evt.Content.ParseRaw(eventType)
|
||||
if perr != nil {
|
||||
log.Error("cannot parse event content: %v", perr)
|
||||
}
|
||||
}
|
||||
|
||||
// UnwrapError tries to unwrap a error into something meaningful, like mautrix.HTTPError or mautrix.RespError
|
||||
func UnwrapError(err error) error {
|
||||
switch err.(type) {
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,22 +1,52 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
)
|
||||
|
||||
// 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 (
|
||||
log *logger.Logger
|
||||
domains []string
|
||||
)
|
||||
|
||||
// SetLogger for utils
|
||||
func SetLogger(loggerInstance *logger.Logger) {
|
||||
log = loggerInstance
|
||||
}
|
||||
|
||||
// Hostname returns hostname part from email address
|
||||
func Hostname(email string) string {
|
||||
return email[strings.LastIndex(email, "@")+1:]
|
||||
// SetDomains for later use
|
||||
func SetDomains(slice []string) {
|
||||
domains = slice
|
||||
}
|
||||
|
||||
// AddrIP returns IP from a network address
|
||||
func AddrIP(addr net.Addr) string {
|
||||
key := addr.String()
|
||||
host, _, _ := net.SplitHostPort(key) //nolint:errcheck // either way it's ok
|
||||
if host != "" {
|
||||
key = host
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// SanitizeDomain checks that input domain is available for use
|
||||
func SanitizeDomain(domain string) string {
|
||||
domain = strings.TrimSpace(domain)
|
||||
if domain == "" {
|
||||
return domains[0]
|
||||
}
|
||||
|
||||
for _, allowed := range domains {
|
||||
if domain == allowed {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
|
||||
return domains[0]
|
||||
}
|
||||
|
||||
// Bool converts string to boolean
|
||||
@@ -34,6 +64,25 @@ 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
|
||||
}
|
||||
|
||||
// SanitizeBoolString converts string to integer and back to string
|
||||
func SanitizeIntString(str string) string {
|
||||
return strconv.Itoa(Int(str))
|
||||
}
|
||||
|
||||
// StringSlice converts comma-separated string to slice
|
||||
func StringSlice(str string) []string {
|
||||
if str == "" {
|
||||
|
||||
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
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}
|
||||
}
|
||||
89
vendor/github.com/emersion/go-sasl/login.go
generated
vendored
Normal file
89
vendor/github.com/emersion/go-sasl/login.go
generated
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
package sasl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
)
|
||||
|
||||
// The LOGIN mechanism name.
|
||||
const Login = "LOGIN"
|
||||
|
||||
var expectedChallenge = []byte("Password:")
|
||||
|
||||
type loginClient struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func (a *loginClient) Start() (mech string, ir []byte, err error) {
|
||||
mech = "LOGIN"
|
||||
ir = []byte(a.Username)
|
||||
return
|
||||
}
|
||||
|
||||
func (a *loginClient) Next(challenge []byte) (response []byte, err error) {
|
||||
if bytes.Compare(challenge, expectedChallenge) != 0 {
|
||||
return nil, ErrUnexpectedServerChallenge
|
||||
} else {
|
||||
return []byte(a.Password), nil
|
||||
}
|
||||
}
|
||||
|
||||
// A client implementation of the LOGIN authentication mechanism for SMTP,
|
||||
// as described in http://www.iana.org/go/draft-murchison-sasl-login
|
||||
//
|
||||
// It is considered obsolete, and should not be used when other mechanisms are
|
||||
// available. For plaintext password authentication use PLAIN mechanism.
|
||||
func NewLoginClient(username, password string) Client {
|
||||
return &loginClient{username, password}
|
||||
}
|
||||
|
||||
// Authenticates users with an username and a password.
|
||||
type LoginAuthenticator func(username, password string) error
|
||||
|
||||
type loginState int
|
||||
|
||||
const (
|
||||
loginNotStarted loginState = iota
|
||||
loginWaitingUsername
|
||||
loginWaitingPassword
|
||||
)
|
||||
|
||||
type loginServer struct {
|
||||
state loginState
|
||||
username, password string
|
||||
authenticate LoginAuthenticator
|
||||
}
|
||||
|
||||
// A server implementation of the LOGIN authentication mechanism, as described
|
||||
// in https://tools.ietf.org/html/draft-murchison-sasl-login-00.
|
||||
//
|
||||
// LOGIN is obsolete and should only be enabled for legacy clients that cannot
|
||||
// be updated to use PLAIN.
|
||||
func NewLoginServer(authenticator LoginAuthenticator) Server {
|
||||
return &loginServer{authenticate: authenticator}
|
||||
}
|
||||
|
||||
func (a *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
|
||||
switch a.state {
|
||||
case loginNotStarted:
|
||||
// Check for initial response field, as per RFC4422 section 3
|
||||
if response == nil {
|
||||
challenge = []byte("Username:")
|
||||
break
|
||||
}
|
||||
a.state++
|
||||
fallthrough
|
||||
case loginWaitingUsername:
|
||||
a.username = string(response)
|
||||
challenge = []byte("Password:")
|
||||
case loginWaitingPassword:
|
||||
a.password = string(response)
|
||||
err = a.authenticate(a.username, a.password)
|
||||
done = true
|
||||
default:
|
||||
err = ErrUnexpectedClientResponse
|
||||
}
|
||||
|
||||
a.state++
|
||||
return
|
||||
}
|
||||
191
vendor/github.com/emersion/go-sasl/oauthbearer.go
generated
vendored
Normal file
191
vendor/github.com/emersion/go-sasl/oauthbearer.go
generated
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
package sasl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The OAUTHBEARER mechanism name.
|
||||
const OAuthBearer = "OAUTHBEARER"
|
||||
|
||||
type OAuthBearerError struct {
|
||||
Status string `json:"status"`
|
||||
Schemes string `json:"schemes"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type OAuthBearerOptions struct {
|
||||
Username string
|
||||
Token string
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
// Implements error
|
||||
func (err *OAuthBearerError) Error() string {
|
||||
return fmt.Sprintf("OAUTHBEARER authentication error (%v)", err.Status)
|
||||
}
|
||||
|
||||
type oauthBearerClient struct {
|
||||
OAuthBearerOptions
|
||||
}
|
||||
|
||||
func (a *oauthBearerClient) Start() (mech string, ir []byte, err error) {
|
||||
mech = OAuthBearer
|
||||
var str = "n,a=" + a.Username + ","
|
||||
|
||||
if a.Host != "" {
|
||||
str += "\x01host=" + a.Host
|
||||
}
|
||||
|
||||
if a.Port != 0 {
|
||||
str += "\x01port=" + strconv.Itoa(a.Port)
|
||||
}
|
||||
str += "\x01auth=Bearer " + a.Token + "\x01\x01"
|
||||
ir = []byte(str)
|
||||
return
|
||||
}
|
||||
|
||||
func (a *oauthBearerClient) Next(challenge []byte) ([]byte, error) {
|
||||
authBearerErr := &OAuthBearerError{}
|
||||
if err := json.Unmarshal(challenge, authBearerErr); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return nil, authBearerErr
|
||||
}
|
||||
}
|
||||
|
||||
// An implementation of the OAUTHBEARER authentication mechanism, as
|
||||
// described in RFC 7628.
|
||||
func NewOAuthBearerClient(opt *OAuthBearerOptions) Client {
|
||||
return &oauthBearerClient{*opt}
|
||||
}
|
||||
|
||||
type OAuthBearerAuthenticator func(opts OAuthBearerOptions) *OAuthBearerError
|
||||
|
||||
type oauthBearerServer struct {
|
||||
done bool
|
||||
failErr error
|
||||
authenticate OAuthBearerAuthenticator
|
||||
}
|
||||
|
||||
func (a *oauthBearerServer) fail(descr string) ([]byte, bool, error) {
|
||||
blob, err := json.Marshal(OAuthBearerError{
|
||||
Status: "invalid_request",
|
||||
Schemes: "bearer",
|
||||
})
|
||||
if err != nil {
|
||||
panic(err) // wtf
|
||||
}
|
||||
a.failErr = errors.New(descr)
|
||||
return blob, false, nil
|
||||
}
|
||||
|
||||
func (a *oauthBearerServer) Next(response []byte) (challenge []byte, done bool, err error) {
|
||||
// Per RFC, we cannot just send an error, we need to return JSON-structured
|
||||
// value as a challenge and then after getting dummy response from the
|
||||
// client stop the exchange.
|
||||
if a.failErr != nil {
|
||||
// Server libraries (go-smtp, go-imap) will not call Next on
|
||||
// protocol-specific SASL cancel response ('*'). However, GS2 (and
|
||||
// indirectly OAUTHBEARER) defines a protocol-independent way to do so
|
||||
// using 0x01.
|
||||
if len(response) != 1 && response[0] != 0x01 {
|
||||
return nil, true, errors.New("unexpected response")
|
||||
}
|
||||
return nil, true, a.failErr
|
||||
}
|
||||
|
||||
if a.done {
|
||||
err = ErrUnexpectedClientResponse
|
||||
return
|
||||
}
|
||||
|
||||
// Generate empty challenge.
|
||||
if response == nil {
|
||||
return []byte{}, false, nil
|
||||
}
|
||||
|
||||
a.done = true
|
||||
|
||||
// Cut n,a=username,\x01host=...\x01auth=...
|
||||
// into
|
||||
// n
|
||||
// a=username
|
||||
// \x01host=...\x01auth=...\x01\x01
|
||||
parts := bytes.SplitN(response, []byte{','}, 3)
|
||||
if len(parts) != 3 {
|
||||
return a.fail("Invalid response")
|
||||
}
|
||||
if !bytes.Equal(parts[0], []byte{'n'}) {
|
||||
return a.fail("Invalid response, missing 'n'")
|
||||
}
|
||||
opts := OAuthBearerOptions{}
|
||||
if !bytes.HasPrefix(parts[1], []byte("a=")) {
|
||||
return a.fail("Invalid response, missing 'a'")
|
||||
}
|
||||
opts.Username = string(bytes.TrimPrefix(parts[1], []byte("a=")))
|
||||
|
||||
// Cut \x01host=...\x01auth=...\x01\x01
|
||||
// into
|
||||
// *empty*
|
||||
// host=...
|
||||
// auth=...
|
||||
// *empty*
|
||||
//
|
||||
// Note that this code does not do a lot of checks to make sure the input
|
||||
// follows the exact format specified by RFC.
|
||||
params := bytes.Split(parts[2], []byte{0x01})
|
||||
for _, p := range params {
|
||||
// Skip empty fields (one at start and end).
|
||||
if len(p) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
pParts := bytes.SplitN(p, []byte{'='}, 2)
|
||||
if len(pParts) != 2 {
|
||||
return a.fail("Invalid response, missing '='")
|
||||
}
|
||||
|
||||
switch string(pParts[0]) {
|
||||
case "host":
|
||||
opts.Host = string(pParts[1])
|
||||
case "port":
|
||||
port, err := strconv.ParseUint(string(pParts[1]), 10, 16)
|
||||
if err != nil {
|
||||
return a.fail("Invalid response, malformed 'port' value")
|
||||
}
|
||||
opts.Port = int(port)
|
||||
case "auth":
|
||||
const prefix = "bearer "
|
||||
strValue := string(pParts[1])
|
||||
// Token type is case-insensitive.
|
||||
if !strings.HasPrefix(strings.ToLower(strValue), prefix) {
|
||||
return a.fail("Unsupported token type")
|
||||
}
|
||||
opts.Token = strValue[len(prefix):]
|
||||
default:
|
||||
return a.fail("Invalid response, unknown parameter: " + string(pParts[0]))
|
||||
}
|
||||
}
|
||||
|
||||
authzErr := a.authenticate(opts)
|
||||
if authzErr != nil {
|
||||
blob, err := json.Marshal(authzErr)
|
||||
if err != nil {
|
||||
panic(err) // wtf
|
||||
}
|
||||
a.failErr = authzErr
|
||||
return blob, false, nil
|
||||
}
|
||||
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
func NewOAuthBearerServer(auth OAuthBearerAuthenticator) Server {
|
||||
return &oauthBearerServer{authenticate: auth}
|
||||
}
|
||||
77
vendor/github.com/emersion/go-sasl/plain.go
generated
vendored
Normal file
77
vendor/github.com/emersion/go-sasl/plain.go
generated
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
package sasl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// The PLAIN mechanism name.
|
||||
const Plain = "PLAIN"
|
||||
|
||||
type plainClient struct {
|
||||
Identity string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func (a *plainClient) Start() (mech string, ir []byte, err error) {
|
||||
mech = "PLAIN"
|
||||
ir = []byte(a.Identity + "\x00" + a.Username + "\x00" + a.Password)
|
||||
return
|
||||
}
|
||||
|
||||
func (a *plainClient) Next(challenge []byte) (response []byte, err error) {
|
||||
return nil, ErrUnexpectedServerChallenge
|
||||
}
|
||||
|
||||
// A client implementation of the PLAIN authentication mechanism, as described
|
||||
// in RFC 4616. Authorization identity may be left blank to indicate that it is
|
||||
// the same as the username.
|
||||
func NewPlainClient(identity, username, password string) Client {
|
||||
return &plainClient{identity, username, password}
|
||||
}
|
||||
|
||||
// Authenticates users with an identity, a username and a password. If the
|
||||
// identity is left blank, it indicates that it is the same as the username.
|
||||
// If identity is not empty and the server doesn't support it, an error must be
|
||||
// returned.
|
||||
type PlainAuthenticator func(identity, username, password string) error
|
||||
|
||||
type plainServer struct {
|
||||
done bool
|
||||
authenticate PlainAuthenticator
|
||||
}
|
||||
|
||||
func (a *plainServer) Next(response []byte) (challenge []byte, done bool, err error) {
|
||||
if a.done {
|
||||
err = ErrUnexpectedClientResponse
|
||||
return
|
||||
}
|
||||
|
||||
// No initial response, send an empty challenge
|
||||
if response == nil {
|
||||
return []byte{}, false, nil
|
||||
}
|
||||
|
||||
a.done = true
|
||||
|
||||
parts := bytes.Split(response, []byte("\x00"))
|
||||
if len(parts) != 3 {
|
||||
err = errors.New("Invalid response")
|
||||
return
|
||||
}
|
||||
|
||||
identity := string(parts[0])
|
||||
username := string(parts[1])
|
||||
password := string(parts[2])
|
||||
|
||||
err = a.authenticate(identity, username, password)
|
||||
done = true
|
||||
return
|
||||
}
|
||||
|
||||
// A server implementation of the PLAIN authentication mechanism, as described
|
||||
// in RFC 4616.
|
||||
func NewPlainServer(authenticator PlainAuthenticator) Server {
|
||||
return &plainServer{authenticate: authenticator}
|
||||
}
|
||||
45
vendor/github.com/emersion/go-sasl/sasl.go
generated
vendored
Normal file
45
vendor/github.com/emersion/go-sasl/sasl.go
generated
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
// Library for Simple Authentication and Security Layer (SASL) defined in RFC 4422.
|
||||
package sasl
|
||||
|
||||
// Note:
|
||||
// Most of this code was copied, with some modifications, from net/smtp. It
|
||||
// would be better if Go provided a standard package (e.g. crypto/sasl) that
|
||||
// could be shared by SMTP, IMAP, and other packages.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Common SASL errors.
|
||||
var (
|
||||
ErrUnexpectedClientResponse = errors.New("sasl: unexpected client response")
|
||||
ErrUnexpectedServerChallenge = errors.New("sasl: unexpected server challenge")
|
||||
)
|
||||
|
||||
// Client interface to perform challenge-response authentication.
|
||||
type Client interface {
|
||||
// Begins SASL authentication with the server. It returns the
|
||||
// authentication mechanism name and "initial response" data (if required by
|
||||
// the selected mechanism). A non-nil error causes the client to abort the
|
||||
// authentication attempt.
|
||||
//
|
||||
// A nil ir value is different from a zero-length value. The nil value
|
||||
// indicates that the selected mechanism does not use an initial response,
|
||||
// while a zero-length value indicates an empty initial response, which must
|
||||
// be sent to the server.
|
||||
Start() (mech string, ir []byte, err error)
|
||||
|
||||
// Continues challenge-response authentication. A non-nil error causes
|
||||
// the client to abort the authentication attempt.
|
||||
Next(challenge []byte) (response []byte, err error)
|
||||
}
|
||||
|
||||
// Server interface to perform challenge-response authentication.
|
||||
type Server interface {
|
||||
// Begins or continues challenge-response authentication. If the client
|
||||
// supplies an initial response, response is non-nil.
|
||||
//
|
||||
// If the authentication is finished, done is set to true. If the
|
||||
// authentication has failed, an error is returned.
|
||||
Next(response []byte) (challenge []byte, done bool, err error)
|
||||
}
|
||||
19
vendor/github.com/emersion/go-smtp/.build.yml
generated
vendored
Normal file
19
vendor/github.com/emersion/go-smtp/.build.yml
generated
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
image: alpine/edge
|
||||
packages:
|
||||
- go
|
||||
# Required by codecov
|
||||
- bash
|
||||
- findutils
|
||||
sources:
|
||||
- https://github.com/emersion/go-smtp
|
||||
tasks:
|
||||
- build: |
|
||||
cd go-smtp
|
||||
go build -v ./...
|
||||
- test: |
|
||||
cd go-smtp
|
||||
go test -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
- upload-coverage: |
|
||||
cd go-smtp
|
||||
export CODECOV_TOKEN=3f257f71-a128-4834-8f68-2b534e9f4cb1
|
||||
curl -s https://codecov.io/bash | bash
|
||||
26
vendor/github.com/emersion/go-smtp/.gitignore
generated
vendored
Normal file
26
vendor/github.com/emersion/go-smtp/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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
|
||||
|
||||
/main.go
|
||||
24
vendor/github.com/emersion/go-smtp/LICENSE
generated
vendored
Normal file
24
vendor/github.com/emersion/go-smtp/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2010 The Go Authors
|
||||
Copyright (c) 2014 Gleez Technologies
|
||||
Copyright (c) 2016 emersion
|
||||
Copyright (c) 2016 Proton Technologies AG
|
||||
|
||||
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.
|
||||
151
vendor/github.com/emersion/go-smtp/README.md
generated
vendored
Normal file
151
vendor/github.com/emersion/go-smtp/README.md
generated
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
# go-smtp
|
||||
|
||||
[](https://godocs.io/github.com/emersion/go-smtp)
|
||||
[](https://builds.sr.ht/~emersion/go-smtp/commits?)
|
||||
[](https://codecov.io/gh/emersion/go-smtp)
|
||||
|
||||
An ESMTP client and server library written in Go.
|
||||
|
||||
## Features
|
||||
|
||||
* ESMTP client & server implementing [RFC 5321](https://tools.ietf.org/html/rfc5321)
|
||||
* Support for SMTP [AUTH](https://tools.ietf.org/html/rfc4954) and [PIPELINING](https://tools.ietf.org/html/rfc2920)
|
||||
* UTF-8 support for subject and message
|
||||
* [LMTP](https://tools.ietf.org/html/rfc2033) support
|
||||
|
||||
## Usage
|
||||
|
||||
### Client
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Set up authentication information.
|
||||
auth := sasl.NewPlainClient("", "user@example.com", "password")
|
||||
|
||||
// Connect to the server, authenticate, set the sender and recipient,
|
||||
// and send the email all in one step.
|
||||
to := []string{"recipient@example.net"}
|
||||
msg := strings.NewReader("To: recipient@example.net\r\n" +
|
||||
"Subject: discount Gophers!\r\n" +
|
||||
"\r\n" +
|
||||
"This is the email body.\r\n")
|
||||
err := smtp.SendMail("mail.example.com:25", auth, "sender@example.org", to, msg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you need more control, you can use `Client` instead.
|
||||
|
||||
### Server
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
// The Backend implements SMTP server methods.
|
||||
type Backend struct{}
|
||||
|
||||
// Login handles a login command with username and password.
|
||||
func (bkd *Backend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||
if username != "username" || password != "password" {
|
||||
return nil, errors.New("Invalid username or password")
|
||||
}
|
||||
return &Session{}, nil
|
||||
}
|
||||
|
||||
// AnonymousLogin requires clients to authenticate using SMTP AUTH before sending emails
|
||||
func (bkd *Backend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||
return nil, smtp.ErrAuthRequired
|
||||
}
|
||||
|
||||
// A Session is returned after successful login.
|
||||
type Session struct{}
|
||||
|
||||
func (s *Session) Mail(from string, opts smtp.MailOptions) error {
|
||||
log.Println("Mail from:", from)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Rcpt(to string) error {
|
||||
log.Println("Rcpt to:", to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Data(r io.Reader) error {
|
||||
if b, err := ioutil.ReadAll(r); err != nil {
|
||||
return err
|
||||
} else {
|
||||
log.Println("Data:", string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Reset() {}
|
||||
|
||||
func (s *Session) Logout() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
be := &Backend{}
|
||||
|
||||
s := smtp.NewServer(be)
|
||||
|
||||
s.Addr = ":1025"
|
||||
s.Domain = "localhost"
|
||||
s.ReadTimeout = 10 * time.Second
|
||||
s.WriteTimeout = 10 * time.Second
|
||||
s.MaxMessageBytes = 1024 * 1024
|
||||
s.MaxRecipients = 50
|
||||
s.AllowInsecureAuth = true
|
||||
|
||||
log.Println("Starting server at", s.Addr)
|
||||
if err := s.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can use the server manually with `telnet`:
|
||||
```
|
||||
$ telnet localhost 1025
|
||||
EHLO localhost
|
||||
AUTH PLAIN
|
||||
AHVzZXJuYW1lAHBhc3N3b3Jk
|
||||
MAIL FROM:<root@nsa.gov>
|
||||
RCPT TO:<root@gchq.gov.uk>
|
||||
DATA
|
||||
Hey <3
|
||||
.
|
||||
```
|
||||
|
||||
## Relationship with net/smtp
|
||||
|
||||
The Go standard library provides a SMTP client implementation in `net/smtp`.
|
||||
However `net/smtp` is frozen: it's not getting any new features. go-smtp
|
||||
provides a server implementation and a number of client improvements.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT
|
||||
102
vendor/github.com/emersion/go-smtp/backend.go
generated
vendored
Normal file
102
vendor/github.com/emersion/go-smtp/backend.go
generated
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthRequired = errors.New("Please authenticate first")
|
||||
ErrAuthUnsupported = errors.New("Authentication not supported")
|
||||
)
|
||||
|
||||
// A SMTP server backend.
|
||||
type Backend interface {
|
||||
// Authenticate a user. Return smtp.ErrAuthUnsupported if you don't want to
|
||||
// support this.
|
||||
Login(state *ConnectionState, username, password string) (Session, error)
|
||||
|
||||
// Called if the client attempts to send mail without logging in first.
|
||||
// Return smtp.ErrAuthRequired if you don't want to support this.
|
||||
AnonymousLogin(state *ConnectionState) (Session, error)
|
||||
}
|
||||
|
||||
type BodyType string
|
||||
|
||||
const (
|
||||
Body7Bit BodyType = "7BIT"
|
||||
Body8BitMIME BodyType = "8BITMIME"
|
||||
BodyBinaryMIME BodyType = "BINARYMIME"
|
||||
)
|
||||
|
||||
// MailOptions contains custom arguments that were
|
||||
// passed as an argument to the MAIL command.
|
||||
type MailOptions struct {
|
||||
// Value of BODY= argument, 7BIT, 8BITMIME or BINARYMIME.
|
||||
Body BodyType
|
||||
|
||||
// Size of the body. Can be 0 if not specified by client.
|
||||
Size int
|
||||
|
||||
// TLS is required for the message transmission.
|
||||
//
|
||||
// The message should be rejected if it can't be transmitted
|
||||
// with TLS.
|
||||
RequireTLS bool
|
||||
|
||||
// The message envelope or message header contains UTF-8-encoded strings.
|
||||
// This flag is set by SMTPUTF8-aware (RFC 6531) client.
|
||||
UTF8 bool
|
||||
|
||||
// The authorization identity asserted by the message sender in decoded
|
||||
// form with angle brackets stripped.
|
||||
//
|
||||
// nil value indicates missing AUTH, non-nil empty string indicates
|
||||
// AUTH=<>.
|
||||
//
|
||||
// Defined in RFC 4954.
|
||||
Auth *string
|
||||
}
|
||||
|
||||
// Session is used by servers to respond to an SMTP client.
|
||||
//
|
||||
// The methods are called when the remote client issues the matching command.
|
||||
type Session interface {
|
||||
// Discard currently processed message.
|
||||
Reset()
|
||||
|
||||
// Free all resources associated with session.
|
||||
Logout() error
|
||||
|
||||
// Set return path for currently processed message.
|
||||
Mail(from string, opts MailOptions) error
|
||||
// Add recipient for currently processed message.
|
||||
Rcpt(to string) error
|
||||
// Set currently processed message contents and send it.
|
||||
Data(r io.Reader) error
|
||||
}
|
||||
|
||||
// LMTPSession is an add-on interface for Session. It can be implemented by
|
||||
// LMTP servers to provide extra functionality.
|
||||
type LMTPSession interface {
|
||||
// LMTPData is the LMTP-specific version of Data method.
|
||||
// It can be optionally implemented by the backend to provide
|
||||
// per-recipient status information when it is used over LMTP
|
||||
// protocol.
|
||||
//
|
||||
// LMTPData implementation sets status information using passed
|
||||
// StatusCollector by calling SetStatus once per each AddRcpt
|
||||
// call, even if AddRcpt was called multiple times with
|
||||
// the same argument. SetStatus must not be called after
|
||||
// LMTPData returns.
|
||||
//
|
||||
// Return value of LMTPData itself is used as a status for
|
||||
// recipients that got no status set before using StatusCollector.
|
||||
LMTPData(r io.Reader, status StatusCollector) error
|
||||
}
|
||||
|
||||
// StatusCollector allows a backend to provide per-recipient status
|
||||
// information.
|
||||
type StatusCollector interface {
|
||||
SetStatus(rcptTo string, err error)
|
||||
}
|
||||
679
vendor/github.com/emersion/go-smtp/client.go
generated
vendored
Normal file
679
vendor/github.com/emersion/go-smtp/client.go
generated
vendored
Normal file
@@ -0,0 +1,679 @@
|
||||
// Copyright 2010 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
)
|
||||
|
||||
// A Client represents a client connection to an SMTP server.
|
||||
type Client struct {
|
||||
// Text is the textproto.Conn used by the Client. It is exported to allow for
|
||||
// clients to add extensions.
|
||||
Text *textproto.Conn
|
||||
|
||||
// keep a reference to the connection so it can be used to create a TLS
|
||||
// connection later
|
||||
conn net.Conn
|
||||
// whether the Client is using TLS
|
||||
tls bool
|
||||
serverName string
|
||||
lmtp bool
|
||||
// map of supported extensions
|
||||
ext map[string]string
|
||||
// supported auth mechanisms
|
||||
auth []string
|
||||
localName string // the name to use in HELO/EHLO/LHLO
|
||||
didHello bool // whether we've said HELO/EHLO/LHLO
|
||||
helloError error // the error from the hello
|
||||
rcpts []string // recipients accumulated for the current session
|
||||
|
||||
// Time to wait for command responses (this includes 3xx reply to DATA).
|
||||
CommandTimeout time.Duration
|
||||
// Time to wait for responses after final dot.
|
||||
SubmissionTimeout time.Duration
|
||||
|
||||
// Logger for all network activity.
|
||||
DebugWriter io.Writer
|
||||
}
|
||||
|
||||
// Dial returns a new Client connected to an SMTP server at addr.
|
||||
// The addr must include a port, as in "mail.example.com:smtp".
|
||||
func Dial(addr string) (*Client, error) {
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
return NewClient(conn, host)
|
||||
}
|
||||
|
||||
// DialTLS returns a new Client connected to an SMTP server via TLS at addr.
|
||||
// The addr must include a port, as in "mail.example.com:smtps".
|
||||
//
|
||||
// A nil tlsConfig is equivalent to a zero tls.Config.
|
||||
func DialTLS(addr string, tlsConfig *tls.Config) (*Client, error) {
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
return NewClient(conn, host)
|
||||
}
|
||||
|
||||
// NewClient returns a new Client using an existing connection and host as a
|
||||
// server name to be used when authenticating.
|
||||
func NewClient(conn net.Conn, host string) (*Client, error) {
|
||||
c := &Client{
|
||||
serverName: host,
|
||||
localName: "localhost",
|
||||
// As recommended by RFC 5321. For DATA command reply (3xx one) RFC
|
||||
// recommends a slightly shorter timeout but we do not bother
|
||||
// differentiating these.
|
||||
CommandTimeout: 5 * time.Minute,
|
||||
// 10 minutes + 2 minute buffer in case the server is doing transparent
|
||||
// forwarding and also follows recommended timeouts.
|
||||
SubmissionTimeout: 12 * time.Minute,
|
||||
}
|
||||
|
||||
c.setConn(conn)
|
||||
|
||||
_, _, err := c.Text.ReadResponse(220)
|
||||
if err != nil {
|
||||
c.Text.Close()
|
||||
if protoErr, ok := err.(*textproto.Error); ok {
|
||||
return nil, toSMTPErr(protoErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewClientLMTP returns a new LMTP Client (as defined in RFC 2033) using an
|
||||
// existing connector and host as a server name to be used when authenticating.
|
||||
func NewClientLMTP(conn net.Conn, host string) (*Client, error) {
|
||||
c, err := NewClient(conn, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.lmtp = true
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// setConn sets the underlying network connection for the client.
|
||||
func (c *Client) setConn(conn net.Conn) {
|
||||
c.conn = conn
|
||||
|
||||
var r io.Reader = conn
|
||||
var w io.Writer = conn
|
||||
|
||||
r = &lineLimitReader{
|
||||
R: conn,
|
||||
// Doubled maximum line length per RFC 5321 (Section 4.5.3.1.6)
|
||||
LineLimit: 2000,
|
||||
}
|
||||
|
||||
r = io.TeeReader(r, clientDebugWriter{c})
|
||||
w = io.MultiWriter(w, clientDebugWriter{c})
|
||||
|
||||
rwc := struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
io.Closer
|
||||
}{
|
||||
Reader: r,
|
||||
Writer: w,
|
||||
Closer: conn,
|
||||
}
|
||||
c.Text = textproto.NewConn(rwc)
|
||||
|
||||
_, isTLS := conn.(*tls.Conn)
|
||||
c.tls = isTLS
|
||||
}
|
||||
|
||||
// Close closes the connection.
|
||||
func (c *Client) Close() error {
|
||||
return c.Text.Close()
|
||||
}
|
||||
|
||||
// hello runs a hello exchange if needed.
|
||||
func (c *Client) hello() error {
|
||||
if !c.didHello {
|
||||
c.didHello = true
|
||||
err := c.ehlo()
|
||||
if err != nil {
|
||||
c.helloError = c.helo()
|
||||
}
|
||||
}
|
||||
return c.helloError
|
||||
}
|
||||
|
||||
// Hello sends a HELO or EHLO to the server as the given host name.
|
||||
// Calling this method is only necessary if the client needs control
|
||||
// over the host name used. The client will introduce itself as "localhost"
|
||||
// automatically otherwise. If Hello is called, it must be called before
|
||||
// any of the other methods.
|
||||
//
|
||||
// If server returns an error, it will be of type *SMTPError.
|
||||
func (c *Client) Hello(localName string) error {
|
||||
if err := validateLine(localName); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.didHello {
|
||||
return errors.New("smtp: Hello called after other methods")
|
||||
}
|
||||
c.localName = localName
|
||||
return c.hello()
|
||||
}
|
||||
|
||||
// cmd is a convenience function that sends a command and returns the response
|
||||
// textproto.Error returned by c.Text.ReadResponse is converted into SMTPError.
|
||||
func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) {
|
||||
c.conn.SetDeadline(time.Now().Add(c.CommandTimeout))
|
||||
defer c.conn.SetDeadline(time.Time{})
|
||||
|
||||
id, err := c.Text.Cmd(format, args...)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
c.Text.StartResponse(id)
|
||||
defer c.Text.EndResponse(id)
|
||||
code, msg, err := c.Text.ReadResponse(expectCode)
|
||||
if err != nil {
|
||||
if protoErr, ok := err.(*textproto.Error); ok {
|
||||
smtpErr := toSMTPErr(protoErr)
|
||||
return code, smtpErr.Message, smtpErr
|
||||
}
|
||||
return code, msg, err
|
||||
}
|
||||
return code, msg, nil
|
||||
}
|
||||
|
||||
// helo sends the HELO greeting to the server. It should be used only when the
|
||||
// server does not support ehlo.
|
||||
func (c *Client) helo() error {
|
||||
c.ext = nil
|
||||
_, _, err := c.cmd(250, "HELO %s", c.localName)
|
||||
return err
|
||||
}
|
||||
|
||||
// ehlo sends the EHLO (extended hello) greeting to the server. It
|
||||
// should be the preferred greeting for servers that support it.
|
||||
func (c *Client) ehlo() error {
|
||||
cmd := "EHLO"
|
||||
if c.lmtp {
|
||||
cmd = "LHLO"
|
||||
}
|
||||
|
||||
_, msg, err := c.cmd(250, "%s %s", cmd, c.localName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ext := make(map[string]string)
|
||||
extList := strings.Split(msg, "\n")
|
||||
if len(extList) > 1 {
|
||||
extList = extList[1:]
|
||||
for _, line := range extList {
|
||||
args := strings.SplitN(line, " ", 2)
|
||||
if len(args) > 1 {
|
||||
ext[args[0]] = args[1]
|
||||
} else {
|
||||
ext[args[0]] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if mechs, ok := ext["AUTH"]; ok {
|
||||
c.auth = strings.Split(mechs, " ")
|
||||
}
|
||||
c.ext = ext
|
||||
return err
|
||||
}
|
||||
|
||||
// StartTLS sends the STARTTLS command and encrypts all further communication.
|
||||
// Only servers that advertise the STARTTLS extension support this function.
|
||||
//
|
||||
// A nil config is equivalent to a zero tls.Config.
|
||||
//
|
||||
// If server returns an error, it will be of type *SMTPError.
|
||||
func (c *Client) StartTLS(config *tls.Config) error {
|
||||
if err := c.hello(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := c.cmd(220, "STARTTLS")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config == nil {
|
||||
config = &tls.Config{}
|
||||
}
|
||||
if config.ServerName == "" {
|
||||
// Make a copy to avoid polluting argument
|
||||
config = config.Clone()
|
||||
config.ServerName = c.serverName
|
||||
}
|
||||
if testHookStartTLS != nil {
|
||||
testHookStartTLS(config)
|
||||
}
|
||||
c.setConn(tls.Client(c.conn, config))
|
||||
return c.ehlo()
|
||||
}
|
||||
|
||||
// TLSConnectionState returns the client's TLS connection state.
|
||||
// The return values are their zero values if StartTLS did
|
||||
// not succeed.
|
||||
func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) {
|
||||
tc, ok := c.conn.(*tls.Conn)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
return tc.ConnectionState(), true
|
||||
}
|
||||
|
||||
// Verify checks the validity of an email address on the server.
|
||||
// If Verify returns nil, the address is valid. A non-nil return
|
||||
// does not necessarily indicate an invalid address. Many servers
|
||||
// will not verify addresses for security reasons.
|
||||
//
|
||||
// If server returns an error, it will be of type *SMTPError.
|
||||
func (c *Client) Verify(addr string) error {
|
||||
if err := validateLine(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.hello(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := c.cmd(250, "VRFY %s", addr)
|
||||
return err
|
||||
}
|
||||
|
||||
// Auth authenticates a client using the provided authentication mechanism.
|
||||
// Only servers that advertise the AUTH extension support this function.
|
||||
//
|
||||
// If server returns an error, it will be of type *SMTPError.
|
||||
func (c *Client) Auth(a sasl.Client) error {
|
||||
if err := c.hello(); err != nil {
|
||||
return err
|
||||
}
|
||||
encoding := base64.StdEncoding
|
||||
mech, resp, err := a.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp64 := make([]byte, encoding.EncodedLen(len(resp)))
|
||||
encoding.Encode(resp64, resp)
|
||||
code, msg64, err := c.cmd(0, strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64)))
|
||||
for err == nil {
|
||||
var msg []byte
|
||||
switch code {
|
||||
case 334:
|
||||
msg, err = encoding.DecodeString(msg64)
|
||||
case 235:
|
||||
// the last message isn't base64 because it isn't a challenge
|
||||
msg = []byte(msg64)
|
||||
default:
|
||||
err = toSMTPErr(&textproto.Error{Code: code, Msg: msg64})
|
||||
}
|
||||
if err == nil {
|
||||
if code == 334 {
|
||||
resp, err = a.Next(msg)
|
||||
} else {
|
||||
resp = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// abort the AUTH
|
||||
c.cmd(501, "*")
|
||||
break
|
||||
}
|
||||
if resp == nil {
|
||||
break
|
||||
}
|
||||
resp64 = make([]byte, encoding.EncodedLen(len(resp)))
|
||||
encoding.Encode(resp64, resp)
|
||||
code, msg64, err = c.cmd(0, string(resp64))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Mail issues a MAIL command to the server using the provided email address.
|
||||
// If the server supports the 8BITMIME extension, Mail adds the BODY=8BITMIME
|
||||
// parameter.
|
||||
// This initiates a mail transaction and is followed by one or more Rcpt calls.
|
||||
//
|
||||
// If opts is not nil, MAIL arguments provided in the structure will be added
|
||||
// to the command. Handling of unsupported options depends on the extension.
|
||||
//
|
||||
// If server returns an error, it will be of type *SMTPError.
|
||||
func (c *Client) Mail(from string, opts *MailOptions) error {
|
||||
if err := validateLine(from); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.hello(); err != nil {
|
||||
return err
|
||||
}
|
||||
cmdStr := "MAIL FROM:<%s>"
|
||||
if _, ok := c.ext["8BITMIME"]; ok {
|
||||
cmdStr += " BODY=8BITMIME"
|
||||
}
|
||||
if _, ok := c.ext["SIZE"]; ok && opts != nil && opts.Size != 0 {
|
||||
cmdStr += " SIZE=" + strconv.Itoa(opts.Size)
|
||||
}
|
||||
if opts != nil && opts.RequireTLS {
|
||||
if _, ok := c.ext["REQUIRETLS"]; ok {
|
||||
cmdStr += " REQUIRETLS"
|
||||
} else {
|
||||
return errors.New("smtp: server does not support REQUIRETLS")
|
||||
}
|
||||
}
|
||||
if opts != nil && opts.UTF8 {
|
||||
if _, ok := c.ext["SMTPUTF8"]; ok {
|
||||
cmdStr += " SMTPUTF8"
|
||||
} else {
|
||||
return errors.New("smtp: server does not support SMTPUTF8")
|
||||
}
|
||||
}
|
||||
if opts != nil && opts.Auth != nil {
|
||||
if _, ok := c.ext["AUTH"]; ok {
|
||||
cmdStr += " AUTH=" + encodeXtext(*opts.Auth)
|
||||
}
|
||||
// We can safely discard parameter if server does not support AUTH.
|
||||
}
|
||||
_, _, err := c.cmd(250, cmdStr, from)
|
||||
return err
|
||||
}
|
||||
|
||||
// Rcpt issues a RCPT command to the server using the provided email address.
|
||||
// A call to Rcpt must be preceded by a call to Mail and may be followed by
|
||||
// a Data call or another Rcpt call.
|
||||
//
|
||||
// If server returns an error, it will be of type *SMTPError.
|
||||
func (c *Client) Rcpt(to string) error {
|
||||
if err := validateLine(to); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := c.cmd(25, "RCPT TO:<%s>", to); err != nil {
|
||||
return err
|
||||
}
|
||||
c.rcpts = append(c.rcpts, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
type dataCloser struct {
|
||||
c *Client
|
||||
io.WriteCloser
|
||||
statusCb func(rcpt string, status *SMTPError)
|
||||
}
|
||||
|
||||
func (d *dataCloser) Close() error {
|
||||
d.WriteCloser.Close()
|
||||
|
||||
d.c.conn.SetDeadline(time.Now().Add(d.c.SubmissionTimeout))
|
||||
defer d.c.conn.SetDeadline(time.Time{})
|
||||
|
||||
expectedResponses := len(d.c.rcpts)
|
||||
if d.c.lmtp {
|
||||
for expectedResponses > 0 {
|
||||
rcpt := d.c.rcpts[len(d.c.rcpts)-expectedResponses]
|
||||
if _, _, err := d.c.Text.ReadResponse(250); err != nil {
|
||||
if protoErr, ok := err.(*textproto.Error); ok {
|
||||
if d.statusCb != nil {
|
||||
d.statusCb(rcpt, toSMTPErr(protoErr))
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else if d.statusCb != nil {
|
||||
d.statusCb(rcpt, nil)
|
||||
}
|
||||
expectedResponses--
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
_, _, err := d.c.Text.ReadResponse(250)
|
||||
if err != nil {
|
||||
if protoErr, ok := err.(*textproto.Error); ok {
|
||||
return toSMTPErr(protoErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Data issues a DATA command to the server and returns a writer that
|
||||
// can be used to write the mail headers and body. The caller should
|
||||
// close the writer before calling any more methods on c. A call to
|
||||
// Data must be preceded by one or more calls to Rcpt.
|
||||
//
|
||||
// If server returns an error, it will be of type *SMTPError.
|
||||
func (c *Client) Data() (io.WriteCloser, error) {
|
||||
_, _, err := c.cmd(354, "DATA")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dataCloser{c, c.Text.DotWriter(), nil}, nil
|
||||
}
|
||||
|
||||
// LMTPData is the LMTP-specific version of the Data method. It accepts a callback
|
||||
// that will be called for each status response received from the server.
|
||||
//
|
||||
// Status callback will receive a SMTPError argument for each negative server
|
||||
// reply and nil for each positive reply. I/O errors will not be reported using
|
||||
// callback and instead will be returned by the Close method of io.WriteCloser.
|
||||
// Callback will be called for each successfull Rcpt call done before in the
|
||||
// same order.
|
||||
func (c *Client) LMTPData(statusCb func(rcpt string, status *SMTPError)) (io.WriteCloser, error) {
|
||||
if !c.lmtp {
|
||||
return nil, errors.New("smtp: not a LMTP client")
|
||||
}
|
||||
|
||||
_, _, err := c.cmd(354, "DATA")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dataCloser{c, c.Text.DotWriter(), statusCb}, nil
|
||||
}
|
||||
|
||||
var testHookStartTLS func(*tls.Config) // nil, except for tests
|
||||
|
||||
// SendMail connects to the server at addr, switches to TLS if
|
||||
// possible, authenticates with the optional mechanism a if possible,
|
||||
// and then sends an email from address from, to addresses to, with
|
||||
// message r.
|
||||
// The addr must include a port, as in "mail.example.com:smtp".
|
||||
//
|
||||
// The addresses in the to parameter are the SMTP RCPT addresses.
|
||||
//
|
||||
// The r parameter should be an RFC 822-style email with headers
|
||||
// first, a blank line, and then the message body. The lines of r
|
||||
// should be CRLF terminated. The r headers should usually include
|
||||
// fields such as "From", "To", "Subject", and "Cc". Sending "Bcc"
|
||||
// messages is accomplished by including an email address in the to
|
||||
// parameter but not including it in the r headers.
|
||||
//
|
||||
// SendMail is intended to be used for very simple use-cases. If you want to
|
||||
// customize SendMail's behavior, use a Client instead.
|
||||
//
|
||||
// The SendMail function and the go-smtp package are low-level
|
||||
// mechanisms and provide no support for DKIM signing (see go-msgauth), MIME
|
||||
// attachments (see the mime/multipart package or the go-message package), or
|
||||
// other mail functionality.
|
||||
func SendMail(addr string, a sasl.Client, from string, to []string, r io.Reader) error {
|
||||
if err := validateLine(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recp := range to {
|
||||
if err := validateLine(recp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c, err := Dial(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
if err = c.hello(); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err = c.StartTLS(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if a != nil && c.ext != nil {
|
||||
if _, ok := c.ext["AUTH"]; !ok {
|
||||
return errors.New("smtp: server doesn't support AUTH")
|
||||
}
|
||||
if err = c.Auth(a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = c.Mail(from, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, addr := range to {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// Extension reports whether an extension is support by the server.
|
||||
// The extension name is case-insensitive. If the extension is supported,
|
||||
// Extension also returns a string that contains any parameters the
|
||||
// server specifies for the extension.
|
||||
func (c *Client) Extension(ext string) (bool, string) {
|
||||
if err := c.hello(); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
if c.ext == nil {
|
||||
return false, ""
|
||||
}
|
||||
ext = strings.ToUpper(ext)
|
||||
param, ok := c.ext[ext]
|
||||
return ok, param
|
||||
}
|
||||
|
||||
// Reset sends the RSET command to the server, aborting the current mail
|
||||
// transaction.
|
||||
func (c *Client) Reset() error {
|
||||
if err := c.hello(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := c.cmd(250, "RSET"); err != nil {
|
||||
return err
|
||||
}
|
||||
c.rcpts = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Noop sends the NOOP command to the server. It does nothing but check
|
||||
// that the connection to the server is okay.
|
||||
func (c *Client) Noop() error {
|
||||
if err := c.hello(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := c.cmd(250, "NOOP")
|
||||
return err
|
||||
}
|
||||
|
||||
// Quit sends the QUIT command and closes the connection to the server.
|
||||
//
|
||||
// If Quit fails the connection is not closed, Close should be used
|
||||
// in this case.
|
||||
func (c *Client) Quit() error {
|
||||
if err := c.hello(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := c.cmd(221, "QUIT")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Text.Close()
|
||||
}
|
||||
|
||||
func parseEnhancedCode(s string) (EnhancedCode, error) {
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return EnhancedCode{}, fmt.Errorf("wrong amount of enhanced code parts")
|
||||
}
|
||||
|
||||
code := EnhancedCode{}
|
||||
for i, part := range parts {
|
||||
num, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return code, err
|
||||
}
|
||||
code[i] = num
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// toSMTPErr converts textproto.Error into SMTPError, parsing
|
||||
// enhanced status code if it is present.
|
||||
func toSMTPErr(protoErr *textproto.Error) *SMTPError {
|
||||
if protoErr == nil {
|
||||
return nil
|
||||
}
|
||||
smtpErr := &SMTPError{
|
||||
Code: protoErr.Code,
|
||||
Message: protoErr.Msg,
|
||||
}
|
||||
|
||||
parts := strings.SplitN(protoErr.Msg, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return smtpErr
|
||||
}
|
||||
|
||||
enchCode, err := parseEnhancedCode(parts[0])
|
||||
if err != nil {
|
||||
return smtpErr
|
||||
}
|
||||
|
||||
msg := parts[1]
|
||||
|
||||
// Per RFC 2034, enhanced code should be prepended to each line.
|
||||
msg = strings.ReplaceAll(msg, "\n"+parts[0]+" ", "\n")
|
||||
|
||||
smtpErr.EnhancedCode = enchCode
|
||||
smtpErr.Message = msg
|
||||
return smtpErr
|
||||
}
|
||||
|
||||
type clientDebugWriter struct {
|
||||
c *Client
|
||||
}
|
||||
|
||||
func (cdw clientDebugWriter) Write(b []byte) (int, error) {
|
||||
if cdw.c.DebugWriter == nil {
|
||||
return len(b), nil
|
||||
}
|
||||
return cdw.c.DebugWriter.Write(b)
|
||||
}
|
||||
1005
vendor/github.com/emersion/go-smtp/conn.go
generated
vendored
Normal file
1005
vendor/github.com/emersion/go-smtp/conn.go
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
147
vendor/github.com/emersion/go-smtp/data.go
generated
vendored
Normal file
147
vendor/github.com/emersion/go-smtp/data.go
generated
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
)
|
||||
|
||||
type EnhancedCode [3]int
|
||||
|
||||
// SMTPError specifies the error code, enhanced error code (if any) and
|
||||
// message returned by the server.
|
||||
type SMTPError struct {
|
||||
Code int
|
||||
EnhancedCode EnhancedCode
|
||||
Message string
|
||||
}
|
||||
|
||||
// NoEnhancedCode is used to indicate that enhanced error code should not be
|
||||
// included in response.
|
||||
//
|
||||
// Note that RFC 2034 requires an enhanced code to be included in all 2xx, 4xx
|
||||
// and 5xx responses. This constant is exported for use by extensions, you
|
||||
// should probably use EnhancedCodeNotSet instead.
|
||||
var NoEnhancedCode = EnhancedCode{-1, -1, -1}
|
||||
|
||||
// EnhancedCodeNotSet is a nil value of EnhancedCode field in SMTPError, used
|
||||
// to indicate that backend failed to provide enhanced status code. X.0.0 will
|
||||
// be used (X is derived from error code).
|
||||
var EnhancedCodeNotSet = EnhancedCode{0, 0, 0}
|
||||
|
||||
func (err *SMTPError) Error() string {
|
||||
return err.Message
|
||||
}
|
||||
|
||||
func (err *SMTPError) Temporary() bool {
|
||||
return err.Code/100 == 4
|
||||
}
|
||||
|
||||
var ErrDataTooLarge = &SMTPError{
|
||||
Code: 552,
|
||||
EnhancedCode: EnhancedCode{5, 3, 4},
|
||||
Message: "Maximum message size exceeded",
|
||||
}
|
||||
|
||||
type dataReader struct {
|
||||
r *bufio.Reader
|
||||
state int
|
||||
|
||||
limited bool
|
||||
n int64 // Maximum bytes remaining
|
||||
}
|
||||
|
||||
func newDataReader(c *Conn) *dataReader {
|
||||
dr := &dataReader{
|
||||
r: c.text.R,
|
||||
}
|
||||
|
||||
if c.server.MaxMessageBytes > 0 {
|
||||
dr.limited = true
|
||||
dr.n = int64(c.server.MaxMessageBytes)
|
||||
}
|
||||
|
||||
return dr
|
||||
}
|
||||
|
||||
func (r *dataReader) Read(b []byte) (n int, err error) {
|
||||
if r.limited {
|
||||
if r.n <= 0 {
|
||||
return 0, ErrDataTooLarge
|
||||
}
|
||||
if int64(len(b)) > r.n {
|
||||
b = b[0:r.n]
|
||||
}
|
||||
}
|
||||
|
||||
// Code below is taken from net/textproto with only one modification to
|
||||
// not rewrite CRLF -> LF.
|
||||
|
||||
// Run data through a simple state machine to
|
||||
// elide leading dots and detect ending .\r\n line.
|
||||
const (
|
||||
stateBeginLine = iota // beginning of line; initial state; must be zero
|
||||
stateDot // read . at beginning of line
|
||||
stateDotCR // read .\r at beginning of line
|
||||
stateCR // read \r (possibly at end of line)
|
||||
stateData // reading data in middle of line
|
||||
stateEOF // reached .\r\n end marker line
|
||||
)
|
||||
for n < len(b) && r.state != stateEOF {
|
||||
var c byte
|
||||
c, err = r.r.ReadByte()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
break
|
||||
}
|
||||
switch r.state {
|
||||
case stateBeginLine:
|
||||
if c == '.' {
|
||||
r.state = stateDot
|
||||
continue
|
||||
}
|
||||
r.state = stateData
|
||||
case stateDot:
|
||||
if c == '\r' {
|
||||
r.state = stateDotCR
|
||||
continue
|
||||
}
|
||||
if c == '\n' {
|
||||
r.state = stateEOF
|
||||
continue
|
||||
}
|
||||
|
||||
r.state = stateData
|
||||
case stateDotCR:
|
||||
if c == '\n' {
|
||||
r.state = stateEOF
|
||||
continue
|
||||
}
|
||||
r.state = stateData
|
||||
case stateCR:
|
||||
if c == '\n' {
|
||||
r.state = stateBeginLine
|
||||
break
|
||||
}
|
||||
r.state = stateData
|
||||
case stateData:
|
||||
if c == '\r' {
|
||||
r.state = stateCR
|
||||
}
|
||||
if c == '\n' {
|
||||
r.state = stateBeginLine
|
||||
}
|
||||
}
|
||||
b[n] = c
|
||||
n++
|
||||
}
|
||||
if err == nil && r.state == stateEOF {
|
||||
err = io.EOF
|
||||
}
|
||||
|
||||
if r.limited {
|
||||
r.n -= int64(n)
|
||||
}
|
||||
return
|
||||
}
|
||||
47
vendor/github.com/emersion/go-smtp/lengthlimit_reader.go
generated
vendored
Normal file
47
vendor/github.com/emersion/go-smtp/lengthlimit_reader.go
generated
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var ErrTooLongLine = errors.New("smtp: too longer line in input stream")
|
||||
|
||||
// lineLimitReader reads from the underlying Reader but restricts
|
||||
// line length of lines in input stream to a certain length.
|
||||
//
|
||||
// If line length exceeds the limit - Read returns ErrTooLongLine
|
||||
type lineLimitReader struct {
|
||||
R io.Reader
|
||||
LineLimit int
|
||||
|
||||
curLineLength int
|
||||
}
|
||||
|
||||
func (r *lineLimitReader) Read(b []byte) (int, error) {
|
||||
if r.curLineLength > r.LineLimit && r.LineLimit > 0 {
|
||||
return 0, ErrTooLongLine
|
||||
}
|
||||
|
||||
n, err := r.R.Read(b)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
|
||||
if r.LineLimit == 0 {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
for _, chr := range b[:n] {
|
||||
if chr == '\n' {
|
||||
r.curLineLength = 0
|
||||
}
|
||||
r.curLineLength++
|
||||
|
||||
if r.curLineLength > r.LineLimit {
|
||||
return 0, ErrTooLongLine
|
||||
}
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
70
vendor/github.com/emersion/go-smtp/parse.go
generated
vendored
Normal file
70
vendor/github.com/emersion/go-smtp/parse.go
generated
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseCmd(line string) (cmd string, arg string, err error) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
|
||||
l := len(line)
|
||||
switch {
|
||||
case strings.HasPrefix(strings.ToUpper(line), "STARTTLS"):
|
||||
return "STARTTLS", "", nil
|
||||
case l == 0:
|
||||
return "", "", nil
|
||||
case l < 4:
|
||||
return "", "", fmt.Errorf("Command too short: %q", line)
|
||||
case l == 4:
|
||||
return strings.ToUpper(line), "", nil
|
||||
case l == 5:
|
||||
// Too long to be only command, too short to have args
|
||||
return "", "", fmt.Errorf("Mangled command: %q", line)
|
||||
}
|
||||
|
||||
// If we made it here, command is long enough to have args
|
||||
if line[4] != ' ' {
|
||||
// There wasn't a space after the command?
|
||||
return "", "", fmt.Errorf("Mangled command: %q", line)
|
||||
}
|
||||
|
||||
// I'm not sure if we should trim the args or not, but we will for now
|
||||
//return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), nil
|
||||
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " \n\r"), nil
|
||||
}
|
||||
|
||||
// Takes the arguments proceeding a command and files them
|
||||
// into a map[string]string after uppercasing each key. Sample arg
|
||||
// string:
|
||||
// " BODY=8BITMIME SIZE=1024 SMTPUTF8"
|
||||
// The leading space is mandatory.
|
||||
func parseArgs(args []string) (map[string]string, error) {
|
||||
argMap := map[string]string{}
|
||||
for _, arg := range args {
|
||||
if arg == "" {
|
||||
continue
|
||||
}
|
||||
m := strings.Split(arg, "=")
|
||||
switch len(m) {
|
||||
case 2:
|
||||
argMap[strings.ToUpper(m[0])] = m[1]
|
||||
case 1:
|
||||
argMap[strings.ToUpper(m[0])] = ""
|
||||
default:
|
||||
return nil, fmt.Errorf("Failed to parse arg string: %q", arg)
|
||||
}
|
||||
}
|
||||
return argMap, nil
|
||||
}
|
||||
|
||||
func parseHelloArgument(arg string) (string, error) {
|
||||
domain := arg
|
||||
if idx := strings.IndexRune(arg, ' '); idx >= 0 {
|
||||
domain = arg[:idx]
|
||||
}
|
||||
if domain == "" {
|
||||
return "", fmt.Errorf("Invalid domain")
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
263
vendor/github.com/emersion/go-smtp/server.go
generated
vendored
Normal file
263
vendor/github.com/emersion/go-smtp/server.go
generated
vendored
Normal file
@@ -0,0 +1,263 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
)
|
||||
|
||||
var errTCPAndLMTP = errors.New("smtp: cannot start LMTP server listening on a TCP socket")
|
||||
|
||||
// A function that creates SASL servers.
|
||||
type SaslServerFactory func(conn *Conn) sasl.Server
|
||||
|
||||
// Logger interface is used by Server to report unexpected internal errors.
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
Println(v ...interface{})
|
||||
}
|
||||
|
||||
// A SMTP server.
|
||||
type Server struct {
|
||||
// TCP or Unix address to listen on.
|
||||
Addr string
|
||||
// The server TLS configuration.
|
||||
TLSConfig *tls.Config
|
||||
// Enable LMTP mode, as defined in RFC 2033. LMTP mode cannot be used with a
|
||||
// TCP listener.
|
||||
LMTP bool
|
||||
|
||||
Domain string
|
||||
MaxRecipients int
|
||||
MaxMessageBytes int
|
||||
MaxLineLength int
|
||||
AllowInsecureAuth bool
|
||||
Strict bool
|
||||
Debug io.Writer
|
||||
ErrorLog Logger
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
|
||||
// Advertise SMTPUTF8 (RFC 6531) capability.
|
||||
// Should be used only if backend supports it.
|
||||
EnableSMTPUTF8 bool
|
||||
|
||||
// Advertise REQUIRETLS (RFC 8689) capability.
|
||||
// Should be used only if backend supports it.
|
||||
EnableREQUIRETLS bool
|
||||
|
||||
// Advertise BINARYMIME (RFC 3030) capability.
|
||||
// Should be used only if backend supports it.
|
||||
EnableBINARYMIME bool
|
||||
|
||||
// If set, the AUTH command will not be advertised and authentication
|
||||
// attempts will be rejected. This setting overrides AllowInsecureAuth.
|
||||
AuthDisabled bool
|
||||
|
||||
// The server backend.
|
||||
Backend Backend
|
||||
|
||||
caps []string
|
||||
auths map[string]SaslServerFactory
|
||||
done chan struct{}
|
||||
|
||||
locker sync.Mutex
|
||||
listeners []net.Listener
|
||||
conns map[*Conn]struct{}
|
||||
}
|
||||
|
||||
// New creates a new SMTP server.
|
||||
func NewServer(be Backend) *Server {
|
||||
return &Server{
|
||||
// Doubled maximum line length per RFC 5321 (Section 4.5.3.1.6)
|
||||
MaxLineLength: 2000,
|
||||
|
||||
Backend: be,
|
||||
done: make(chan struct{}, 1),
|
||||
ErrorLog: log.New(os.Stderr, "smtp/server ", log.LstdFlags),
|
||||
caps: []string{"PIPELINING", "8BITMIME", "ENHANCEDSTATUSCODES", "CHUNKING"},
|
||||
auths: map[string]SaslServerFactory{
|
||||
sasl.Plain: func(conn *Conn) sasl.Server {
|
||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||
if identity != "" && identity != username {
|
||||
return errors.New("Identities not supported")
|
||||
}
|
||||
|
||||
state := conn.State()
|
||||
session, err := be.Login(&state, username, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn.SetSession(session)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
conns: make(map[*Conn]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Serve accepts incoming connections on the Listener l.
|
||||
func (s *Server) Serve(l net.Listener) error {
|
||||
s.locker.Lock()
|
||||
s.listeners = append(s.listeners, l)
|
||||
s.locker.Unlock()
|
||||
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.done:
|
||||
// we called Close()
|
||||
return nil
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
go s.handleConn(newConn(c, s))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(c *Conn) error {
|
||||
s.locker.Lock()
|
||||
s.conns[c] = struct{}{}
|
||||
s.locker.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.Close()
|
||||
|
||||
s.locker.Lock()
|
||||
delete(s.conns, c)
|
||||
s.locker.Unlock()
|
||||
}()
|
||||
|
||||
c.greet()
|
||||
|
||||
for {
|
||||
line, err := c.ReadLine()
|
||||
if err == nil {
|
||||
cmd, arg, err := parseCmd(line)
|
||||
if err != nil {
|
||||
c.protocolError(501, EnhancedCode{5, 5, 2}, "Bad command")
|
||||
continue
|
||||
}
|
||||
|
||||
c.handle(cmd, arg)
|
||||
} else {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err == ErrTooLongLine {
|
||||
c.WriteResponse(500, EnhancedCode{5, 4, 0}, "Too long line, closing connection")
|
||||
return nil
|
||||
}
|
||||
|
||||
if neterr, ok := err.(net.Error); ok && neterr.Timeout() {
|
||||
c.WriteResponse(221, EnhancedCode{2, 4, 2}, "Idle timeout, bye bye")
|
||||
return nil
|
||||
}
|
||||
|
||||
c.WriteResponse(221, EnhancedCode{2, 4, 0}, "Connection error, sorry")
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe listens on the network address s.Addr and then calls Serve
|
||||
// to handle requests on incoming connections.
|
||||
//
|
||||
// If s.Addr is blank and LMTP is disabled, ":smtp" is used.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
network := "tcp"
|
||||
if s.LMTP {
|
||||
network = "unix"
|
||||
}
|
||||
|
||||
addr := s.Addr
|
||||
if !s.LMTP && addr == "" {
|
||||
addr = ":smtp"
|
||||
}
|
||||
|
||||
l, err := net.Listen(network, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.Serve(l)
|
||||
}
|
||||
|
||||
// ListenAndServeTLS listens on the TCP network address s.Addr and then calls
|
||||
// Serve to handle requests on incoming TLS connections.
|
||||
//
|
||||
// If s.Addr is blank, ":smtps" is used.
|
||||
func (s *Server) ListenAndServeTLS() error {
|
||||
if s.LMTP {
|
||||
return errTCPAndLMTP
|
||||
}
|
||||
|
||||
addr := s.Addr
|
||||
if addr == "" {
|
||||
addr = ":smtps"
|
||||
}
|
||||
|
||||
l, err := tls.Listen("tcp", addr, s.TLSConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.Serve(l)
|
||||
}
|
||||
|
||||
// Close immediately closes all active listeners and connections.
|
||||
//
|
||||
// Close returns any error returned from closing the server's underlying
|
||||
// listener(s).
|
||||
func (s *Server) Close() error {
|
||||
select {
|
||||
case <-s.done:
|
||||
return errors.New("smtp: server already closed")
|
||||
default:
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
var err error
|
||||
for _, l := range s.listeners {
|
||||
if lerr := l.Close(); lerr != nil && err == nil {
|
||||
err = lerr
|
||||
}
|
||||
}
|
||||
|
||||
s.locker.Lock()
|
||||
for conn := range s.conns {
|
||||
conn.Close()
|
||||
}
|
||||
s.locker.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// EnableAuth enables an authentication mechanism on this server.
|
||||
//
|
||||
// This function should not be called directly, it must only be used by
|
||||
// libraries implementing extensions of the SMTP protocol.
|
||||
func (s *Server) EnableAuth(name string, f SaslServerFactory) {
|
||||
s.auths[name] = f
|
||||
}
|
||||
|
||||
// ForEachConn iterates through all opened connections.
|
||||
func (s *Server) ForEachConn(f func(*Conn)) {
|
||||
s.locker.Lock()
|
||||
defer s.locker.Unlock()
|
||||
for conn := range s.conns {
|
||||
f(conn)
|
||||
}
|
||||
}
|
||||
30
vendor/github.com/emersion/go-smtp/smtp.go
generated
vendored
Normal file
30
vendor/github.com/emersion/go-smtp/smtp.go
generated
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321.
|
||||
//
|
||||
// It also implements the following extensions:
|
||||
//
|
||||
// 8BITMIME: RFC 1652
|
||||
// AUTH: RFC 2554
|
||||
// STARTTLS: RFC 3207
|
||||
// ENHANCEDSTATUSCODES: RFC 2034
|
||||
// SMTPUTF8: RFC 6531
|
||||
// REQUIRETLS: RFC 8689
|
||||
// CHUNKING: RFC 3030
|
||||
// BINARYMIME: RFC 3030
|
||||
//
|
||||
// LMTP (RFC 2033) is also supported.
|
||||
//
|
||||
// Additional extensions may be handled by other packages.
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validateLine checks to see if a line has CR or LF as per RFC 5321
|
||||
func validateLine(line string) error {
|
||||
if strings.ContainsAny(line, "\n\r") {
|
||||
return errors.New("smtp: A line must not contain CR or LF")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
12
vendor/github.com/fsnotify/fsnotify/.editorconfig
generated
vendored
Normal file
12
vendor/github.com/fsnotify/fsnotify/.editorconfig
generated
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
1
vendor/github.com/fsnotify/fsnotify/.gitattributes
generated
vendored
Normal file
1
vendor/github.com/fsnotify/fsnotify/.gitattributes
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
go.sum linguist-generated
|
||||
6
vendor/github.com/fsnotify/fsnotify/.gitignore
generated
vendored
Normal file
6
vendor/github.com/fsnotify/fsnotify/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# go test -c output
|
||||
*.test
|
||||
*.test.exe
|
||||
|
||||
# Output of go build ./cmd/fsnotify
|
||||
/fsnotify
|
||||
2
vendor/github.com/fsnotify/fsnotify/.mailmap
generated
vendored
Normal file
2
vendor/github.com/fsnotify/fsnotify/.mailmap
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
Chris Howey <howeyc@gmail.com> <chris@howey.me>
|
||||
Nathan Youngman <git@nathany.com> <4566+nathany@users.noreply.github.com>
|
||||
470
vendor/github.com/fsnotify/fsnotify/CHANGELOG.md
generated
vendored
Normal file
470
vendor/github.com/fsnotify/fsnotify/CHANGELOG.md
generated
vendored
Normal file
@@ -0,0 +1,470 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Nothing yet.
|
||||
|
||||
## [1.6.0] - 2022-10-13
|
||||
|
||||
This version of fsnotify needs Go 1.16 (this was already the case since 1.5.1,
|
||||
but not documented). It also increases the minimum Linux version to 2.6.32.
|
||||
|
||||
### Additions
|
||||
|
||||
- all: add `Event.Has()` and `Op.Has()` ([#477])
|
||||
|
||||
This makes checking events a lot easier; for example:
|
||||
|
||||
if event.Op&Write == Write && !(event.Op&Remove == Remove) {
|
||||
}
|
||||
|
||||
Becomes:
|
||||
|
||||
if event.Has(Write) && !event.Has(Remove) {
|
||||
}
|
||||
|
||||
- all: add cmd/fsnotify ([#463])
|
||||
|
||||
A command-line utility for testing and some examples.
|
||||
|
||||
### Changes and fixes
|
||||
|
||||
- inotify: don't ignore events for files that don't exist ([#260], [#470])
|
||||
|
||||
Previously the inotify watcher would call `os.Lstat()` to check if a file
|
||||
still exists before emitting events.
|
||||
|
||||
This was inconsistent with other platforms and resulted in inconsistent event
|
||||
reporting (e.g. when a file is quickly removed and re-created), and generally
|
||||
a source of confusion. It was added in 2013 to fix a memory leak that no
|
||||
longer exists.
|
||||
|
||||
- all: return `ErrNonExistentWatch` when `Remove()` is called on a path that's
|
||||
not watched ([#460])
|
||||
|
||||
- inotify: replace epoll() with non-blocking inotify ([#434])
|
||||
|
||||
Non-blocking inotify was not generally available at the time this library was
|
||||
written in 2014, but now it is. As a result, the minimum Linux version is
|
||||
bumped from 2.6.27 to 2.6.32. This hugely simplifies the code and is faster.
|
||||
|
||||
- kqueue: don't check for events every 100ms ([#480])
|
||||
|
||||
The watcher would wake up every 100ms, even when there was nothing to do. Now
|
||||
it waits until there is something to do.
|
||||
|
||||
- macos: retry opening files on EINTR ([#475])
|
||||
|
||||
- kqueue: skip unreadable files ([#479])
|
||||
|
||||
kqueue requires a file descriptor for every file in a directory; this would
|
||||
fail if a file was unreadable by the current user. Now these files are simply
|
||||
skipped.
|
||||
|
||||
- windows: fix renaming a watched directory if the parent is also watched ([#370])
|
||||
|
||||
- windows: increase buffer size from 4K to 64K ([#485])
|
||||
|
||||
- windows: close file handle on Remove() ([#288])
|
||||
|
||||
- kqueue: put pathname in the error if watching a file fails ([#471])
|
||||
|
||||
- inotify, windows: calling Close() more than once could race ([#465])
|
||||
|
||||
- kqueue: improve Close() performance ([#233])
|
||||
|
||||
- all: various documentation additions and clarifications.
|
||||
|
||||
[#233]: https://github.com/fsnotify/fsnotify/pull/233
|
||||
[#260]: https://github.com/fsnotify/fsnotify/pull/260
|
||||
[#288]: https://github.com/fsnotify/fsnotify/pull/288
|
||||
[#370]: https://github.com/fsnotify/fsnotify/pull/370
|
||||
[#434]: https://github.com/fsnotify/fsnotify/pull/434
|
||||
[#460]: https://github.com/fsnotify/fsnotify/pull/460
|
||||
[#463]: https://github.com/fsnotify/fsnotify/pull/463
|
||||
[#465]: https://github.com/fsnotify/fsnotify/pull/465
|
||||
[#470]: https://github.com/fsnotify/fsnotify/pull/470
|
||||
[#471]: https://github.com/fsnotify/fsnotify/pull/471
|
||||
[#475]: https://github.com/fsnotify/fsnotify/pull/475
|
||||
[#477]: https://github.com/fsnotify/fsnotify/pull/477
|
||||
[#479]: https://github.com/fsnotify/fsnotify/pull/479
|
||||
[#480]: https://github.com/fsnotify/fsnotify/pull/480
|
||||
[#485]: https://github.com/fsnotify/fsnotify/pull/485
|
||||
|
||||
## [1.5.4] - 2022-04-25
|
||||
|
||||
* Windows: add missing defer to `Watcher.WatchList` [#447](https://github.com/fsnotify/fsnotify/pull/447)
|
||||
* go.mod: use latest x/sys [#444](https://github.com/fsnotify/fsnotify/pull/444)
|
||||
* Fix compilation for OpenBSD [#443](https://github.com/fsnotify/fsnotify/pull/443)
|
||||
|
||||
## [1.5.3] - 2022-04-22
|
||||
|
||||
* This version is retracted. An incorrect branch is published accidentally [#445](https://github.com/fsnotify/fsnotify/issues/445)
|
||||
|
||||
## [1.5.2] - 2022-04-21
|
||||
|
||||
* Add a feature to return the directories and files that are being monitored [#374](https://github.com/fsnotify/fsnotify/pull/374)
|
||||
* Fix potential crash on windows if `raw.FileNameLength` exceeds `syscall.MAX_PATH` [#361](https://github.com/fsnotify/fsnotify/pull/361)
|
||||
* Allow build on unsupported GOOS [#424](https://github.com/fsnotify/fsnotify/pull/424)
|
||||
* Don't set `poller.fd` twice in `newFdPoller` [#406](https://github.com/fsnotify/fsnotify/pull/406)
|
||||
* fix go vet warnings: call to `(*T).Fatalf` from a non-test goroutine [#416](https://github.com/fsnotify/fsnotify/pull/416)
|
||||
|
||||
## [1.5.1] - 2021-08-24
|
||||
|
||||
* Revert Add AddRaw to not follow symlinks [#394](https://github.com/fsnotify/fsnotify/pull/394)
|
||||
|
||||
## [1.5.0] - 2021-08-20
|
||||
|
||||
* Go: Increase minimum required version to Go 1.12 [#381](https://github.com/fsnotify/fsnotify/pull/381)
|
||||
* Feature: Add AddRaw method which does not follow symlinks when adding a watch [#289](https://github.com/fsnotify/fsnotify/pull/298)
|
||||
* Windows: Follow symlinks by default like on all other systems [#289](https://github.com/fsnotify/fsnotify/pull/289)
|
||||
* CI: Use GitHub Actions for CI and cover go 1.12-1.17
|
||||
[#378](https://github.com/fsnotify/fsnotify/pull/378)
|
||||
[#381](https://github.com/fsnotify/fsnotify/pull/381)
|
||||
[#385](https://github.com/fsnotify/fsnotify/pull/385)
|
||||
* Go 1.14+: Fix unsafe pointer conversion [#325](https://github.com/fsnotify/fsnotify/pull/325)
|
||||
|
||||
## [1.4.9] - 2020-03-11
|
||||
|
||||
* Move example usage to the readme #329. This may resolve #328.
|
||||
|
||||
## [1.4.8] - 2020-03-10
|
||||
|
||||
* CI: test more go versions (@nathany 1d13583d846ea9d66dcabbfefbfb9d8e6fb05216)
|
||||
* Tests: Queued inotify events could have been read by the test before max_queued_events was hit (@matthias-stone #265)
|
||||
* Tests: t.Fatalf -> t.Errorf in go routines (@gdey #266)
|
||||
* CI: Less verbosity (@nathany #267)
|
||||
* Tests: Darwin: Exchangedata is deprecated on 10.13 (@nathany #267)
|
||||
* Tests: Check if channels are closed in the example (@alexeykazakov #244)
|
||||
* CI: Only run golint on latest version of go and fix issues (@cpuguy83 #284)
|
||||
* CI: Add windows to travis matrix (@cpuguy83 #284)
|
||||
* Docs: Remover appveyor badge (@nathany 11844c0959f6fff69ba325d097fce35bd85a8e93)
|
||||
* Linux: create epoll and pipe fds with close-on-exec (@JohannesEbke #219)
|
||||
* Linux: open files with close-on-exec (@linxiulei #273)
|
||||
* Docs: Plan to support fanotify (@nathany ab058b44498e8b7566a799372a39d150d9ea0119 )
|
||||
* Project: Add go.mod (@nathany #309)
|
||||
* Project: Revise editor config (@nathany #309)
|
||||
* Project: Update copyright for 2019 (@nathany #309)
|
||||
* CI: Drop go1.8 from CI matrix (@nathany #309)
|
||||
* Docs: Updating the FAQ section for supportability with NFS & FUSE filesystems (@Pratik32 4bf2d1fec78374803a39307bfb8d340688f4f28e )
|
||||
|
||||
## [1.4.7] - 2018-01-09
|
||||
|
||||
* BSD/macOS: Fix possible deadlock on closing the watcher on kqueue (thanks @nhooyr and @glycerine)
|
||||
* Tests: Fix missing verb on format string (thanks @rchiossi)
|
||||
* Linux: Fix deadlock in Remove (thanks @aarondl)
|
||||
* Linux: Watch.Add improvements (avoid race, fix consistency, reduce garbage) (thanks @twpayne)
|
||||
* Docs: Moved FAQ into the README (thanks @vahe)
|
||||
* Linux: Properly handle inotify's IN_Q_OVERFLOW event (thanks @zeldovich)
|
||||
* Docs: replace references to OS X with macOS
|
||||
|
||||
## [1.4.2] - 2016-10-10
|
||||
|
||||
* Linux: use InotifyInit1 with IN_CLOEXEC to stop leaking a file descriptor to a child process when using fork/exec [#178](https://github.com/fsnotify/fsnotify/pull/178) (thanks @pattyshack)
|
||||
|
||||
## [1.4.1] - 2016-10-04
|
||||
|
||||
* Fix flaky inotify stress test on Linux [#177](https://github.com/fsnotify/fsnotify/pull/177) (thanks @pattyshack)
|
||||
|
||||
## [1.4.0] - 2016-10-01
|
||||
|
||||
* add a String() method to Event.Op [#165](https://github.com/fsnotify/fsnotify/pull/165) (thanks @oozie)
|
||||
|
||||
## [1.3.1] - 2016-06-28
|
||||
|
||||
* Windows: fix for double backslash when watching the root of a drive [#151](https://github.com/fsnotify/fsnotify/issues/151) (thanks @brunoqc)
|
||||
|
||||
## [1.3.0] - 2016-04-19
|
||||
|
||||
* Support linux/arm64 by [patching](https://go-review.googlesource.com/#/c/21971/) x/sys/unix and switching to to it from syscall (thanks @suihkulokki) [#135](https://github.com/fsnotify/fsnotify/pull/135)
|
||||
|
||||
## [1.2.10] - 2016-03-02
|
||||
|
||||
* Fix golint errors in windows.go [#121](https://github.com/fsnotify/fsnotify/pull/121) (thanks @tiffanyfj)
|
||||
|
||||
## [1.2.9] - 2016-01-13
|
||||
|
||||
kqueue: Fix logic for CREATE after REMOVE [#111](https://github.com/fsnotify/fsnotify/pull/111) (thanks @bep)
|
||||
|
||||
## [1.2.8] - 2015-12-17
|
||||
|
||||
* kqueue: fix race condition in Close [#105](https://github.com/fsnotify/fsnotify/pull/105) (thanks @djui for reporting the issue and @ppknap for writing a failing test)
|
||||
* inotify: fix race in test
|
||||
* enable race detection for continuous integration (Linux, Mac, Windows)
|
||||
|
||||
## [1.2.5] - 2015-10-17
|
||||
|
||||
* inotify: use epoll_create1 for arm64 support (requires Linux 2.6.27 or later) [#100](https://github.com/fsnotify/fsnotify/pull/100) (thanks @suihkulokki)
|
||||
* inotify: fix path leaks [#73](https://github.com/fsnotify/fsnotify/pull/73) (thanks @chamaken)
|
||||
* kqueue: watch for rename events on subdirectories [#83](https://github.com/fsnotify/fsnotify/pull/83) (thanks @guotie)
|
||||
* kqueue: avoid infinite loops from symlinks cycles [#101](https://github.com/fsnotify/fsnotify/pull/101) (thanks @illicitonion)
|
||||
|
||||
## [1.2.1] - 2015-10-14
|
||||
|
||||
* kqueue: don't watch named pipes [#98](https://github.com/fsnotify/fsnotify/pull/98) (thanks @evanphx)
|
||||
|
||||
## [1.2.0] - 2015-02-08
|
||||
|
||||
* inotify: use epoll to wake up readEvents [#66](https://github.com/fsnotify/fsnotify/pull/66) (thanks @PieterD)
|
||||
* inotify: closing watcher should now always shut down goroutine [#63](https://github.com/fsnotify/fsnotify/pull/63) (thanks @PieterD)
|
||||
* kqueue: close kqueue after removing watches, fixes [#59](https://github.com/fsnotify/fsnotify/issues/59)
|
||||
|
||||
## [1.1.1] - 2015-02-05
|
||||
|
||||
* inotify: Retry read on EINTR [#61](https://github.com/fsnotify/fsnotify/issues/61) (thanks @PieterD)
|
||||
|
||||
## [1.1.0] - 2014-12-12
|
||||
|
||||
* kqueue: rework internals [#43](https://github.com/fsnotify/fsnotify/pull/43)
|
||||
* add low-level functions
|
||||
* only need to store flags on directories
|
||||
* less mutexes [#13](https://github.com/fsnotify/fsnotify/issues/13)
|
||||
* done can be an unbuffered channel
|
||||
* remove calls to os.NewSyscallError
|
||||
* More efficient string concatenation for Event.String() [#52](https://github.com/fsnotify/fsnotify/pull/52) (thanks @mdlayher)
|
||||
* kqueue: fix regression in rework causing subdirectories to be watched [#48](https://github.com/fsnotify/fsnotify/issues/48)
|
||||
* kqueue: cleanup internal watch before sending remove event [#51](https://github.com/fsnotify/fsnotify/issues/51)
|
||||
|
||||
## [1.0.4] - 2014-09-07
|
||||
|
||||
* kqueue: add dragonfly to the build tags.
|
||||
* Rename source code files, rearrange code so exported APIs are at the top.
|
||||
* Add done channel to example code. [#37](https://github.com/fsnotify/fsnotify/pull/37) (thanks @chenyukang)
|
||||
|
||||
## [1.0.3] - 2014-08-19
|
||||
|
||||
* [Fix] Windows MOVED_TO now translates to Create like on BSD and Linux. [#36](https://github.com/fsnotify/fsnotify/issues/36)
|
||||
|
||||
## [1.0.2] - 2014-08-17
|
||||
|
||||
* [Fix] Missing create events on macOS. [#14](https://github.com/fsnotify/fsnotify/issues/14) (thanks @zhsso)
|
||||
* [Fix] Make ./path and path equivalent. (thanks @zhsso)
|
||||
|
||||
## [1.0.0] - 2014-08-15
|
||||
|
||||
* [API] Remove AddWatch on Windows, use Add.
|
||||
* Improve documentation for exported identifiers. [#30](https://github.com/fsnotify/fsnotify/issues/30)
|
||||
* Minor updates based on feedback from golint.
|
||||
|
||||
## dev / 2014-07-09
|
||||
|
||||
* Moved to [github.com/fsnotify/fsnotify](https://github.com/fsnotify/fsnotify).
|
||||
* Use os.NewSyscallError instead of returning errno (thanks @hariharan-uno)
|
||||
|
||||
## dev / 2014-07-04
|
||||
|
||||
* kqueue: fix incorrect mutex used in Close()
|
||||
* Update example to demonstrate usage of Op.
|
||||
|
||||
## dev / 2014-06-28
|
||||
|
||||
* [API] Don't set the Write Op for attribute notifications [#4](https://github.com/fsnotify/fsnotify/issues/4)
|
||||
* Fix for String() method on Event (thanks Alex Brainman)
|
||||
* Don't build on Plan 9 or Solaris (thanks @4ad)
|
||||
|
||||
## dev / 2014-06-21
|
||||
|
||||
* Events channel of type Event rather than *Event.
|
||||
* [internal] use syscall constants directly for inotify and kqueue.
|
||||
* [internal] kqueue: rename events to kevents and fileEvent to event.
|
||||
|
||||
## dev / 2014-06-19
|
||||
|
||||
* Go 1.3+ required on Windows (uses syscall.ERROR_MORE_DATA internally).
|
||||
* [internal] remove cookie from Event struct (unused).
|
||||
* [internal] Event struct has the same definition across every OS.
|
||||
* [internal] remove internal watch and removeWatch methods.
|
||||
|
||||
## dev / 2014-06-12
|
||||
|
||||
* [API] Renamed Watch() to Add() and RemoveWatch() to Remove().
|
||||
* [API] Pluralized channel names: Events and Errors.
|
||||
* [API] Renamed FileEvent struct to Event.
|
||||
* [API] Op constants replace methods like IsCreate().
|
||||
|
||||
## dev / 2014-06-12
|
||||
|
||||
* Fix data race on kevent buffer (thanks @tilaks) [#98](https://github.com/howeyc/fsnotify/pull/98)
|
||||
|
||||
## dev / 2014-05-23
|
||||
|
||||
* [API] Remove current implementation of WatchFlags.
|
||||
* current implementation doesn't take advantage of OS for efficiency
|
||||
* provides little benefit over filtering events as they are received, but has extra bookkeeping and mutexes
|
||||
* no tests for the current implementation
|
||||
* not fully implemented on Windows [#93](https://github.com/howeyc/fsnotify/issues/93#issuecomment-39285195)
|
||||
|
||||
## [0.9.3] - 2014-12-31
|
||||
|
||||
* kqueue: cleanup internal watch before sending remove event [#51](https://github.com/fsnotify/fsnotify/issues/51)
|
||||
|
||||
## [0.9.2] - 2014-08-17
|
||||
|
||||
* [Backport] Fix missing create events on macOS. [#14](https://github.com/fsnotify/fsnotify/issues/14) (thanks @zhsso)
|
||||
|
||||
## [0.9.1] - 2014-06-12
|
||||
|
||||
* Fix data race on kevent buffer (thanks @tilaks) [#98](https://github.com/howeyc/fsnotify/pull/98)
|
||||
|
||||
## [0.9.0] - 2014-01-17
|
||||
|
||||
* IsAttrib() for events that only concern a file's metadata [#79][] (thanks @abustany)
|
||||
* [Fix] kqueue: fix deadlock [#77][] (thanks @cespare)
|
||||
* [NOTICE] Development has moved to `code.google.com/p/go.exp/fsnotify` in preparation for inclusion in the Go standard library.
|
||||
|
||||
## [0.8.12] - 2013-11-13
|
||||
|
||||
* [API] Remove FD_SET and friends from Linux adapter
|
||||
|
||||
## [0.8.11] - 2013-11-02
|
||||
|
||||
* [Doc] Add Changelog [#72][] (thanks @nathany)
|
||||
* [Doc] Spotlight and double modify events on macOS [#62][] (reported by @paulhammond)
|
||||
|
||||
## [0.8.10] - 2013-10-19
|
||||
|
||||
* [Fix] kqueue: remove file watches when parent directory is removed [#71][] (reported by @mdwhatcott)
|
||||
* [Fix] kqueue: race between Close and readEvents [#70][] (reported by @bernerdschaefer)
|
||||
* [Doc] specify OS-specific limits in README (thanks @debrando)
|
||||
|
||||
## [0.8.9] - 2013-09-08
|
||||
|
||||
* [Doc] Contributing (thanks @nathany)
|
||||
* [Doc] update package path in example code [#63][] (thanks @paulhammond)
|
||||
* [Doc] GoCI badge in README (Linux only) [#60][]
|
||||
* [Doc] Cross-platform testing with Vagrant [#59][] (thanks @nathany)
|
||||
|
||||
## [0.8.8] - 2013-06-17
|
||||
|
||||
* [Fix] Windows: handle `ERROR_MORE_DATA` on Windows [#49][] (thanks @jbowtie)
|
||||
|
||||
## [0.8.7] - 2013-06-03
|
||||
|
||||
* [API] Make syscall flags internal
|
||||
* [Fix] inotify: ignore event changes
|
||||
* [Fix] race in symlink test [#45][] (reported by @srid)
|
||||
* [Fix] tests on Windows
|
||||
* lower case error messages
|
||||
|
||||
## [0.8.6] - 2013-05-23
|
||||
|
||||
* kqueue: Use EVT_ONLY flag on Darwin
|
||||
* [Doc] Update README with full example
|
||||
|
||||
## [0.8.5] - 2013-05-09
|
||||
|
||||
* [Fix] inotify: allow monitoring of "broken" symlinks (thanks @tsg)
|
||||
|
||||
## [0.8.4] - 2013-04-07
|
||||
|
||||
* [Fix] kqueue: watch all file events [#40][] (thanks @ChrisBuchholz)
|
||||
|
||||
## [0.8.3] - 2013-03-13
|
||||
|
||||
* [Fix] inoitfy/kqueue memory leak [#36][] (reported by @nbkolchin)
|
||||
* [Fix] kqueue: use fsnFlags for watching a directory [#33][] (reported by @nbkolchin)
|
||||
|
||||
## [0.8.2] - 2013-02-07
|
||||
|
||||
* [Doc] add Authors
|
||||
* [Fix] fix data races for map access [#29][] (thanks @fsouza)
|
||||
|
||||
## [0.8.1] - 2013-01-09
|
||||
|
||||
* [Fix] Windows path separators
|
||||
* [Doc] BSD License
|
||||
|
||||
## [0.8.0] - 2012-11-09
|
||||
|
||||
* kqueue: directory watching improvements (thanks @vmirage)
|
||||
* inotify: add `IN_MOVED_TO` [#25][] (requested by @cpisto)
|
||||
* [Fix] kqueue: deleting watched directory [#24][] (reported by @jakerr)
|
||||
|
||||
## [0.7.4] - 2012-10-09
|
||||
|
||||
* [Fix] inotify: fixes from https://codereview.appspot.com/5418045/ (ugorji)
|
||||
* [Fix] kqueue: preserve watch flags when watching for delete [#21][] (reported by @robfig)
|
||||
* [Fix] kqueue: watch the directory even if it isn't a new watch (thanks @robfig)
|
||||
* [Fix] kqueue: modify after recreation of file
|
||||
|
||||
## [0.7.3] - 2012-09-27
|
||||
|
||||
* [Fix] kqueue: watch with an existing folder inside the watched folder (thanks @vmirage)
|
||||
* [Fix] kqueue: no longer get duplicate CREATE events
|
||||
|
||||
## [0.7.2] - 2012-09-01
|
||||
|
||||
* kqueue: events for created directories
|
||||
|
||||
## [0.7.1] - 2012-07-14
|
||||
|
||||
* [Fix] for renaming files
|
||||
|
||||
## [0.7.0] - 2012-07-02
|
||||
|
||||
* [Feature] FSNotify flags
|
||||
* [Fix] inotify: Added file name back to event path
|
||||
|
||||
## [0.6.0] - 2012-06-06
|
||||
|
||||
* kqueue: watch files after directory created (thanks @tmc)
|
||||
|
||||
## [0.5.1] - 2012-05-22
|
||||
|
||||
* [Fix] inotify: remove all watches before Close()
|
||||
|
||||
## [0.5.0] - 2012-05-03
|
||||
|
||||
* [API] kqueue: return errors during watch instead of sending over channel
|
||||
* kqueue: match symlink behavior on Linux
|
||||
* inotify: add `DELETE_SELF` (requested by @taralx)
|
||||
* [Fix] kqueue: handle EINTR (reported by @robfig)
|
||||
* [Doc] Godoc example [#1][] (thanks @davecheney)
|
||||
|
||||
## [0.4.0] - 2012-03-30
|
||||
|
||||
* Go 1 released: build with go tool
|
||||
* [Feature] Windows support using winfsnotify
|
||||
* Windows does not have attribute change notifications
|
||||
* Roll attribute notifications into IsModify
|
||||
|
||||
## [0.3.0] - 2012-02-19
|
||||
|
||||
* kqueue: add files when watch directory
|
||||
|
||||
## [0.2.0] - 2011-12-30
|
||||
|
||||
* update to latest Go weekly code
|
||||
|
||||
## [0.1.0] - 2011-10-19
|
||||
|
||||
* kqueue: add watch on file creation to match inotify
|
||||
* kqueue: create file event
|
||||
* inotify: ignore `IN_IGNORED` events
|
||||
* event String()
|
||||
* linux: common FileEvent functions
|
||||
* initial commit
|
||||
|
||||
[#79]: https://github.com/howeyc/fsnotify/pull/79
|
||||
[#77]: https://github.com/howeyc/fsnotify/pull/77
|
||||
[#72]: https://github.com/howeyc/fsnotify/issues/72
|
||||
[#71]: https://github.com/howeyc/fsnotify/issues/71
|
||||
[#70]: https://github.com/howeyc/fsnotify/issues/70
|
||||
[#63]: https://github.com/howeyc/fsnotify/issues/63
|
||||
[#62]: https://github.com/howeyc/fsnotify/issues/62
|
||||
[#60]: https://github.com/howeyc/fsnotify/issues/60
|
||||
[#59]: https://github.com/howeyc/fsnotify/issues/59
|
||||
[#49]: https://github.com/howeyc/fsnotify/issues/49
|
||||
[#45]: https://github.com/howeyc/fsnotify/issues/45
|
||||
[#40]: https://github.com/howeyc/fsnotify/issues/40
|
||||
[#36]: https://github.com/howeyc/fsnotify/issues/36
|
||||
[#33]: https://github.com/howeyc/fsnotify/issues/33
|
||||
[#29]: https://github.com/howeyc/fsnotify/issues/29
|
||||
[#25]: https://github.com/howeyc/fsnotify/issues/25
|
||||
[#24]: https://github.com/howeyc/fsnotify/issues/24
|
||||
[#21]: https://github.com/howeyc/fsnotify/issues/21
|
||||
26
vendor/github.com/fsnotify/fsnotify/CONTRIBUTING.md
generated
vendored
Normal file
26
vendor/github.com/fsnotify/fsnotify/CONTRIBUTING.md
generated
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
Thank you for your interest in contributing to fsnotify! We try to review and
|
||||
merge PRs in a reasonable timeframe, but please be aware that:
|
||||
|
||||
- To avoid "wasted" work, please discus changes on the issue tracker first. You
|
||||
can just send PRs, but they may end up being rejected for one reason or the
|
||||
other.
|
||||
|
||||
- fsnotify is a cross-platform library, and changes must work reasonably well on
|
||||
all supported platforms.
|
||||
|
||||
- Changes will need to be compatible; old code should still compile, and the
|
||||
runtime behaviour can't change in ways that are likely to lead to problems for
|
||||
users.
|
||||
|
||||
Testing
|
||||
-------
|
||||
Just `go test ./...` runs all the tests; the CI runs this on all supported
|
||||
platforms. Testing different platforms locally can be done with something like
|
||||
[goon] or [Vagrant], but this isn't super-easy to set up at the moment.
|
||||
|
||||
Use the `-short` flag to make the "stress test" run faster.
|
||||
|
||||
|
||||
[goon]: https://github.com/arp242/goon
|
||||
[Vagrant]: https://www.vagrantup.com/
|
||||
[integration_test.go]: /integration_test.go
|
||||
25
vendor/github.com/fsnotify/fsnotify/LICENSE
generated
vendored
Normal file
25
vendor/github.com/fsnotify/fsnotify/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
Copyright © 2012 The Go Authors. All rights reserved.
|
||||
Copyright © fsnotify 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 Google Inc. 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.
|
||||
161
vendor/github.com/fsnotify/fsnotify/README.md
generated
vendored
Normal file
161
vendor/github.com/fsnotify/fsnotify/README.md
generated
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
fsnotify is a Go library to provide cross-platform filesystem notifications on
|
||||
Windows, Linux, macOS, and BSD systems.
|
||||
|
||||
Go 1.16 or newer is required; the full documentation is at
|
||||
https://pkg.go.dev/github.com/fsnotify/fsnotify
|
||||
|
||||
**It's best to read the documentation at pkg.go.dev, as it's pinned to the last
|
||||
released version, whereas this README is for the last development version which
|
||||
may include additions/changes.**
|
||||
|
||||
---
|
||||
|
||||
Platform support:
|
||||
|
||||
| Adapter | OS | Status |
|
||||
| --------------------- | ---------------| -------------------------------------------------------------|
|
||||
| inotify | Linux 2.6.32+ | Supported |
|
||||
| kqueue | BSD, macOS | Supported |
|
||||
| ReadDirectoryChangesW | Windows | Supported |
|
||||
| FSEvents | macOS | [Planned](https://github.com/fsnotify/fsnotify/issues/11) |
|
||||
| FEN | Solaris 11 | [In Progress](https://github.com/fsnotify/fsnotify/pull/371) |
|
||||
| fanotify | Linux 5.9+ | [Maybe](https://github.com/fsnotify/fsnotify/issues/114) |
|
||||
| USN Journals | Windows | [Maybe](https://github.com/fsnotify/fsnotify/issues/53) |
|
||||
| Polling | *All* | [Maybe](https://github.com/fsnotify/fsnotify/issues/9) |
|
||||
|
||||
Linux and macOS should include Android and iOS, but these are currently untested.
|
||||
|
||||
Usage
|
||||
-----
|
||||
A basic example:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create new watcher.
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
// Start listening for events.
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Println("event:", event)
|
||||
if event.Has(fsnotify.Write) {
|
||||
log.Println("modified file:", event.Name)
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Println("error:", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Add a path.
|
||||
err = watcher.Add("/tmp")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Block main goroutine forever.
|
||||
<-make(chan struct{})
|
||||
}
|
||||
```
|
||||
|
||||
Some more examples can be found in [cmd/fsnotify](cmd/fsnotify), which can be
|
||||
run with:
|
||||
|
||||
% go run ./cmd/fsnotify
|
||||
|
||||
FAQ
|
||||
---
|
||||
### Will a file still be watched when it's moved to another directory?
|
||||
No, not unless you are watching the location it was moved to.
|
||||
|
||||
### Are subdirectories watched too?
|
||||
No, you must add watches for any directory you want to watch (a recursive
|
||||
watcher is on the roadmap: [#18]).
|
||||
|
||||
[#18]: https://github.com/fsnotify/fsnotify/issues/18
|
||||
|
||||
### Do I have to watch the Error and Event channels in a goroutine?
|
||||
As of now, yes (you can read both channels in the same goroutine using `select`,
|
||||
you don't need a separate goroutine for both channels; see the example).
|
||||
|
||||
### Why don't notifications work with NFS, SMB, FUSE, /proc, or /sys?
|
||||
fsnotify requires support from underlying OS to work. The current NFS and SMB
|
||||
protocols does not provide network level support for file notifications, and
|
||||
neither do the /proc and /sys virtual filesystems.
|
||||
|
||||
This could be fixed with a polling watcher ([#9]), but it's not yet implemented.
|
||||
|
||||
[#9]: https://github.com/fsnotify/fsnotify/issues/9
|
||||
|
||||
Platform-specific notes
|
||||
-----------------------
|
||||
### Linux
|
||||
When a file is removed a REMOVE event won't be emitted until all file
|
||||
descriptors are closed; it will emit a CHMOD instead:
|
||||
|
||||
fp := os.Open("file")
|
||||
os.Remove("file") // CHMOD
|
||||
fp.Close() // REMOVE
|
||||
|
||||
This is the event that inotify sends, so not much can be changed about this.
|
||||
|
||||
The `fs.inotify.max_user_watches` sysctl variable specifies the upper limit for
|
||||
the number of watches per user, and `fs.inotify.max_user_instances` specifies
|
||||
the maximum number of inotify instances per user. Every Watcher you create is an
|
||||
"instance", and every path you add is a "watch".
|
||||
|
||||
These are also exposed in `/proc` as `/proc/sys/fs/inotify/max_user_watches` and
|
||||
`/proc/sys/fs/inotify/max_user_instances`
|
||||
|
||||
To increase them you can use `sysctl` or write the value to proc file:
|
||||
|
||||
# The default values on Linux 5.18
|
||||
sysctl fs.inotify.max_user_watches=124983
|
||||
sysctl fs.inotify.max_user_instances=128
|
||||
|
||||
To make the changes persist on reboot edit `/etc/sysctl.conf` or
|
||||
`/usr/lib/sysctl.d/50-default.conf` (details differ per Linux distro; check your
|
||||
distro's documentation):
|
||||
|
||||
fs.inotify.max_user_watches=124983
|
||||
fs.inotify.max_user_instances=128
|
||||
|
||||
Reaching the limit will result in a "no space left on device" or "too many open
|
||||
files" error.
|
||||
|
||||
### kqueue (macOS, all BSD systems)
|
||||
kqueue requires opening a file descriptor for every file that's being watched;
|
||||
so if you're watching a directory with five files then that's six file
|
||||
descriptors. You will run in to your system's "max open files" limit faster on
|
||||
these platforms.
|
||||
|
||||
The sysctl variables `kern.maxfiles` and `kern.maxfilesperproc` can be used to
|
||||
control the maximum number of open files.
|
||||
|
||||
### macOS
|
||||
Spotlight indexing on macOS can result in multiple events (see [#15]). A temporary
|
||||
workaround is to add your folder(s) to the *Spotlight Privacy settings* until we
|
||||
have a native FSEvents implementation (see [#11]).
|
||||
|
||||
[#11]: https://github.com/fsnotify/fsnotify/issues/11
|
||||
[#15]: https://github.com/fsnotify/fsnotify/issues/15
|
||||
162
vendor/github.com/fsnotify/fsnotify/backend_fen.go
generated
vendored
Normal file
162
vendor/github.com/fsnotify/fsnotify/backend_fen.go
generated
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
//go:build solaris
|
||||
// +build solaris
|
||||
|
||||
package fsnotify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Watcher watches a set of paths, delivering events on a channel.
|
||||
//
|
||||
// A watcher should not be copied (e.g. pass it by pointer, rather than by
|
||||
// value).
|
||||
//
|
||||
// # Linux notes
|
||||
//
|
||||
// When a file is removed a Remove event won't be emitted until all file
|
||||
// descriptors are closed, and deletes will always emit a Chmod. For example:
|
||||
//
|
||||
// fp := os.Open("file")
|
||||
// os.Remove("file") // Triggers Chmod
|
||||
// fp.Close() // Triggers Remove
|
||||
//
|
||||
// This is the event that inotify sends, so not much can be changed about this.
|
||||
//
|
||||
// The fs.inotify.max_user_watches sysctl variable specifies the upper limit
|
||||
// for the number of watches per user, and fs.inotify.max_user_instances
|
||||
// specifies the maximum number of inotify instances per user. Every Watcher you
|
||||
// create is an "instance", and every path you add is a "watch".
|
||||
//
|
||||
// These are also exposed in /proc as /proc/sys/fs/inotify/max_user_watches and
|
||||
// /proc/sys/fs/inotify/max_user_instances
|
||||
//
|
||||
// To increase them you can use sysctl or write the value to the /proc file:
|
||||
//
|
||||
// # Default values on Linux 5.18
|
||||
// sysctl fs.inotify.max_user_watches=124983
|
||||
// sysctl fs.inotify.max_user_instances=128
|
||||
//
|
||||
// To make the changes persist on reboot edit /etc/sysctl.conf or
|
||||
// /usr/lib/sysctl.d/50-default.conf (details differ per Linux distro; check
|
||||
// your distro's documentation):
|
||||
//
|
||||
// fs.inotify.max_user_watches=124983
|
||||
// fs.inotify.max_user_instances=128
|
||||
//
|
||||
// Reaching the limit will result in a "no space left on device" or "too many open
|
||||
// files" error.
|
||||
//
|
||||
// # kqueue notes (macOS, BSD)
|
||||
//
|
||||
// kqueue requires opening a file descriptor for every file that's being watched;
|
||||
// so if you're watching a directory with five files then that's six file
|
||||
// descriptors. You will run in to your system's "max open files" limit faster on
|
||||
// these platforms.
|
||||
//
|
||||
// The sysctl variables kern.maxfiles and kern.maxfilesperproc can be used to
|
||||
// control the maximum number of open files, as well as /etc/login.conf on BSD
|
||||
// systems.
|
||||
//
|
||||
// # macOS notes
|
||||
//
|
||||
// Spotlight indexing on macOS can result in multiple events (see [#15]). A
|
||||
// temporary workaround is to add your folder(s) to the "Spotlight Privacy
|
||||
// Settings" until we have a native FSEvents implementation (see [#11]).
|
||||
//
|
||||
// [#11]: https://github.com/fsnotify/fsnotify/issues/11
|
||||
// [#15]: https://github.com/fsnotify/fsnotify/issues/15
|
||||
type Watcher struct {
|
||||
// Events sends the filesystem change events.
|
||||
//
|
||||
// fsnotify can send the following events; a "path" here can refer to a
|
||||
// file, directory, symbolic link, or special file like a FIFO.
|
||||
//
|
||||
// fsnotify.Create A new path was created; this may be followed by one
|
||||
// or more Write events if data also gets written to a
|
||||
// file.
|
||||
//
|
||||
// fsnotify.Remove A path was removed.
|
||||
//
|
||||
// fsnotify.Rename A path was renamed. A rename is always sent with the
|
||||
// old path as Event.Name, and a Create event will be
|
||||
// sent with the new name. Renames are only sent for
|
||||
// paths that are currently watched; e.g. moving an
|
||||
// unmonitored file into a monitored directory will
|
||||
// show up as just a Create. Similarly, renaming a file
|
||||
// to outside a monitored directory will show up as
|
||||
// only a Rename.
|
||||
//
|
||||
// fsnotify.Write A file or named pipe was written to. A Truncate will
|
||||
// also trigger a Write. A single "write action"
|
||||
// initiated by the user may show up as one or multiple
|
||||
// writes, depending on when the system syncs things to
|
||||
// disk. For example when compiling a large Go program
|
||||
// you may get hundreds of Write events, so you
|
||||
// probably want to wait until you've stopped receiving
|
||||
// them (see the dedup example in cmd/fsnotify).
|
||||
//
|
||||
// fsnotify.Chmod Attributes were changed. On Linux this is also sent
|
||||
// when a file is removed (or more accurately, when a
|
||||
// link to an inode is removed). On kqueue it's sent
|
||||
// and on kqueue when a file is truncated. On Windows
|
||||
// it's never sent.
|
||||
Events chan Event
|
||||
|
||||
// Errors sends any errors.
|
||||
Errors chan error
|
||||
}
|
||||
|
||||
// NewWatcher creates a new Watcher.
|
||||
func NewWatcher() (*Watcher, error) {
|
||||
return nil, errors.New("FEN based watcher not yet supported for fsnotify\n")
|
||||
}
|
||||
|
||||
// Close removes all watches and closes the events channel.
|
||||
func (w *Watcher) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add starts monitoring the path for changes.
|
||||
//
|
||||
// A path can only be watched once; attempting to watch it more than once will
|
||||
// return an error. Paths that do not yet exist on the filesystem cannot be
|
||||
// added. A watch will be automatically removed if the path is deleted.
|
||||
//
|
||||
// A path will remain watched if it gets renamed to somewhere else on the same
|
||||
// filesystem, but the monitor will get removed if the path gets deleted and
|
||||
// re-created, or if it's moved to a different filesystem.
|
||||
//
|
||||
// Notifications on network filesystems (NFS, SMB, FUSE, etc.) or special
|
||||
// filesystems (/proc, /sys, etc.) generally don't work.
|
||||
//
|
||||
// # Watching directories
|
||||
//
|
||||
// All files in a directory are monitored, including new files that are created
|
||||
// after the watcher is started. Subdirectories are not watched (i.e. it's
|
||||
// non-recursive).
|
||||
//
|
||||
// # Watching files
|
||||
//
|
||||
// Watching individual files (rather than directories) is generally not
|
||||
// recommended as many tools update files atomically. Instead of "just" writing
|
||||
// to the file a temporary file will be written to first, and if successful the
|
||||
// temporary file is moved to to destination removing the original, or some
|
||||
// variant thereof. The watcher on the original file is now lost, as it no
|
||||
// longer exists.
|
||||
//
|
||||
// Instead, watch the parent directory and use Event.Name to filter out files
|
||||
// you're not interested in. There is an example of this in [cmd/fsnotify/file.go].
|
||||
func (w *Watcher) Add(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove stops monitoring the path for changes.
|
||||
//
|
||||
// Directories are always removed non-recursively. For example, if you added
|
||||
// /tmp/dir and /tmp/dir/subdir then you will need to remove both.
|
||||
//
|
||||
// Removing a path that has not yet been added returns [ErrNonExistentWatch].
|
||||
func (w *Watcher) Remove(name string) error {
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user