Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a9701f4c9 | ||
|
|
0bd2fc525e | ||
|
|
32b80191a3 | ||
|
|
c19c87393c | ||
|
|
fe17195bc1 | ||
|
|
6d12a61b2a | ||
|
|
f2e032e1e8 | ||
|
|
d3aaa5c060 | ||
|
|
60ec6b7e66 | ||
|
|
1a5e591f4f | ||
|
|
ea74eda6fc | ||
|
|
b659ab8915 | ||
|
|
1bf8ba4700 | ||
|
|
0d0dcf20b9 | ||
|
|
ef221038f7 | ||
|
|
7a50caac86 | ||
|
|
3876e6c405 | ||
|
|
655b5a096f | ||
|
|
d644cb3757 | ||
|
|
b7f34d8479 | ||
|
|
987e126c9d | ||
|
|
8ce3288ada | ||
|
|
0bf8672a88 | ||
|
|
53655261fe | ||
|
|
bebfa6df92 | ||
|
|
8bdd46fb32 | ||
|
|
da41bd31fb | ||
|
|
7fbb279830 | ||
|
|
816db6f409 | ||
|
|
e2f5f4c731 | ||
|
|
6be4891165 | ||
|
|
18f1113d33 | ||
|
|
b413e5871a | ||
|
|
8f3a74d46c | ||
|
|
480c99cf79 | ||
|
|
74defa85e4 | ||
|
|
3bb1f3ecba | ||
|
|
e3557f5522 | ||
|
|
c4576869ab | ||
|
|
8545ce80e4 | ||
|
|
60b4386dd8 | ||
|
|
e90925eceb | ||
|
|
f2432270e5 | ||
|
|
d71937a087 | ||
|
|
429645c3a9 | ||
|
|
f9d05d94c9 | ||
|
|
4c11919a46 | ||
|
|
2bdb8ca635 | ||
|
|
a6b20a75ab | ||
|
|
9bcc2d462f | ||
|
|
81c6d5abf1 | ||
|
|
ee8d8680ac | ||
|
|
84102d5b5b | ||
|
|
e8ade4173f | ||
|
|
321060d2d6 | ||
|
|
2879b10625 | ||
|
|
01b15b7ac4 | ||
|
|
3e0ecc1c02 | ||
|
|
19e2047a2b | ||
|
|
dbe4a73174 | ||
|
|
a7d5207484 | ||
|
|
0f7af734e5 | ||
|
|
7d0d8cd2e6 | ||
|
|
6d55ee40ed | ||
|
|
dc82d97aaa | ||
|
|
12d2fee2d4 | ||
|
|
ddf2460dbd | ||
|
|
3f1fd00fb6 | ||
|
|
ac9c27aa32 | ||
|
|
1e9558c1fc | ||
|
|
174930fc90 | ||
|
|
0559978fa2 | ||
|
|
f54b87c1f7 | ||
|
|
2ac6c64d13 | ||
|
|
fcd6110790 | ||
|
|
8d6c4aeafe | ||
|
|
14bad9f479 | ||
|
|
4a76a3269d | ||
|
|
351f0fca77 | ||
|
|
363ba313e0 | ||
|
|
3115373118 | ||
|
|
0701f8c9c3 | ||
|
|
b4d6d992ac | ||
|
|
21772d7360 | ||
|
|
a5edaaea78 | ||
|
|
6ddb894577 | ||
|
|
117736dcf3 | ||
|
|
bb7cf4aa7a | ||
|
|
8007f77535 | ||
|
|
ced98e818e | ||
|
|
9d25b9455f | ||
|
|
1bcf9bb050 | ||
|
|
128d2b595a | ||
|
|
8aac16aca8 | ||
|
|
5fe8603506 | ||
|
|
052fd5bb25 | ||
|
|
9e532a6007 | ||
|
|
ad83eab930 |
@@ -1,28 +1,24 @@
|
||||
stages:
|
||||
- test
|
||||
- release
|
||||
|
||||
lint:
|
||||
stage: test
|
||||
default:
|
||||
image: registry.gitlab.com/etke.cc/base/build
|
||||
script:
|
||||
- make lint
|
||||
variables:
|
||||
PLATFORMS: linux/arm64/v8,linux/amd64
|
||||
|
||||
unit:
|
||||
test:
|
||||
stage: test
|
||||
image: registry.gitlab.com/etke.cc/base/build
|
||||
script:
|
||||
- make test
|
||||
- just lint
|
||||
- just test
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- /root/cache/
|
||||
|
||||
docker:
|
||||
stage: release
|
||||
only: ['main', 'tags']
|
||||
services:
|
||||
- docker:dind
|
||||
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/jdrouet/docker-with-buildx:stable
|
||||
before_script:
|
||||
- apk --no-cache add make
|
||||
services: ['docker:dind']
|
||||
script:
|
||||
- make login docker
|
||||
tags:
|
||||
- docker
|
||||
- just login docker
|
||||
|
||||
110
.golangci.yml
110
.golangci.yml
@@ -4,77 +4,123 @@ run:
|
||||
issues-exit-code: 1
|
||||
tests: true
|
||||
build-tags: []
|
||||
skip-dirs: []
|
||||
skip-dirs:
|
||||
- mocks
|
||||
skip-dirs-use-default: true
|
||||
skip-files: []
|
||||
modules-download-mode: readonly
|
||||
allow-parallel-runners: false
|
||||
|
||||
output:
|
||||
format: colored-line-number
|
||||
print-issued-lines: true
|
||||
print-linter-name: true
|
||||
uniq-by-line: true
|
||||
path-prefix: ""
|
||||
sort-results: true
|
||||
|
||||
linters-settings:
|
||||
decorder:
|
||||
dec-order:
|
||||
- const
|
||||
- var
|
||||
- type
|
||||
- func
|
||||
dogsled:
|
||||
max-blank-identifiers: 3
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: true
|
||||
errchkjson:
|
||||
report-no-exported: true
|
||||
exhaustive:
|
||||
check:
|
||||
- switch
|
||||
- map
|
||||
default-signifies-exhaustive: true
|
||||
gocognit:
|
||||
min-complexity: 15
|
||||
nestif:
|
||||
min-complexity: 4
|
||||
min-complexity: 5
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- style
|
||||
- performance
|
||||
gofmt:
|
||||
simplify: true
|
||||
rewrite-rules:
|
||||
- pattern: 'interface{}'
|
||||
replacement: 'any'
|
||||
- pattern: 'a[b:len(a)]'
|
||||
replacement: 'a[b:]'
|
||||
gofumpt:
|
||||
lang-version: "1.18"
|
||||
gosimple:
|
||||
go: "1.18"
|
||||
checks: [ "all" ]
|
||||
extra-rules: true
|
||||
govet:
|
||||
check-shadowing: true
|
||||
enable:
|
||||
- atomicalign
|
||||
- shadow
|
||||
grouper:
|
||||
const-require-single-const: true
|
||||
import-require-single-import: true
|
||||
var-require-single-var: true
|
||||
misspell:
|
||||
locale: US
|
||||
staticcheck:
|
||||
go: "1.18"
|
||||
checks: [ "all" ]
|
||||
stylecheck:
|
||||
go: "1.18"
|
||||
usestdlibvars:
|
||||
time-month: true
|
||||
time-layout: true
|
||||
crypto-hash: true
|
||||
default-rpc-path: true
|
||||
os-dev-null: true
|
||||
sql-isolation-level: true
|
||||
tls-signature-scheme: true
|
||||
constant-kind: true
|
||||
unparam:
|
||||
check-exported: true
|
||||
unused:
|
||||
go: "1.18"
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- prefix(gitlab.com/etke.cc/postmoogle)
|
||||
section-separators:
|
||||
- newLine
|
||||
linters:
|
||||
disable-all: false
|
||||
enable:
|
||||
- megacheck
|
||||
- govet
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- decorder
|
||||
- dogsled
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- gci
|
||||
- errchkjson
|
||||
- errname
|
||||
- errorlint
|
||||
- execinquery
|
||||
- exhaustive
|
||||
- exportloopref
|
||||
- forcetypeassert
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- nestif
|
||||
# - gocritic # ref: https://github.com/golangci/golangci-lint/issues/2649#issue-1170906525
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- goerr113
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
- gosec
|
||||
- gosimple
|
||||
- gosmopolitan
|
||||
- govet
|
||||
- ineffassign
|
||||
- makezero
|
||||
- mirror
|
||||
- misspell
|
||||
- nestif
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- predeclared
|
||||
- revive
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- stylecheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- usestdlibvars
|
||||
- wastedassign
|
||||
fast: false
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ FROM registry.gitlab.com/etke.cc/base/build AS builder
|
||||
|
||||
WORKDIR /postmoogle
|
||||
COPY . .
|
||||
RUN make build
|
||||
RUN just build
|
||||
|
||||
FROM registry.gitlab.com/etke.cc/base/app
|
||||
|
||||
|
||||
54
Makefile
54
Makefile
@@ -1,54 +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
|
||||
go mod vendor
|
||||
|
||||
mock:
|
||||
-@rm -rf mocks
|
||||
@mockery --all
|
||||
|
||||
# run linter
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# run linter and fix issues if possible
|
||||
lintfix:
|
||||
golangci-lint run --fix ./...
|
||||
|
||||
# run unit tests
|
||||
test:
|
||||
@go test -coverprofile=cover.out ./...
|
||||
@go tool cover -func=cover.out
|
||||
-@rm -f cover.out
|
||||
|
||||
# note: make doesn't understand exit code 130 and sets it == 1
|
||||
run:
|
||||
@go run ./cmd || exit 0
|
||||
|
||||
build:
|
||||
go build -v -o postmoogle ./cmd
|
||||
|
||||
# CI: docker login
|
||||
login:
|
||||
@echo "trying to login to docker registry..."
|
||||
$(CI_LOGIN_COMMAND)
|
||||
|
||||
# docker build
|
||||
docker:
|
||||
docker buildx create --use
|
||||
docker buildx build --platform linux/arm64/v8,linux/amd64 --push -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} .
|
||||
142
README.md
142
README.md
@@ -13,23 +13,22 @@ so you can use it to send emails from your apps and scripts as well.
|
||||
### Receive
|
||||
|
||||
- [x] SMTP server (plaintext and SSL)
|
||||
- [x] live reload of SSL certs
|
||||
- [x] Matrix bot
|
||||
- [x] Configuration in room's account data
|
||||
- [x] Receive emails to matrix rooms
|
||||
- [x] Receive attachments
|
||||
- [x] Subaddressing support
|
||||
- [x] Catch-all mailbox
|
||||
- [x] Map email threads to matrix threads
|
||||
- [x] Multi-domain support
|
||||
- [x] automatic banlist
|
||||
- [x] automatic greylisting
|
||||
|
||||
#### deep dive
|
||||
|
||||
> features in that section considered as "nice to have", but not a priority
|
||||
|
||||
- [ ] DKIM verification
|
||||
- [ ] SPF verification
|
||||
- [ ] DMARC verification
|
||||
- [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
|
||||
|
||||
@@ -37,6 +36,8 @@ so you can use it to send emails from your apps and scripts as well.
|
||||
- [x] SMTP server (you can use Postmoogle as general purpose SMTP server to send emails from your scripts or apps)
|
||||
- [x] Send a message to matrix room with special format to send a new email, even to multiple email addresses at once
|
||||
- [x] Reply to matrix thread sends reply into email thread
|
||||
- [x] Email signatures
|
||||
- [x] Email autoreply / autoresponder for new email threads
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -45,33 +46,44 @@ so you can use it to send emails from your apps and scripts as well.
|
||||
env vars
|
||||
|
||||
* **POSTMOOGLE_HOMESERVER** - homeserver url, eg: `https://matrix.example.com`
|
||||
* **POSTMOOGLE_LOGIN** - user login/localpart, eg: `moogle`
|
||||
* **POSTMOOGLE_PASSWORD** - user password
|
||||
* **POSTMOOGLE_LOGIN** - user login, localpart when logging in with password (e.g., `moogle`), OR full MXID when using shared secret (e.g., `@moogle:example.com`)
|
||||
* **POSTMOOGLE_PASSWORD** - user password, alternatively you may use shared secret
|
||||
* **POSTMOOGLE_SHAREDSECRET** - alternative to password, shared secret ([details](https://github.com/devture/matrix-synapse-shared-secret-auth))
|
||||
* **POSTMOOGLE_DOMAINS** - space separated list of SMTP domains to listen for new emails. The first domain acts as the default domain, all other as aliases
|
||||
|
||||
<details>
|
||||
<summary>other optional config parameters</summary>
|
||||
|
||||
* **POSTMOOGLE_PORT** - SMTP port to listen for new emails
|
||||
* **POSTMOOGLE_PROXIES** - space separated list of IP addresses considered as trusted proxies, thus never banned
|
||||
* **POSTMOOGLE_TLS_PORT** - secure SMTP port to listen for new emails. Requires valid cert and key as well
|
||||
* **POSTMOOGLE_TLS_CERT** - space separated list of paths to the SSL certificates (chain) of your domains, note that position in the cert list must match the position of the cert's key in the key list
|
||||
* **POSTMOOGLE_TLS_KEY** - space separated list of paths to the SSL certificates' private keys of your domains, note that position on the key list must match the position of cert in the cert list
|
||||
* **POSTMOOGLE_TLS_REQUIRED** - require TLS connection, **even** on the non-TLS port (`POSTMOOGLE_PORT`). TLS connections are always required on the TLS port (`POSTMOOGLE_TLS_PORT`) regardless of this setting.
|
||||
* **POSTMOOGLE_DATA_SECRET** - secure key (password) to encrypt account data, must be 16, 24, or 32 bytes long
|
||||
* **POSTMOOGLE_NOENCRYPTION** - disable matrix encryption (libolm) support
|
||||
* **POSTMOOGLE_STATUSMSG** - presence status message
|
||||
* **POSTMOOGLE_SENTRY_DSN** - sentry DSN
|
||||
* **POSTMOOGLE_MONITORING_SENTRY_DSN** - sentry DSN
|
||||
* **POSTMOOGLE_MONITORING_SENTRY_RATE** - sentry sample rate, from 0 to 100 (default: 20)
|
||||
* **POSTMOOGLE_MONITORING_HEALTHCHECKS_UUID** - healthchecks.io UUID
|
||||
* **POSTMOOGLE_MONITORING_HEALTHCHECKS_DURATION** - heathchecks.io duration between pings in secods (default: 5)
|
||||
* **POSTMOOGLE_LOGLEVEL** - log level
|
||||
* **POSTMOOGLE_DB_DSN** - database connection string
|
||||
* **POSTMOOGLE_DB_DIALECT** - database dialect (postgres, sqlite3)
|
||||
* **POSTMOOGLE_MAILBOXES_RESERVED** - space separated list of reserved mailboxes, [docs/mailboxes.md](docs/mailboxes.md)
|
||||
* **POSTMOOGLE_MAILBOXES_FORWARDED** - space separated list of forwarded from emails that should be ignored when sending replies
|
||||
* **POSTMOOGLE_MAILBOXES_ACTIVATION** - activation flow for new mailboxes, [docs/mailboxes.md](docs/mailboxes.md)
|
||||
* **POSTMOOGLE_MAXSIZE** - max email size (including attachments) in megabytes
|
||||
* **POSTMOOGLE_ADMINS** - a space-separated list of admin users. See `POSTMOOGLE_USERS` for syntax examples
|
||||
* **POSTMOOGLE_RELAY_HOST** - SMTP hostname of relay host (e.g. Sendgrid)
|
||||
* **POSTMOOGLE_RELAY_PORT** - SMTP port of relay host
|
||||
* **POSTMOOGLE_RELAY_USERNAME** - Username of relay host
|
||||
* **POSTMOOGLE_RELAY_PASSWORD** - Password of relay host
|
||||
|
||||
You can find default values in [config/defaults.go](config/defaults.go)
|
||||
|
||||
</details>
|
||||
|
||||
### 2. DNS (optional)
|
||||
### 2. DNS (highly recommended)
|
||||
|
||||
Follow the [docs/dns](docs/dns.md)
|
||||
|
||||
@@ -88,48 +100,94 @@ If you want to change them - check available options in the help message (`!pm h
|
||||
<details>
|
||||
<summary>Full list of available commands</summary>
|
||||
|
||||
* **!pm help** - Show help message
|
||||
* **!pm stop** - Disable bridge for the room and clear all configuration
|
||||
> The following section is visible to all allowed users
|
||||
|
||||
* **`!pm help`** - Show this help message
|
||||
* **`!pm stop`** - Disable bridge for the room and clear all configuration
|
||||
* **`!pm send`** - Send email
|
||||
|
||||
---
|
||||
|
||||
* **!pm mailbox** - Get or set mailbox of the room
|
||||
* **!pm 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
|
||||
#### mailbox ownership
|
||||
|
||||
> The following section is visible to the mailbox owners only
|
||||
|
||||
* **`!pm mailbox`** - Get or set mailbox of the room
|
||||
* **`!pm domain`** - Get or set default domain of the room
|
||||
* **`!pm owner`** - Get or set owner of the room
|
||||
* **`!pm password`** - Get or set SMTP password of the room's mailbox
|
||||
|
||||
---
|
||||
|
||||
* **!pm nosender** - Get or set `nosender` of the room (`true` - hide email sender; `false` - show email sender)
|
||||
* **!pm norecipient** - Get or set `norecipient` of the room (`true` - hide recipient; `false` - show recipient)
|
||||
* **!pm nosubject** - Get or set `nosubject` of the room (`true` - hide email subject; `false` - show email subject)
|
||||
* **!pm nohtml** - Get or set `nohtml` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)
|
||||
* **!pm nothreads** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)
|
||||
* **!pm nofiles** - Get or set `nofiles` of the room (`true` - ignore email attachments; `false` - upload email attachments)
|
||||
#### mailbox options
|
||||
|
||||
> The following section is visible to the mailbox owners only
|
||||
|
||||
* **`!pm autoreply`** - Get or set autoreply of the room (markdown supported) that will be sent on any new incoming email thread
|
||||
* **`!pm signature`** - Get or set signature of the room (markdown supported)
|
||||
* **`!pm threadify`** - Get or set `threadify` of the room (`true` - send incoming email body in thread; `false` - send incoming email body as part of the message)
|
||||
* **`!pm nosend`** - Get or set `nosend` of the room (`true` - disable email sending; `false` - enable email sending)
|
||||
* **`!pm noreplies`** - Get or set `noreplies` of the room (`true` - ignore matrix replies; `false` - parse matrix replies)
|
||||
* **`!pm nosender`** - Get or set `nosender` of the room (`true` - hide email sender; `false` - show email sender)
|
||||
* **`!pm norecipient`** - Get or set `norecipient` of the room (`true` - hide recipient; `false` - show recipient)
|
||||
* **`!pm nocc`** - Get or set `nocc` of the room (`true` - hide CC; `false` - show CC)
|
||||
* **`!pm nosubject`** - Get or set `nosubject` of the room (`true` - hide email subject; `false` - show email subject)
|
||||
* **`!pm nohtml`** - Get or set `nohtml` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)
|
||||
* **`!pm nothreads`** - Get or set `nothreads` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)
|
||||
* **`!pm nofiles`** - Get or set `nofiles` of the room (`true` - ignore email attachments; `false` - upload email attachments)
|
||||
* **`!pm noinlines`** - Get or set `noinlines` of the room (`true` - ignore inline attachments; `false` - upload inline attachments)
|
||||
|
||||
---
|
||||
|
||||
* **!pm spamcheck:mx** - only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)
|
||||
* **!pm spamcheck:smtp** - only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)
|
||||
* **!pm spamlist** - Get or set `spamlist` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,noreply@*`
|
||||
#### mailbox security checks
|
||||
|
||||
> The following section is visible to the mailbox owners only
|
||||
|
||||
* **`!pm spamcheck:mx`** - only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)
|
||||
* **`!pm spamcheck:spf`** - only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable)
|
||||
* **`!pm spamcheck:dkim`** - only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`true` - enable, `false` - disable)
|
||||
* **`!pm spamcheck:smtp`** - only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)
|
||||
|
||||
---
|
||||
|
||||
* **!pm dkim** - Get DKIM signature
|
||||
* **!pm catch-all** - Configure catch-all mailbox
|
||||
* **!pm 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
|
||||
#### mailbox anti-spam
|
||||
|
||||
> The following section is visible to the mailbox owners only
|
||||
|
||||
* **`!pm spam:list`** - Show comma-separated spamlist of the room, eg: `spammer@example.com,*@spammer.org,spam@*`
|
||||
* **`!pm spam:add`** - Mark an email address (or pattern) as spam (or you can react to the email with emoji: ⛔️,🛑, or 🚫)
|
||||
* **`!pm spam:remove`** - Unmark an email address (or pattern) as spam
|
||||
* **`!pm spam:reset`** - Reset spamlist
|
||||
|
||||
---
|
||||
|
||||
* **!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
|
||||
#### server options
|
||||
|
||||
> The following section is visible to the bridge admins only
|
||||
|
||||
* **`!pm adminroom`** - Get or set admin room
|
||||
* **`!pm users`** - Get or set allowed users
|
||||
* **`!pm dkim`** - Get DKIM signature
|
||||
* **`!pm catch-all`** - Get or set catch-all mailbox
|
||||
* **`!pm queue:batch`** - max amount of emails to process on each queue check
|
||||
* **`!pm queue:retries`** - max amount of tries per email in queue before removal
|
||||
* **`!pm mailboxes`** - Show the list of all mailboxes
|
||||
* **`!pm delete`** - Delete specific mailbox
|
||||
|
||||
---
|
||||
|
||||
#### server antispam
|
||||
|
||||
> The following section is visible to the bridge admins only
|
||||
|
||||
* **`!pm greylist`** - Set automatic greylisting duration in minutes (0 - disabled)
|
||||
* **`!pm banlist`** - Enable/disable banlist and show current values
|
||||
* **`!pm banlist:auth`** - Enable/disable automatic banning for invalid auth credentials
|
||||
* **`!pm banlist:auto`** - Enable/disable automatic banning for invalid emails
|
||||
* **`!pm banlist:totals`** - List banlist totals only
|
||||
* **`!pm banlist:add`** - Ban an IP
|
||||
* **`!pm banlist:remove`** - Unban an IP
|
||||
* **`!pm banlist:reset`** - Reset banlist
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
176
bot/access.go
176
bot/access.go
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/raja/argon2pw"
|
||||
"gitlab.com/etke.cc/go/mxidwc"
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -23,27 +22,37 @@ func parseMXIDpatterns(patterns []string, defaultPattern string) ([]*regexp.Rege
|
||||
return mxidwc.ParsePatterns(patterns)
|
||||
}
|
||||
|
||||
func (b *Bot) allowUsers(actorID id.UserID) bool {
|
||||
if len(b.allowedUsers) != 0 {
|
||||
if !mxidwc.Match(actorID.String(), b.allowedUsers) {
|
||||
return false
|
||||
}
|
||||
func (b *Bot) allowUsers(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
// first, check if it's an allowed user
|
||||
if mxidwc.Match(actorID.String(), b.allowedUsers) {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
// second, check if it's an admin (admin may not fit the allowed users pattern)
|
||||
if b.allowAdmin(actorID, targetRoomID) {
|
||||
return true
|
||||
}
|
||||
|
||||
// then, check if it's the owner (same as above)
|
||||
cfg, err := b.cfg.GetRoom(targetRoomID)
|
||||
if err == nil && cfg.Owner() == actorID.String() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *Bot) allowAnyone(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
func (b *Bot) allowAnyone(_ id.UserID, _ id.RoomID) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
if !b.allowUsers(actorID) {
|
||||
if !b.allowUsers(actorID, targetRoomID) {
|
||||
return false
|
||||
}
|
||||
cfg, err := b.getRoomSettings(targetRoomID)
|
||||
cfg, err := b.cfg.GetRoom(targetRoomID)
|
||||
if err != nil {
|
||||
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
|
||||
b.Error(context.Background(), "failed to retrieve settings: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -52,71 +61,153 @@ func (b *Bot) allowOwner(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
return owner == actorID.String()
|
||||
return owner == actorID.String() || b.allowAdmin(actorID, targetRoomID)
|
||||
}
|
||||
|
||||
func (b *Bot) allowAdmin(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
func (b *Bot) allowAdmin(actorID id.UserID, _ id.RoomID) bool {
|
||||
return mxidwc.Match(actorID.String(), b.allowedAdmins)
|
||||
}
|
||||
|
||||
func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
if !b.allowUsers(actorID) {
|
||||
if !b.allowUsers(actorID, targetRoomID) {
|
||||
return false
|
||||
}
|
||||
|
||||
cfg, err := b.getRoomSettings(targetRoomID)
|
||||
cfg, err := b.cfg.GetRoom(targetRoomID)
|
||||
if err != nil {
|
||||
b.Error(sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()), targetRoomID, "failed to retrieve settings: %v", err)
|
||||
b.Error(context.Background(), "failed to retrieve settings: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return !cfg.NoSend()
|
||||
}
|
||||
|
||||
// IsGreylisted checks if host is in greylist
|
||||
func (b *Bot) IsGreylisted(addr net.Addr) bool {
|
||||
if b.getBotSettings().Greylist() == 0 {
|
||||
func (b *Bot) allowReply(actorID id.UserID, targetRoomID id.RoomID) bool {
|
||||
if !b.allowUsers(actorID, targetRoomID) {
|
||||
return false
|
||||
}
|
||||
|
||||
greylist := b.getGreylist()
|
||||
cfg, err := b.cfg.GetRoom(targetRoomID)
|
||||
if err != nil {
|
||||
b.Error(context.Background(), "failed to retrieve settings: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return !cfg.NoReplies()
|
||||
}
|
||||
|
||||
func (b *Bot) isReserved(mailbox string) bool {
|
||||
for _, reserved := range b.mbxc.Reserved {
|
||||
if mailbox == reserved {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsGreylisted checks if host is in greylist
|
||||
func (b *Bot) IsGreylisted(addr net.Addr) bool {
|
||||
if b.cfg.GetBot().Greylist() == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
greylist := b.cfg.GetGreylist()
|
||||
greylistedAt, ok := greylist.Get(addr)
|
||||
if !ok {
|
||||
b.log.Debug("greylisting %s", addr.String())
|
||||
b.log.Debug().Str("addr", addr.String()).Msg("greylisting")
|
||||
greylist.Add(addr)
|
||||
err := b.setGreylist(greylist)
|
||||
err := b.cfg.SetGreylist(greylist)
|
||||
if err != nil {
|
||||
b.log.Error("cannot update greylist with %s: %v", addr.String(), err)
|
||||
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update greylist")
|
||||
}
|
||||
return true
|
||||
}
|
||||
duration := time.Duration(b.getBotSettings().Greylist()) * time.Minute
|
||||
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.banlist.Has(addr)
|
||||
return b.cfg.GetBanlist().Has(addr)
|
||||
}
|
||||
|
||||
// Ban an address
|
||||
func (b *Bot) Ban(addr net.Addr) {
|
||||
if !b.getBotSettings().BanlistEnabled() {
|
||||
// IsTrusted checks if address is a trusted (proxy)
|
||||
func (b *Bot) IsTrusted(addr net.Addr) bool {
|
||||
ip := utils.AddrIP(addr)
|
||||
for _, proxy := range b.proxies {
|
||||
if ip == proxy {
|
||||
b.log.Debug().Str("addr", ip).Msg("address is trusted")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Ban an address automatically
|
||||
func (b *Bot) BanAuto(addr net.Addr) {
|
||||
if !b.cfg.GetBot().BanlistEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
b.log.Debug("banning %s", addr.String())
|
||||
banlist := b.getBanlist()
|
||||
if !b.cfg.GetBot().BanlistAuto() {
|
||||
return
|
||||
}
|
||||
|
||||
if b.IsTrusted(addr) {
|
||||
return
|
||||
}
|
||||
b.log.Debug().Str("addr", addr.String()).Msg("attempting to automatically ban")
|
||||
banlist := b.cfg.GetBanlist()
|
||||
banlist.Add(addr)
|
||||
err := b.setBanlist(banlist)
|
||||
err := b.cfg.SetBanlist(banlist)
|
||||
if err != nil {
|
||||
b.log.Error("cannot update banlist with %s: %v", addr.String(), err)
|
||||
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update banlist")
|
||||
}
|
||||
}
|
||||
|
||||
// Ban an address for incorrect auth automatically
|
||||
func (b *Bot) BanAuth(addr net.Addr) {
|
||||
if !b.cfg.GetBot().BanlistEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
if !b.cfg.GetBot().BanlistAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
if b.IsTrusted(addr) {
|
||||
return
|
||||
}
|
||||
b.log.Debug().Str("addr", addr.String()).Msg("attempting to automatically ban")
|
||||
banlist := b.cfg.GetBanlist()
|
||||
banlist.Add(addr)
|
||||
err := b.cfg.SetBanlist(banlist)
|
||||
if err != nil {
|
||||
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update banlist")
|
||||
}
|
||||
}
|
||||
|
||||
// Ban an address manually
|
||||
func (b *Bot) BanManually(addr net.Addr) {
|
||||
if !b.cfg.GetBot().BanlistEnabled() {
|
||||
return
|
||||
}
|
||||
if b.IsTrusted(addr) {
|
||||
return
|
||||
}
|
||||
b.log.Debug().Str("addr", addr.String()).Msg("attempting to manually ban")
|
||||
banlist := b.cfg.GetBanlist()
|
||||
banlist.Add(addr)
|
||||
err := b.cfg.SetBanlist(banlist)
|
||||
if err != nil {
|
||||
b.log.Error().Err(err).Str("addr", addr.String()).Msg("cannot update banlist")
|
||||
}
|
||||
}
|
||||
|
||||
// AllowAuth check if SMTP login (email) and password are valid
|
||||
func (b *Bot) AllowAuth(email, password string) bool {
|
||||
func (b *Bot) AllowAuth(email, password string) (id.RoomID, bool) {
|
||||
var suffix bool
|
||||
for _, domain := range b.domains {
|
||||
if strings.HasSuffix(email, "@"+domain) {
|
||||
@@ -125,22 +216,27 @@ func (b *Bot) AllowAuth(email, password string) bool {
|
||||
}
|
||||
}
|
||||
if !suffix {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
|
||||
roomID, ok := b.getMapping(utils.Mailbox(email))
|
||||
if !ok {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
cfg, err := b.getRoomSettings(roomID)
|
||||
cfg, err := b.cfg.GetRoom(roomID)
|
||||
if err != nil {
|
||||
b.log.Error("failed to retrieve settings: %v", err)
|
||||
return false
|
||||
b.log.Error().Err(err).Msg("failed to retrieve settings")
|
||||
return "", false
|
||||
}
|
||||
|
||||
if cfg.NoSend() {
|
||||
b.log.Warn().Str("email", email).Str("roomID", roomID.String()).Msg("trying to send email, but room is receive-only")
|
||||
return "", false
|
||||
}
|
||||
|
||||
allow, err := argon2pw.CompareHashWithPassword(cfg.Password(), password)
|
||||
if err != nil {
|
||||
b.log.Warn("Password for %s is not valid: %v", email, err)
|
||||
b.log.Warn().Err(err).Str("email", email).Msg("Password is not valid")
|
||||
}
|
||||
return allow
|
||||
return roomID, allow
|
||||
}
|
||||
|
||||
54
bot/activation.go
Normal file
54
bot/activation.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type activationFlow func(id.UserID, id.RoomID, string) bool
|
||||
|
||||
func (b *Bot) getActivationFlow() activationFlow {
|
||||
switch b.mbxc.Activation {
|
||||
case "none":
|
||||
return b.activateNone
|
||||
case "notify":
|
||||
return b.activateNotify
|
||||
default:
|
||||
return b.activateNone
|
||||
}
|
||||
}
|
||||
|
||||
// ActivateMailbox using the configured flow
|
||||
func (b *Bot) ActivateMailbox(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
|
||||
flow := b.getActivationFlow()
|
||||
return flow(ownerID, roomID, mailbox)
|
||||
}
|
||||
|
||||
func (b *Bot) activateNone(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
|
||||
b.log.Debug().Str("mailbox", mailbox).Str("roomID", roomID.String()).Str("ownerID", ownerID.String()).Msg("activating mailbox through the flow 'none'")
|
||||
b.rooms.Store(mailbox, roomID)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Bot) activateNotify(ownerID id.UserID, roomID id.RoomID, mailbox string) bool {
|
||||
b.log.Debug().Str("mailbox", mailbox).Str("roomID", roomID.String()).Str("ownerID", ownerID.String()).Msg("activating mailbox through the flow 'notify'")
|
||||
b.rooms.Store(mailbox, roomID)
|
||||
if len(b.adminRooms) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Mailbox %q has been registered by %q for the room %q", mailbox, ownerID, roomID)
|
||||
for _, adminRoom := range b.adminRooms {
|
||||
content := format.RenderMarkdown(msg, true, true)
|
||||
_, err := b.lp.Send(adminRoom, &content)
|
||||
if err != nil {
|
||||
b.log.Info().Str("adminRoom", adminRoom.String()).Msg("cannot send mailbox activation notification to the admin room")
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return true
|
||||
}
|
||||
101
bot/bot.go
101
bot/bot.go
@@ -6,45 +6,68 @@ import (
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"github.com/rs/zerolog"
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
"gitlab.com/etke.cc/postmoogle/bot/queue"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// Mailboxes config
|
||||
type MBXConfig struct {
|
||||
Reserved []string
|
||||
Forwarded []string
|
||||
Activation string
|
||||
}
|
||||
|
||||
// Bot represents matrix bot
|
||||
type Bot struct {
|
||||
prefix string
|
||||
mbxc MBXConfig
|
||||
domains []string
|
||||
allowedUsers []*regexp.Regexp
|
||||
allowedAdmins []*regexp.Regexp
|
||||
adminRooms []id.RoomID
|
||||
ignoreBefore int64 // mautrix 0.15.x migration
|
||||
commands commandList
|
||||
banlist bglist
|
||||
rooms sync.Map
|
||||
proxies []string
|
||||
sendmail func(string, string, string) error
|
||||
log *logger.Logger
|
||||
cfg *config.Manager
|
||||
log *zerolog.Logger
|
||||
lp *linkpearl.Linkpearl
|
||||
mu map[string]*sync.Mutex
|
||||
mu utils.Mutex
|
||||
q *queue.Queue
|
||||
handledMembershipEvents sync.Map
|
||||
}
|
||||
|
||||
// New creates a new matrix bot
|
||||
func New(
|
||||
q *queue.Queue,
|
||||
lp *linkpearl.Linkpearl,
|
||||
log *logger.Logger,
|
||||
log *zerolog.Logger,
|
||||
cfg *config.Manager,
|
||||
proxies []string,
|
||||
prefix string,
|
||||
domains []string,
|
||||
admins []string,
|
||||
mbxc MBXConfig,
|
||||
) (*Bot, error) {
|
||||
b := &Bot{
|
||||
prefix: prefix,
|
||||
domains: domains,
|
||||
rooms: sync.Map{},
|
||||
log: log,
|
||||
lp: lp,
|
||||
mu: map[string]*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 {
|
||||
@@ -68,45 +91,45 @@ func New(
|
||||
}
|
||||
|
||||
// Error message to the log and matrix room
|
||||
func (b *Bot) Error(ctx context.Context, roomID id.RoomID, message string, args ...interface{}) {
|
||||
b.log.Error(message, args...)
|
||||
err := fmt.Errorf(message, args...)
|
||||
|
||||
if hub := sentry.GetHubFromContext(ctx); hub != nil {
|
||||
sentry.GetHubFromContext(ctx).CaptureException(err)
|
||||
func (b *Bot) Error(ctx context.Context, message string, args ...any) {
|
||||
evt := eventFromContext(ctx)
|
||||
threadID := threadIDFromContext(ctx)
|
||||
if threadID == "" {
|
||||
threadID = linkpearl.EventParent(evt.ID, evt.Content.AsMessage())
|
||||
}
|
||||
if roomID != "" {
|
||||
b.SendError(ctx, roomID, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// SendError sends an error message to the matrix room
|
||||
func (b *Bot) SendError(ctx context.Context, roomID id.RoomID, message string) {
|
||||
b.SendNotice(ctx, roomID, "ERROR: "+message)
|
||||
}
|
||||
|
||||
// SendNotice sends a notice message to the matrix room
|
||||
func (b *Bot) SendNotice(ctx context.Context, roomID id.RoomID, message string) {
|
||||
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)
|
||||
err := fmt.Errorf(message, args...) //nolint:goerr113 // we have to
|
||||
b.log.Error().Err(err).Msg(err.Error())
|
||||
if evt == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var noThreads bool
|
||||
cfg, cerr := b.cfg.GetRoom(evt.RoomID)
|
||||
if cerr == nil {
|
||||
noThreads = cfg.NoThreads()
|
||||
}
|
||||
|
||||
var relatesTo *event.RelatesTo
|
||||
if threadID != "" {
|
||||
relatesTo = linkpearl.RelatesTo(threadID, noThreads)
|
||||
}
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, "ERROR: "+err.Error(), relatesTo)
|
||||
}
|
||||
|
||||
// Start performs matrix /sync
|
||||
func (b *Bot) Start(statusMsg string) error {
|
||||
if err := b.migrate(); err != nil {
|
||||
if err := b.migrateMautrix015(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.syncRooms(); err != nil {
|
||||
return err
|
||||
}
|
||||
b.syncBanlist()
|
||||
|
||||
b.initSync()
|
||||
b.log.Info("Postmoogle has been started")
|
||||
b.log.Info().Msg("Postmoogle has been started")
|
||||
return b.lp.Start(statusMsg)
|
||||
}
|
||||
|
||||
@@ -114,7 +137,7 @@ func (b *Bot) Start(statusMsg string) error {
|
||||
func (b *Bot) Stop() {
|
||||
err := b.lp.GetClient().SetPresence(event.PresenceOffline)
|
||||
if err != nil {
|
||||
b.log.Error("cannot set presence = offline: %v", err)
|
||||
b.log.Error().Err(err).Msg("cannot set presence = offline")
|
||||
}
|
||||
b.lp.GetClient().StopSync()
|
||||
}
|
||||
|
||||
408
bot/command.go
408
bot/command.go
@@ -2,32 +2,43 @@ package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
commandHelp = "help"
|
||||
commandStop = "stop"
|
||||
commandSend = "send"
|
||||
commandDKIM = "dkim"
|
||||
commandCatchAll = botOptionCatchAll
|
||||
commandUsers = botOptionUsers
|
||||
commandQueueBatch = botOptionQueueBatch
|
||||
commandQueueRetries = botOptionQueueRetries
|
||||
commandDelete = "delete"
|
||||
commandBanlist = "banlist"
|
||||
commandBanlistAdd = "banlist:add"
|
||||
commandBanlistRemove = "banlist:remove"
|
||||
commandBanlistReset = "banlist:reset"
|
||||
commandMailboxes = "mailboxes"
|
||||
commandHelp = "help"
|
||||
commandStop = "stop"
|
||||
commandSend = "send"
|
||||
commandDKIM = "dkim"
|
||||
commandCatchAll = config.BotCatchAll
|
||||
commandUsers = config.BotUsers
|
||||
commandQueueBatch = config.BotQueueBatch
|
||||
commandQueueRetries = config.BotQueueRetries
|
||||
commandSpamlist = "spam:list"
|
||||
commandSpamlistAdd = "spam:add"
|
||||
commandSpamlistRemove = "spam:remove"
|
||||
commandSpamlistReset = "spam:reset"
|
||||
commandDelete = "delete"
|
||||
commandBanlist = "banlist"
|
||||
commandBanlistTotals = "banlist:totals"
|
||||
commandBanlistAuto = "banlist:auto"
|
||||
commandBanlistAuth = "banlist:auth"
|
||||
commandBanlistAdd = "banlist:add"
|
||||
commandBanlistRemove = "banlist:remove"
|
||||
commandBanlistReset = "banlist:reset"
|
||||
commandMailboxes = "mailboxes"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -67,120 +78,198 @@ 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: roomOptionDomain,
|
||||
key: config.RoomDomain,
|
||||
description: "Get or set default domain of the room",
|
||||
sanitizer: utils.SanitizeDomain,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionOwner,
|
||||
key: config.RoomOwner,
|
||||
description: "Get or set owner of the room",
|
||||
sanitizer: func(s string) string { return s },
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionPassword,
|
||||
key: config.RoomPassword,
|
||||
description: "Get or set SMTP password of the room's mailbox",
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowOwner}, // delimiter
|
||||
{allowed: b.allowOwner, description: "mailbox options"}, // delimiter
|
||||
{
|
||||
key: roomOptionNoSend,
|
||||
key: config.RoomAutoreply,
|
||||
description: "Get or set autoreply of the room (markdown supported) that will be send for any new incoming email thread",
|
||||
sanitizer: func(s string) string { return s },
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: config.RoomSignature,
|
||||
description: "Get or set signature of the room (markdown supported)",
|
||||
sanitizer: func(s string) string { return s },
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: config.RoomThreadify,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - send incoming email body in thread; `false` - send incoming email body as part of the message)",
|
||||
config.RoomThreadify,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: config.RoomNoSend,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - disable email sending; `false` - enable email sending)",
|
||||
roomOptionNoSend,
|
||||
config.RoomNoSend,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoSender,
|
||||
key: config.RoomNoReplies,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - ignore matrix replies; `false` - parse matrix replies)",
|
||||
config.RoomNoReplies,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: config.RoomNoSender,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - hide email sender; `false` - show email sender)",
|
||||
roomOptionNoSender,
|
||||
config.RoomNoSender,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoRecipient,
|
||||
key: config.RoomNoRecipient,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - hide recipient; `false` - show recipient)",
|
||||
roomOptionNoRecipient,
|
||||
config.RoomNoRecipient,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoSubject,
|
||||
key: config.RoomNoCC,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - hide CC; `false` - show CC)",
|
||||
config.RoomNoCC,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: config.RoomNoSubject,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - hide email subject; `false` - show email subject)",
|
||||
roomOptionNoSubject,
|
||||
config.RoomNoSubject,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoHTML,
|
||||
key: config.RoomNoHTML,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - ignore HTML in email; `false` - parse HTML in emails)",
|
||||
roomOptionNoHTML,
|
||||
config.RoomNoHTML,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoThreads,
|
||||
key: config.RoomNoThreads,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - ignore email threads; `false` - convert email threads into matrix threads)",
|
||||
roomOptionNoThreads,
|
||||
config.RoomNoThreads,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionNoFiles,
|
||||
key: config.RoomNoFiles,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - ignore email attachments; `false` - upload email attachments)",
|
||||
roomOptionNoFiles,
|
||||
config.RoomNoFiles,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowOwner}, // delimiter
|
||||
{
|
||||
key: roomOptionSpamcheckMX,
|
||||
key: config.RoomNoInlines,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (`true` - ignore inline attachments; `false` - upload inline attachments)",
|
||||
config.RoomNoInlines,
|
||||
),
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowOwner, description: "mailbox security checks"}, // delimiter
|
||||
{
|
||||
key: config.RoomSpamcheckMX,
|
||||
description: "only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable)",
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionSpamcheckSMTP,
|
||||
description: "only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)",
|
||||
key: config.RoomSpamcheckSPF,
|
||||
description: "only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable)",
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: roomOptionSpamlist,
|
||||
description: fmt.Sprintf(
|
||||
"Get or set `%s` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,spam@*`",
|
||||
roomOptionSpamlist,
|
||||
),
|
||||
sanitizer: utils.SanitizeStringSlice,
|
||||
allowed: b.allowOwner,
|
||||
key: config.RoomSpamcheckDKIM,
|
||||
description: "only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`true` - enable, `false` - disable)",
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowAdmin}, // delimiter
|
||||
{
|
||||
key: botOptionUsers,
|
||||
key: config.RoomSpamcheckSMTP,
|
||||
description: "only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)",
|
||||
sanitizer: utils.SanitizeBoolString,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowOwner, description: "mailbox anti-spam"}, // delimiter
|
||||
{
|
||||
key: commandSpamlist,
|
||||
description: "Show comma-separated spamlist of the room, eg: `spammer@example.com,*@spammer.org,spam@*`",
|
||||
sanitizer: utils.SanitizeStringSlice,
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: commandSpamlistAdd,
|
||||
description: "Mark an email address (or pattern) as spam (or you can react to the email with emoji: ⛔️,🛑, or 🚫)",
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: commandSpamlistRemove,
|
||||
description: "Unmark an email address (or pattern) as spam",
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{
|
||||
key: commandSpamlistReset,
|
||||
description: "Reset spamlist",
|
||||
allowed: b.allowOwner,
|
||||
},
|
||||
{allowed: b.allowAdmin, description: "server options"}, // delimiter
|
||||
{
|
||||
key: config.BotAdminRoom,
|
||||
description: "Get or set admin room",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: config.BotUsers,
|
||||
description: "Get or set allowed users",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
@@ -216,9 +305,9 @@ func (b *Bot) initCommands() commandList {
|
||||
description: "Delete specific mailbox",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{allowed: b.allowAdmin}, // delimiter
|
||||
{allowed: b.allowAdmin, description: "server antispam"}, // delimiter
|
||||
{
|
||||
key: botOptionGreylist,
|
||||
key: config.BotGreylist,
|
||||
description: "Set automatic greylisting duration in minutes (0 - disabled)",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
@@ -227,6 +316,21 @@ func (b *Bot) initCommands() commandList {
|
||||
description: "Enable/disable banlist and show current values",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: commandBanlistAuth,
|
||||
description: "Enable/disable automatic banning of IP addresses when they try to auth with invalid credentials",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: commandBanlistAuto,
|
||||
description: "Enable/disable automatic banning of IP addresses when they try to send invalid emails",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: commandBanlistTotals,
|
||||
description: "List banlist totals only",
|
||||
allowed: b.allowAdmin,
|
||||
},
|
||||
{
|
||||
key: commandBanlistAdd,
|
||||
description: "Ban an IP",
|
||||
@@ -245,19 +349,43 @@ func (b *Bot) initCommands() commandList {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice []string) {
|
||||
func (b *Bot) handle(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
err := b.lp.GetClient().MarkRead(evt.RoomID, evt.ID)
|
||||
if err != nil {
|
||||
b.log.Error().Err(err).Msg("cannot send read receipt")
|
||||
}
|
||||
|
||||
content := evt.Content.AsMessage()
|
||||
if content == nil {
|
||||
b.Error(ctx, "cannot read message")
|
||||
return
|
||||
}
|
||||
// ignore notices
|
||||
if content.MsgType == event.MsgNotice {
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(content.Body)
|
||||
commandSlice := b.parseCommand(message, true)
|
||||
if commandSlice == nil {
|
||||
if linkpearl.EventParent("", content) != "" {
|
||||
b.SendEmailReply(ctx)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cmd := b.commands.get(commandSlice[0])
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
_, err := b.lp.GetClient().UserTyping(evt.RoomID, true, 30*time.Second)
|
||||
_, err = b.lp.GetClient().UserTyping(evt.RoomID, true, 30*time.Second)
|
||||
if err != nil {
|
||||
b.log.Error("cannot send typing notification: %v", err)
|
||||
b.log.Error().Err(err).Msg("cannot send typing notification")
|
||||
}
|
||||
defer b.lp.GetClient().UserTyping(evt.RoomID, false, 30*time.Second) //nolint:errcheck
|
||||
defer b.lp.GetClient().UserTyping(evt.RoomID, false, 30*time.Second) //nolint:errcheck // we don't care
|
||||
|
||||
if !cmd.allowed(evt.Sender, evt.RoomID) {
|
||||
b.SendNotice(ctx, evt.RoomID, "not allowed to do that, kupo")
|
||||
b.lp.SendNotice(evt.RoomID, "not allowed to do that, kupo")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -270,20 +398,34 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice
|
||||
b.runSend(ctx)
|
||||
case commandDKIM:
|
||||
b.runDKIM(ctx, commandSlice)
|
||||
case commandSpamlistAdd:
|
||||
b.runSpamlistAdd(ctx, commandSlice)
|
||||
case commandSpamlistRemove:
|
||||
b.runSpamlistRemove(ctx, commandSlice)
|
||||
case commandSpamlistReset:
|
||||
b.runSpamlistReset(ctx)
|
||||
case config.BotAdminRoom:
|
||||
b.runAdminRoom(ctx, commandSlice)
|
||||
case commandUsers:
|
||||
b.runUsers(ctx, commandSlice)
|
||||
case commandCatchAll:
|
||||
b.runCatchAll(ctx, commandSlice)
|
||||
case commandDelete:
|
||||
b.runDelete(ctx, commandSlice)
|
||||
case botOptionGreylist:
|
||||
case config.BotGreylist:
|
||||
b.runGreylist(ctx, commandSlice)
|
||||
case commandBanlist:
|
||||
b.runBanlist(ctx, commandSlice)
|
||||
case commandBanlistAuth:
|
||||
b.runBanlistAuth(ctx, commandSlice)
|
||||
case commandBanlistAuto:
|
||||
b.runBanlistAuto(ctx, commandSlice)
|
||||
case commandBanlistTotals:
|
||||
b.runBanlistTotals(ctx)
|
||||
case commandBanlistAdd:
|
||||
b.runBanlistAdd(ctx, commandSlice)
|
||||
b.runBanlistChange(ctx, "add", commandSlice)
|
||||
case commandBanlistRemove:
|
||||
b.runBanlistRemove(ctx, commandSlice)
|
||||
b.runBanlistChange(ctx, "remove", commandSlice)
|
||||
case commandBanlistReset:
|
||||
b.runBanlistReset(ctx)
|
||||
case commandMailboxes:
|
||||
@@ -310,7 +452,7 @@ func (b *Bot) parseCommand(message string, toLower bool) []string {
|
||||
return strings.Split(strings.TrimSpace(message), " ")
|
||||
}
|
||||
|
||||
func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
|
||||
func (b *Bot) sendIntroduction(roomID id.RoomID) {
|
||||
var msg strings.Builder
|
||||
msg.WriteString("Hello, kupo!\n\n")
|
||||
|
||||
@@ -319,22 +461,45 @@ func (b *Bot) sendIntroduction(ctx context.Context, roomID id.RoomID) {
|
||||
msg.WriteString("To get started, assign an email address to this room by sending a `")
|
||||
msg.WriteString(b.prefix)
|
||||
msg.WriteString(" ")
|
||||
msg.WriteString(roomOptionMailbox)
|
||||
msg.WriteString(config.RoomMailbox)
|
||||
msg.WriteString(" SOME_INBOX` command.\n")
|
||||
|
||||
msg.WriteString("You will then be able to send emails to ")
|
||||
msg.WriteString(utils.EmailsList("SOME_INBOX", ""))
|
||||
msg.WriteString("` and have them appear in this room.")
|
||||
|
||||
b.SendNotice(ctx, roomID, msg.String())
|
||||
b.lp.SendNotice(roomID, msg.String())
|
||||
}
|
||||
|
||||
func (b *Bot) getHelpValue(cfg config.Room, cmd command) string {
|
||||
name := cmd.key
|
||||
if name == commandSpamlist {
|
||||
name = config.RoomSpamlist
|
||||
}
|
||||
|
||||
value := cfg.Get(name)
|
||||
if cmd.sanitizer != nil {
|
||||
switch value != "" {
|
||||
case false:
|
||||
return "(currently not set)"
|
||||
case true:
|
||||
txt := "(currently " + value
|
||||
if cmd.key == config.RoomMailbox {
|
||||
txt += " (" + utils.EmailsList(value, cfg.Domain()) + ")"
|
||||
}
|
||||
return txt + ")"
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (b *Bot) sendHelp(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
|
||||
cfg, serr := b.getRoomSettings(evt.RoomID)
|
||||
cfg, serr := b.cfg.GetRoom(evt.RoomID)
|
||||
if serr != nil {
|
||||
b.log.Error("cannot retrieve settings: %v", serr)
|
||||
b.log.Error().Err(serr).Msg("cannot retrieve settings")
|
||||
}
|
||||
|
||||
var msg strings.Builder
|
||||
@@ -344,7 +509,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("* **`")
|
||||
@@ -352,41 +520,55 @@ func (b *Bot) sendHelp(ctx context.Context) {
|
||||
msg.WriteString(" ")
|
||||
msg.WriteString(cmd.key)
|
||||
msg.WriteString("`**")
|
||||
value := cfg.Get(cmd.key)
|
||||
if cmd.sanitizer != nil {
|
||||
switch value != "" {
|
||||
case false:
|
||||
msg.WriteString("(currently not set)")
|
||||
case true:
|
||||
msg.WriteString("(currently `")
|
||||
msg.WriteString(value)
|
||||
if cmd.key == roomOptionMailbox {
|
||||
msg.WriteString(" (")
|
||||
msg.WriteString(utils.EmailsList(value, cfg.Domain()))
|
||||
msg.WriteString(")")
|
||||
}
|
||||
msg.WriteString("`)")
|
||||
}
|
||||
}
|
||||
|
||||
msg.WriteString(b.getHelpValue(cfg, cmd))
|
||||
msg.WriteString(" - ")
|
||||
|
||||
msg.WriteString(cmd.description)
|
||||
msg.WriteString("\n")
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, msg.String())
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
func (b *Bot) runSend(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
if !b.allowSend(evt.Sender, evt.RoomID) {
|
||||
to, subject, body, shouldSend := b.getSendDetails(ctx)
|
||||
if !shouldSend {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, "failed to retrieve room settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var htmlBody string
|
||||
if !cfg.NoHTML() {
|
||||
htmlBody = format.RenderMarkdown(body, true, true).FormattedBody
|
||||
}
|
||||
|
||||
tos := strings.Split(to, ",")
|
||||
b.runSendCommand(ctx, cfg, tos, subject, body, htmlBody)
|
||||
}
|
||||
|
||||
func (b *Bot) getSendDetails(ctx context.Context) (to, subject, body string, ok bool) {
|
||||
evt := eventFromContext(ctx)
|
||||
if !b.allowSend(evt.Sender, evt.RoomID) {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, "failed to retrieve room settings: %v", err)
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
commandSlice := b.parseCommand(evt.Content.AsMessage().Body, false)
|
||||
to, subject, body, err := utils.ParseSend(commandSlice)
|
||||
if err == utils.ErrInvalidArgs {
|
||||
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf(
|
||||
to, subject, body, err = utils.ParseSend(commandSlice)
|
||||
if errors.Is(err, utils.ErrInvalidArgs) {
|
||||
b.lp.SendNotice(evt.RoomID, fmt.Sprintf(
|
||||
"Usage:\n"+
|
||||
"```\n"+
|
||||
"%s send someone@example.com\n"+
|
||||
@@ -395,58 +577,64 @@ func (b *Bot) runSend(ctx context.Context) {
|
||||
"on as many lines\n"+
|
||||
"as you want.\n"+
|
||||
"```",
|
||||
b.prefix))
|
||||
return
|
||||
}
|
||||
htmlBody := format.RenderMarkdown(body, true, true).FormattedBody
|
||||
|
||||
cfg, err := b.getRoomSettings(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "failed to retrieve room settings: %v", err)
|
||||
return
|
||||
b.prefix),
|
||||
linkpearl.RelatesTo(evt.ID, cfg.NoThreads()),
|
||||
)
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
mailbox := cfg.Mailbox()
|
||||
if mailbox == "" {
|
||||
b.SendNotice(ctx, evt.RoomID, "mailbox is not configured, kupo")
|
||||
return
|
||||
b.lp.SendNotice(evt.RoomID, "mailbox is not configured, kupo", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
tos := strings.Split(to, ",")
|
||||
signature := cfg.Signature()
|
||||
if signature != "" {
|
||||
body += "\n\n---\n" + signature
|
||||
}
|
||||
|
||||
return to, subject, body, true
|
||||
}
|
||||
|
||||
func (b *Bot) runSendCommand(ctx context.Context, cfg config.Room, tos []string, subject, body, htmlBody string) {
|
||||
evt := eventFromContext(ctx)
|
||||
|
||||
// validate first
|
||||
for _, to := range tos {
|
||||
if !utils.AddressValid(to) {
|
||||
b.Error(ctx, evt.RoomID, "email address is not valid")
|
||||
if !email.AddressValid(to) {
|
||||
b.Error(ctx, "email address is not valid")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
b.lock(evt.RoomID.String())
|
||||
defer b.unlock(evt.RoomID.String())
|
||||
b.lock(evt.RoomID, evt.ID)
|
||||
defer b.unlock(evt.RoomID, evt.ID)
|
||||
|
||||
domain := utils.SanitizeDomain(cfg.Domain())
|
||||
from := mailbox + "@" + domain
|
||||
ID := utils.MessageID(evt.ID, domain)
|
||||
from := cfg.Mailbox() + "@" + domain
|
||||
ID := email.MessageID(evt.ID, domain)
|
||||
for _, to := range tos {
|
||||
email := utils.NewEmail(ID, "", " "+ID, subject, from, to, body, htmlBody, nil)
|
||||
data := email.Compose(b.getBotSettings().DKIMPrivateKey())
|
||||
recipients := []string{to}
|
||||
eml := email.New(ID, "", " "+ID, subject, from, to, to, "", body, htmlBody, nil, nil)
|
||||
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
|
||||
if data == "" {
|
||||
b.SendError(ctx, evt.RoomID, "email body is empty")
|
||||
b.lp.SendNotice(evt.RoomID, "email body is empty", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
return
|
||||
}
|
||||
queued, err := b.Sendmail(evt.ID, from, to, data)
|
||||
if queued {
|
||||
b.log.Error("cannot send email: %v", err)
|
||||
b.saveSentMetadata(ctx, queued, evt.ID, email, &cfg)
|
||||
b.log.Warn().Err(err).Msg("email has been queued")
|
||||
b.saveSentMetadata(ctx, queued, evt.ID, recipients, eml, cfg)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot send email to %s: %v", to, err)
|
||||
b.Error(ctx, "cannot send email to %s: %v", to, err)
|
||||
continue
|
||||
}
|
||||
b.saveSentMetadata(ctx, false, evt.ID, email, &cfg)
|
||||
b.saveSentMetadata(ctx, false, evt.ID, recipients, eml, cfg)
|
||||
}
|
||||
if len(tos) > 1 {
|
||||
b.SendNotice(ctx, evt.RoomID, "All emails were sent.")
|
||||
b.lp.SendNotice(evt.RoomID, "All emails were sent.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,21 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/etke.cc/go/secgen"
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
func (b *Bot) sendMailboxes(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
mailboxes := map[string]roomSettings{}
|
||||
mailboxes := map[string]config.Room{}
|
||||
slice := []string{}
|
||||
b.rooms.Range(func(key any, value any) bool {
|
||||
b.rooms.Range(func(key, value any) bool {
|
||||
if key == nil {
|
||||
return true
|
||||
}
|
||||
@@ -34,19 +37,19 @@ func (b *Bot) sendMailboxes(ctx context.Context) {
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
config, err := b.getRoomSettings(roomID)
|
||||
cfg, err := b.cfg.GetRoom(roomID)
|
||||
if err != nil {
|
||||
b.log.Error("cannot retrieve settings: %v", err)
|
||||
b.log.Error().Err(err).Msg("cannot retrieve settings")
|
||||
}
|
||||
|
||||
mailboxes[mailbox] = config
|
||||
mailboxes[mailbox] = cfg
|
||||
slice = append(slice, mailbox)
|
||||
return true
|
||||
})
|
||||
sort.Strings(slice)
|
||||
|
||||
if len(slice) == 0 {
|
||||
b.SendNotice(ctx, evt.RoomID, "No mailboxes are managed by the bot so far, kupo!")
|
||||
b.lp.SendNotice(evt.RoomID, "No mailboxes are managed by the bot so far, kupo!", linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,37 +64,40 @@ func (b *Bot) sendMailboxes(ctx context.Context) {
|
||||
msg.WriteString("\n")
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, msg.String())
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runDelete(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
if len(commandSlice) < 2 {
|
||||
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Usage: `%s delete MAILBOX`", b.prefix))
|
||||
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Usage: `%s delete MAILBOX`", b.prefix), linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
mailbox := utils.Mailbox(commandSlice[1])
|
||||
|
||||
v, ok := b.rooms.Load(mailbox)
|
||||
if v == nil || !ok {
|
||||
b.SendError(ctx, evt.RoomID, "mailbox does not exists, kupo")
|
||||
b.lp.SendNotice(evt.RoomID, "mailbox does not exists, kupo", linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
roomID, ok := v.(id.RoomID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
roomID := v.(id.RoomID)
|
||||
|
||||
b.rooms.Delete(mailbox)
|
||||
err := b.setRoomSettings(roomID, roomSettings{})
|
||||
err := b.cfg.SetRoom(roomID, config.Room{})
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
|
||||
b.Error(ctx, "cannot update settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "mailbox has been deleted")
|
||||
b.lp.SendNotice(evt.RoomID, "mailbox has been deleted", linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg := b.getBotSettings()
|
||||
cfg := b.cfg.GetBot()
|
||||
if len(commandSlice) < 2 {
|
||||
var msg strings.Builder
|
||||
users := cfg.Users()
|
||||
@@ -106,38 +112,38 @@ func (b *Bot) runUsers(ctx context.Context, commandSlice []string) {
|
||||
msg.WriteString("where each pattern is like `@someone:example.com`, ")
|
||||
msg.WriteString("`@bot.*:example.com`, `@*:another.com`, or `@*:*`\n")
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, msg.String())
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
_, homeserver, err := b.lp.GetClient().UserID.Parse()
|
||||
if err != nil {
|
||||
b.SendError(ctx, evt.RoomID, fmt.Sprintf("invalid userID: %v", err))
|
||||
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("invalid userID: %v", err), linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
patterns := commandSlice[1:]
|
||||
allowedUsers, err := parseMXIDpatterns(patterns, "@*:"+homeserver)
|
||||
if err != nil {
|
||||
b.SendError(ctx, evt.RoomID, fmt.Sprintf("invalid patterns: %v", err))
|
||||
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("invalid patterns: %v", err), linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Set(botOptionUsers, strings.Join(patterns, " "))
|
||||
cfg.Set(config.BotUsers, strings.Join(patterns, " "))
|
||||
|
||||
err = b.setBotSettings(cfg)
|
||||
err = b.cfg.SetBot(cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err)
|
||||
b.Error(ctx, "cannot set bot config: %v", err)
|
||||
}
|
||||
b.allowedUsers = allowedUsers
|
||||
b.SendNotice(ctx, evt.RoomID, "allowed users updated")
|
||||
b.lp.SendNotice(evt.RoomID, "allowed users updated", linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg := b.getBotSettings()
|
||||
cfg := b.cfg.GetBot()
|
||||
if len(commandSlice) > 1 && commandSlice[1] == "reset" {
|
||||
cfg.Set(botOptionDKIMPrivateKey, "")
|
||||
cfg.Set(botOptionDKIMSignature, "")
|
||||
cfg.Set(config.BotDKIMPrivateKey, "")
|
||||
cfg.Set(config.BotDKIMSignature, "")
|
||||
}
|
||||
|
||||
signature := cfg.DKIMSignature()
|
||||
@@ -146,30 +152,32 @@ func (b *Bot) runDKIM(ctx context.Context, commandSlice []string) {
|
||||
var derr error
|
||||
signature, private, derr = secgen.DKIM()
|
||||
if derr != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot generate DKIM signature: %v", derr)
|
||||
b.Error(ctx, "cannot generate DKIM signature: %v", derr)
|
||||
return
|
||||
}
|
||||
cfg.Set(botOptionDKIMSignature, signature)
|
||||
cfg.Set(botOptionDKIMPrivateKey, private)
|
||||
err := b.setBotSettings(cfg)
|
||||
cfg.Set(config.BotDKIMSignature, signature)
|
||||
cfg.Set(config.BotDKIMPrivateKey, private)
|
||||
err := b.cfg.SetBot(cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
|
||||
b.Error(ctx, "cannot save bot options: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf(
|
||||
b.lp.SendNotice(evt.RoomID, fmt.Sprintf(
|
||||
"DKIM signature is: `%s`.\n"+
|
||||
"You need to add it to DNS records of all domains added to postmoogle (if not already):\n"+
|
||||
"Add new DNS record with type = `TXT`, key (subdomain/from): `postmoogle._domainkey` and value (to):\n ```\n%s\n```\n"+
|
||||
"Without that record other email servers may reject your emails as spam, kupo.\n"+
|
||||
"To reset the signature, send `%s dkim reset`",
|
||||
signature, signature, b.prefix))
|
||||
signature, signature, b.prefix),
|
||||
linkpearl.RelatesTo(evt.ID),
|
||||
)
|
||||
}
|
||||
|
||||
func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg := b.getBotSettings()
|
||||
cfg := b.cfg.GetBot()
|
||||
if len(commandSlice) < 2 {
|
||||
var msg strings.Builder
|
||||
msg.WriteString("Currently: `")
|
||||
@@ -187,30 +195,64 @@ func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) {
|
||||
msg.WriteString(" catch-all MAILBOX`")
|
||||
msg.WriteString("where mailbox is valid and existing mailbox name\n")
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, msg.String())
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
mailbox := utils.Mailbox(commandSlice[1])
|
||||
_, ok := b.GetMapping(mailbox)
|
||||
if !ok {
|
||||
b.SendError(ctx, evt.RoomID, "mailbox does not exist, kupo.")
|
||||
b.lp.SendNotice(evt.RoomID, "mailbox does not exist, kupo.", linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Set(botOptionCatchAll, mailbox)
|
||||
err := b.setBotSettings(cfg)
|
||||
cfg.Set(config.BotCatchAll, mailbox)
|
||||
err := b.cfg.SetBot(cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot save bot options: %v", err)
|
||||
b.Error(ctx, "cannot save bot options: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s` (%s).", mailbox, utils.EmailsList(mailbox, "")))
|
||||
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s` (%s).", mailbox, utils.EmailsList(mailbox, "")), linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runAdminRoom(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg := b.cfg.GetBot()
|
||||
if len(commandSlice) < 2 {
|
||||
var msg strings.Builder
|
||||
msg.WriteString("Currently: `")
|
||||
if cfg.AdminRoom() != "" {
|
||||
msg.WriteString(cfg.AdminRoom().String())
|
||||
} else {
|
||||
msg.WriteString("not set")
|
||||
}
|
||||
msg.WriteString("`\n\n")
|
||||
msg.WriteString("Usage: `")
|
||||
msg.WriteString(b.prefix)
|
||||
msg.WriteString(" adminroom ROOM_ID`")
|
||||
msg.WriteString("where ROOM_ID is valid and existing matrix room id\n")
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
roomID := b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case
|
||||
cfg.Set(config.BotAdminRoom, roomID)
|
||||
err := b.cfg.SetBot(cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot save bot options: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.adminRooms = append([]id.RoomID{id.RoomID(roomID)}, b.adminRooms...) // make it the first room in list on the fly
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Admin Room is set to: `%s`.", roomID), linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) {
|
||||
cfg := b.getBotSettings()
|
||||
greylist := b.getGreylist()
|
||||
cfg := b.cfg.GetBot()
|
||||
greylist := b.cfg.GetGreylist()
|
||||
var msg strings.Builder
|
||||
size := len(greylist)
|
||||
duration := cfg.Greylist()
|
||||
@@ -218,7 +260,7 @@ func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) {
|
||||
if duration == 0 {
|
||||
msg.WriteString("disabled")
|
||||
} else {
|
||||
msg.WriteString(cfg.Get(botOptionGreylist))
|
||||
msg.WriteString(cfg.Get(config.BotGreylist))
|
||||
msg.WriteString("min")
|
||||
}
|
||||
msg.WriteString("`")
|
||||
@@ -236,7 +278,7 @@ func (b *Bot) printGreylist(ctx context.Context, roomID id.RoomID) {
|
||||
msg.WriteString("where `MIN` is duration in minutes for automatic greylisting\n")
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, roomID, msg.String())
|
||||
b.lp.SendNotice(roomID, msg.String(), linkpearl.RelatesTo(eventFromContext(ctx).ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runGreylist(ctx context.Context, commandSlice []string) {
|
||||
@@ -245,31 +287,29 @@ func (b *Bot) runGreylist(ctx context.Context, commandSlice []string) {
|
||||
b.printGreylist(ctx, evt.RoomID)
|
||||
return
|
||||
}
|
||||
cfg := b.getBotSettings()
|
||||
cfg := b.cfg.GetBot()
|
||||
value := utils.SanitizeIntString(commandSlice[1])
|
||||
cfg.Set(botOptionGreylist, value)
|
||||
err := b.setBotSettings(cfg)
|
||||
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.Error(ctx, "cannot set bot config: %v", err)
|
||||
}
|
||||
b.SendNotice(ctx, evt.RoomID, "greylist duration has been updated")
|
||||
b.lp.SendNotice(evt.RoomID, "greylist duration has been updated", linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg := b.getBotSettings()
|
||||
cfg := b.cfg.GetBot()
|
||||
if len(commandSlice) < 2 {
|
||||
banlist := b.getBanlist()
|
||||
banlist := b.cfg.GetBanlist()
|
||||
var msg strings.Builder
|
||||
size := len(banlist)
|
||||
if size > 0 {
|
||||
msg.WriteString("Currently: `")
|
||||
msg.WriteString(cfg.Get(botOptionBanlistEnabled))
|
||||
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")
|
||||
msg.WriteString("\n\n")
|
||||
}
|
||||
if !cfg.BanlistEnabled() {
|
||||
msg.WriteString("To enable banlist, send `")
|
||||
@@ -279,83 +319,182 @@ func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) {
|
||||
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")
|
||||
msg.WriteString("where each ip is IPv4 or IPv6\n\n")
|
||||
msg.WriteString("You can find current banlist values below:\n")
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, msg.String())
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
|
||||
b.addBanlistTimeline(ctx, false)
|
||||
return
|
||||
}
|
||||
value := utils.SanitizeBoolString(commandSlice[1])
|
||||
cfg.Set(botOptionBanlistEnabled, value)
|
||||
err := b.setBotSettings(cfg)
|
||||
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.Error(ctx, "cannot set bot config: %v", err)
|
||||
}
|
||||
b.syncBanlist()
|
||||
b.SendNotice(ctx, evt.RoomID, "banlist has been updated")
|
||||
b.lp.SendNotice(evt.RoomID, "banlist has been updated", linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) {
|
||||
func (b *Bot) runBanlistTotals(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
banlist := b.cfg.GetBanlist()
|
||||
var msg strings.Builder
|
||||
size := len(banlist)
|
||||
if size == 0 {
|
||||
b.lp.SendNotice(evt.RoomID, "banlist is empty, kupo.", linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
msg.WriteString("Total: ")
|
||||
msg.WriteString(strconv.Itoa(size))
|
||||
msg.WriteString(" hosts banned\n\n")
|
||||
msg.WriteString("You can find daily totals below:\n")
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
|
||||
b.addBanlistTimeline(ctx, true)
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlistAuth(ctx context.Context, commandSlice []string) { //nolint:dupl // not in that case
|
||||
evt := eventFromContext(ctx)
|
||||
cfg := b.cfg.GetBot()
|
||||
if len(commandSlice) < 2 {
|
||||
var msg strings.Builder
|
||||
msg.WriteString("Currently: `")
|
||||
msg.WriteString(cfg.Get(config.BotBanlistAuth))
|
||||
msg.WriteString("`\n\n")
|
||||
|
||||
if !cfg.BanlistAuth() {
|
||||
msg.WriteString("To enable automatic banning for invalid credentials, send `")
|
||||
msg.WriteString(b.prefix)
|
||||
msg.WriteString(" banlist:auth true` (banlist itself must be enabled!)\n\n")
|
||||
}
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
value := utils.SanitizeBoolString(commandSlice[1])
|
||||
cfg.Set(config.BotBanlistAuth, value)
|
||||
err := b.cfg.SetBot(cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot set bot config: %v", err)
|
||||
}
|
||||
b.lp.SendNotice(evt.RoomID, "auth banning has been updated", linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlistAuto(ctx context.Context, commandSlice []string) { //nolint:dupl // not in that case
|
||||
evt := eventFromContext(ctx)
|
||||
cfg := b.cfg.GetBot()
|
||||
if len(commandSlice) < 2 {
|
||||
var msg strings.Builder
|
||||
msg.WriteString("Currently: `")
|
||||
msg.WriteString(cfg.Get(config.BotBanlistAuto))
|
||||
msg.WriteString("`\n\n")
|
||||
|
||||
if !cfg.BanlistAuto() {
|
||||
msg.WriteString("To enable automatic banning for invalid emails, send `")
|
||||
msg.WriteString(b.prefix)
|
||||
msg.WriteString(" banlist:auto true` (banlist itself must be enabled!)\n\n")
|
||||
}
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, msg.String(), linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
value := utils.SanitizeBoolString(commandSlice[1])
|
||||
cfg.Set(config.BotBanlistAuto, value)
|
||||
err := b.cfg.SetBot(cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot set bot config: %v", err)
|
||||
}
|
||||
b.lp.SendNotice(evt.RoomID, "auto banning has been updated", linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlistChange(ctx context.Context, mode string, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
if len(commandSlice) < 2 {
|
||||
b.runBanlist(ctx, commandSlice)
|
||||
return
|
||||
}
|
||||
banlist := b.getBanlist()
|
||||
if !b.cfg.GetBot().BanlistEnabled() {
|
||||
b.lp.SendNotice(evt.RoomID, "banlist is disabled, you have to enable it first, kupo", linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
banlist := b.cfg.GetBanlist()
|
||||
|
||||
var action func(net.Addr)
|
||||
if mode == "remove" {
|
||||
action = banlist.Remove
|
||||
} else {
|
||||
action = banlist.Add
|
||||
}
|
||||
|
||||
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)
|
||||
b.Error(ctx, "cannot remove %s from banlist: %v", ip, err)
|
||||
return
|
||||
}
|
||||
banlist.Add(addr)
|
||||
action(addr)
|
||||
}
|
||||
|
||||
err := b.setBanlist(banlist)
|
||||
err := b.cfg.SetBanlist(banlist)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err)
|
||||
b.Error(ctx, "cannot set banlist: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "banlist has been updated, kupo")
|
||||
b.lp.SendNotice(evt.RoomID, "banlist has been updated, kupo", linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) {
|
||||
func (b *Bot) addBanlistTimeline(ctx context.Context, onlyTotals bool) {
|
||||
evt := eventFromContext(ctx)
|
||||
if len(commandSlice) < 2 {
|
||||
b.runBanlist(ctx, commandSlice)
|
||||
return
|
||||
}
|
||||
banlist := b.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 := b.cfg.GetBanlist()
|
||||
timeline := map[string][]string{}
|
||||
for ip, ts := range banlist {
|
||||
key := "???"
|
||||
date, _ := time.ParseInLocation(time.RFC1123Z, ts, time.UTC) //nolint:errcheck // stored in that format
|
||||
if !date.IsZero() {
|
||||
key = date.Truncate(24 * time.Hour).Format(time.DateOnly)
|
||||
}
|
||||
banlist.Remove(addr)
|
||||
if _, ok := timeline[key]; !ok {
|
||||
timeline[key] = []string{}
|
||||
}
|
||||
timeline[key] = append(timeline[key], ip)
|
||||
}
|
||||
keys := utils.MapKeys(timeline)
|
||||
|
||||
err := b.setBanlist(banlist)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err)
|
||||
return
|
||||
for _, chunk := range utils.Chunks(keys, 7) {
|
||||
var txt strings.Builder
|
||||
for _, day := range chunk {
|
||||
data := timeline[day]
|
||||
sort.Strings(data)
|
||||
txt.WriteString("* `")
|
||||
txt.WriteString(day)
|
||||
if onlyTotals {
|
||||
txt.WriteString("` ")
|
||||
txt.WriteString(strconv.Itoa(len(data)))
|
||||
txt.WriteString(" hosts banned\n")
|
||||
continue
|
||||
}
|
||||
txt.WriteString("` `")
|
||||
txt.WriteString(strings.Join(data, "`, `"))
|
||||
txt.WriteString("`\n")
|
||||
}
|
||||
b.lp.SendNotice(evt.RoomID, txt.String(), linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "banlist has been updated, kupo")
|
||||
}
|
||||
|
||||
func (b *Bot) runBanlistReset(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
|
||||
err := b.setBanlist(bglist{})
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err)
|
||||
if !b.cfg.GetBot().BanlistEnabled() {
|
||||
b.lp.SendNotice(evt.RoomID, "banlist is disabled, you have to enable it first, kupo", linkpearl.RelatesTo(evt.ID))
|
||||
return
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "banlist has been reset, kupo")
|
||||
err := b.cfg.SetBanlist(config.List{})
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot set banlist: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, "banlist has been reset, kupo", linkpearl.RelatesTo(evt.ID))
|
||||
}
|
||||
|
||||
@@ -3,35 +3,40 @@ package bot
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/raja/argon2pw"
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
func (b *Bot) runStop(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg, err := b.getRoomSettings(evt.RoomID)
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err)
|
||||
b.Error(ctx, "failed to retrieve settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
mailbox := cfg.Get(roomOptionMailbox)
|
||||
mailbox := cfg.Get(config.RoomMailbox)
|
||||
if mailbox == "" {
|
||||
b.SendNotice(ctx, evt.RoomID, "that room is not configured yet")
|
||||
b.lp.SendNotice(evt.RoomID, "that room is not configured yet", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
return
|
||||
}
|
||||
|
||||
b.rooms.Delete(mailbox)
|
||||
|
||||
err = b.setRoomSettings(evt.RoomID, roomSettings{})
|
||||
err = b.cfg.SetRoom(evt.RoomID, config.Room{})
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
|
||||
b.Error(ctx, "cannot update settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.SendNotice(ctx, evt.RoomID, "mailbox has been disabled")
|
||||
b.lp.SendNotice(evt.RoomID, "mailbox has been disabled", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
func (b *Bot) handleOption(ctx context.Context, cmd []string) {
|
||||
@@ -39,44 +44,114 @@ func (b *Bot) handleOption(ctx context.Context, cmd []string) {
|
||||
b.getOption(ctx, cmd[0])
|
||||
return
|
||||
}
|
||||
b.setOption(ctx, cmd[0], cmd[1])
|
||||
switch cmd[0] {
|
||||
case config.RoomActive:
|
||||
return
|
||||
case config.RoomMailbox:
|
||||
b.setMailbox(ctx, cmd[1])
|
||||
case config.RoomPassword:
|
||||
b.setPassword(ctx)
|
||||
default:
|
||||
b.setOption(ctx, cmd[0], cmd[1])
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) getOption(ctx context.Context, name string) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg, err := b.getRoomSettings(evt.RoomID)
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "failed to retrieve settings: %v", err)
|
||||
b.Error(ctx, "failed to retrieve settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if name == commandSpamlist {
|
||||
name = config.RoomSpamlist
|
||||
}
|
||||
|
||||
value := cfg.Get(name)
|
||||
if value == "" {
|
||||
msg := fmt.Sprintf("`%s` is not set, kupo.\n"+
|
||||
"To set it, send a `%s %s VALUE` command.",
|
||||
name, b.prefix, name)
|
||||
b.SendNotice(ctx, evt.RoomID, msg)
|
||||
b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
return
|
||||
}
|
||||
|
||||
if name == roomOptionMailbox {
|
||||
if name == config.RoomMailbox {
|
||||
value = utils.EmailsList(value, cfg.Domain())
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("`%s` of this room is `%s`\n"+
|
||||
msg := fmt.Sprintf("`%s` of this room is:\n```\n%s\n```\n"+
|
||||
"To set it to a new value, send a `%s %s VALUE` command.",
|
||||
name, value, b.prefix, name)
|
||||
if name == roomOptionPassword {
|
||||
if name == config.RoomPassword {
|
||||
msg = fmt.Sprintf("There is an SMTP password already set for this room/mailbox. "+
|
||||
"It's stored in a secure hashed manner, so we can't tell you what the original raw password was. "+
|
||||
"To find the raw password, try to find your old message which had originally set it, "+
|
||||
"or just set a new one with `%s %s NEW_PASSWORD`.",
|
||||
b.prefix, name)
|
||||
}
|
||||
b.SendNotice(ctx, evt.RoomID, msg)
|
||||
b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
func (b *Bot) setMailbox(ctx context.Context, value string) {
|
||||
evt := eventFromContext(ctx)
|
||||
existingID, ok := b.getMapping(value)
|
||||
if (ok && existingID != "" && existingID != evt.RoomID) || b.isReserved(value) {
|
||||
b.lp.SendNotice(evt.RoomID, fmt.Sprintf("Mailbox `%s` (%s) already taken, kupo", value, utils.EmailsList(value, "")))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, "failed to retrieve settings: %v", err)
|
||||
return
|
||||
}
|
||||
old := cfg.Get(config.RoomMailbox)
|
||||
cfg.Set(config.RoomMailbox, value)
|
||||
cfg.Set(config.RoomOwner, evt.Sender.String())
|
||||
if old != "" {
|
||||
b.rooms.Delete(old)
|
||||
}
|
||||
active := b.ActivateMailbox(evt.Sender, evt.RoomID, value)
|
||||
cfg.Set(config.RoomActive, strconv.FormatBool(active))
|
||||
value = fmt.Sprintf("%s@%s", value, utils.SanitizeDomain(cfg.Domain()))
|
||||
|
||||
err = b.cfg.SetRoom(evt.RoomID, cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot update settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("mailbox of this room set to `%s`", value)
|
||||
b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
func (b *Bot) setPassword(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, "failed to retrieve settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
value := b.parseCommand(evt.Content.AsMessage().Body, false)[1] // get original value, without forced lower case
|
||||
value, err = argon2pw.GenerateSaltedHash(value)
|
||||
if err != nil {
|
||||
b.Error(ctx, "failed to hash password: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Set(config.RoomPassword, value)
|
||||
err = b.cfg.SetRoom(evt.RoomID, cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot update settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, "SMTP password has been set", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
//nolint:gocognit
|
||||
func (b *Bot) setOption(ctx context.Context, name, value string) {
|
||||
cmd := b.commands.get(name)
|
||||
if cmd != nil && cmd.sanitizer != nil {
|
||||
@@ -84,50 +159,136 @@ func (b *Bot) setOption(ctx context.Context, name, value string) {
|
||||
}
|
||||
|
||||
evt := eventFromContext(ctx)
|
||||
if name == roomOptionMailbox {
|
||||
existingID, ok := b.getMapping(value)
|
||||
if ok && existingID != "" && existingID != evt.RoomID {
|
||||
b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Mailbox `%s` (%s) already taken, kupo", value, 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)
|
||||
b.Error(ctx, "failed to retrieve settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if name == roomOptionPassword {
|
||||
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)
|
||||
return
|
||||
}
|
||||
if name == config.RoomAutoreply ||
|
||||
name == config.RoomSignature {
|
||||
value = strings.Join(b.parseCommand(evt.Content.AsMessage().Body, false)[1:], " ")
|
||||
}
|
||||
|
||||
if value == "reset" {
|
||||
value = ""
|
||||
}
|
||||
|
||||
old := cfg.Get(name)
|
||||
cfg.Set(name, value)
|
||||
|
||||
if name == roomOptionMailbox {
|
||||
cfg.Set(roomOptionOwner, evt.Sender.String())
|
||||
if old != "" {
|
||||
b.rooms.Delete(old)
|
||||
}
|
||||
b.rooms.Store(value, evt.RoomID)
|
||||
value = fmt.Sprintf("%s@%s", value, utils.SanitizeDomain(cfg.Domain()))
|
||||
}
|
||||
|
||||
err = b.setRoomSettings(evt.RoomID, cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot update settings: %v", err)
|
||||
if old == value {
|
||||
b.lp.SendNotice(evt.RoomID, "nothing changed, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("`%s` of this room set to `%s`", name, value)
|
||||
if name == roomOptionPassword {
|
||||
msg = "SMTP password has been set"
|
||||
cfg.Set(name, value)
|
||||
err = b.cfg.SetRoom(evt.RoomID, cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot update settings: %v", err)
|
||||
return
|
||||
}
|
||||
b.SendNotice(ctx, evt.RoomID, msg)
|
||||
|
||||
msg := fmt.Sprintf("`%s` of this room set to:\n```\n%s\n```", name, value)
|
||||
b.lp.SendNotice(evt.RoomID, msg, linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
func (b *Bot) runSpamlistAdd(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
if len(commandSlice) < 2 {
|
||||
b.getOption(ctx, config.RoomSpamlist)
|
||||
return
|
||||
}
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot get room settings: %v", err)
|
||||
return
|
||||
}
|
||||
spamlist := utils.StringSlice(cfg[config.RoomSpamlist])
|
||||
for _, newItem := range commandSlice[1:] {
|
||||
newItem = strings.TrimSpace(newItem)
|
||||
if slices.Contains(spamlist, newItem) {
|
||||
continue
|
||||
}
|
||||
spamlist = append(spamlist, newItem)
|
||||
}
|
||||
|
||||
cfg.Set(config.RoomSpamlist, utils.SliceString(spamlist))
|
||||
err = b.cfg.SetRoom(evt.RoomID, cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot store room settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
threadID := threadIDFromContext(ctx)
|
||||
if threadID == "" {
|
||||
threadID = evt.ID
|
||||
}
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, "spamlist has been updated, kupo", linkpearl.RelatesTo(threadID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
func (b *Bot) runSpamlistRemove(ctx context.Context, commandSlice []string) {
|
||||
evt := eventFromContext(ctx)
|
||||
if len(commandSlice) < 2 {
|
||||
b.getOption(ctx, config.RoomSpamlist)
|
||||
return
|
||||
}
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot get room settings: %v", err)
|
||||
return
|
||||
}
|
||||
toRemove := map[int]struct{}{}
|
||||
spamlist := utils.StringSlice(cfg[config.RoomSpamlist])
|
||||
for _, item := range commandSlice[1:] {
|
||||
item = strings.TrimSpace(item)
|
||||
idx := slices.Index(spamlist, item)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
toRemove[idx] = struct{}{}
|
||||
}
|
||||
if len(toRemove) == 0 {
|
||||
b.lp.SendNotice(evt.RoomID, "nothing new, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
return
|
||||
}
|
||||
|
||||
updatedSpamlist := []string{}
|
||||
for i, item := range spamlist {
|
||||
if _, ok := toRemove[i]; ok {
|
||||
continue
|
||||
}
|
||||
updatedSpamlist = append(updatedSpamlist, item)
|
||||
}
|
||||
|
||||
cfg.Set(config.RoomSpamlist, utils.SliceString(updatedSpamlist))
|
||||
err = b.cfg.SetRoom(evt.RoomID, cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot store room settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, "spamlist has been updated, kupo", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
func (b *Bot) runSpamlistReset(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot get room settings: %v", err)
|
||||
return
|
||||
}
|
||||
spamlist := utils.StringSlice(cfg[config.RoomSpamlist])
|
||||
if len(spamlist) == 0 {
|
||||
b.lp.SendNotice(evt.RoomID, "spamlist is empty, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Set(config.RoomSpamlist, "")
|
||||
err = b.cfg.SetRoom(evt.RoomID, cfg)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot store room settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.lp.SendNotice(evt.RoomID, "spamlist has been reset, kupo.", linkpearl.RelatesTo(evt.ID, cfg.NoThreads()))
|
||||
}
|
||||
|
||||
110
bot/config/bot.go
Normal file
110
bot/config/bot.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// account data key
|
||||
const acBotKey = "cc.etke.postmoogle.config"
|
||||
|
||||
// bot options keys
|
||||
const (
|
||||
BotAdminRoom = "adminroom"
|
||||
BotUsers = "users"
|
||||
BotCatchAll = "catch-all"
|
||||
BotDKIMSignature = "dkim.pub"
|
||||
BotDKIMPrivateKey = "dkim.pem"
|
||||
BotQueueBatch = "queue:batch"
|
||||
BotQueueRetries = "queue:retries"
|
||||
BotBanlistEnabled = "banlist:enabled"
|
||||
BotBanlistAuto = "banlist:auto"
|
||||
BotBanlistAuth = "banlist:auth"
|
||||
BotGreylist = "greylist"
|
||||
BotMautrix015Migration = "mautrix015migration"
|
||||
)
|
||||
|
||||
// Bot map
|
||||
type Bot map[string]string
|
||||
|
||||
// Get option
|
||||
func (s Bot) Get(key string) string {
|
||||
return s[strings.ToLower(strings.TrimSpace(key))]
|
||||
}
|
||||
|
||||
// Set option
|
||||
func (s Bot) Set(key, value string) {
|
||||
s[strings.ToLower(strings.TrimSpace(key))] = value
|
||||
}
|
||||
|
||||
// Mautrix015Migration option (timestamp)
|
||||
func (s Bot) Mautrix015Migration() int64 {
|
||||
return utils.Int64(s.Get(BotMautrix015Migration))
|
||||
}
|
||||
|
||||
// Users option
|
||||
func (s Bot) Users() []string {
|
||||
value := s.Get(BotUsers)
|
||||
if value == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
if strings.Contains(value, " ") {
|
||||
return strings.Split(value, " ")
|
||||
}
|
||||
|
||||
return []string{value}
|
||||
}
|
||||
|
||||
// CatchAll option
|
||||
func (s Bot) CatchAll() string {
|
||||
return s.Get(BotCatchAll)
|
||||
}
|
||||
|
||||
// AdminRoom option
|
||||
func (s Bot) AdminRoom() id.RoomID {
|
||||
return id.RoomID(s.Get(BotAdminRoom))
|
||||
}
|
||||
|
||||
// BanlistEnabled option
|
||||
func (s Bot) BanlistEnabled() bool {
|
||||
return utils.Bool(s.Get(BotBanlistEnabled))
|
||||
}
|
||||
|
||||
// BanlistAuto option
|
||||
func (s Bot) BanlistAuto() bool {
|
||||
return utils.Bool(s.Get(BotBanlistAuto))
|
||||
}
|
||||
|
||||
// BanlistAuth option
|
||||
func (s Bot) BanlistAuth() bool {
|
||||
return utils.Bool(s.Get(BotBanlistAuth))
|
||||
}
|
||||
|
||||
// Greylist option (duration in minutes)
|
||||
func (s Bot) Greylist() int {
|
||||
return utils.Int(s.Get(BotGreylist))
|
||||
}
|
||||
|
||||
// DKIMSignature (DNS TXT record)
|
||||
func (s Bot) DKIMSignature() string {
|
||||
return s.Get(BotDKIMSignature)
|
||||
}
|
||||
|
||||
// DKIMPrivateKey keep it secret
|
||||
func (s Bot) DKIMPrivateKey() string {
|
||||
return s.Get(BotDKIMPrivateKey)
|
||||
}
|
||||
|
||||
// QueueBatch option
|
||||
func (s Bot) QueueBatch() int {
|
||||
return utils.Int(s.Get(BotQueueBatch))
|
||||
}
|
||||
|
||||
// QueueRetries option
|
||||
func (s Bot) QueueRetries() int {
|
||||
return utils.Int(s.Get(BotQueueRetries))
|
||||
}
|
||||
69
bot/config/lists.go
Normal file
69
bot/config/lists.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// account data keys
|
||||
const (
|
||||
acBanlistKey = "cc.etke.postmoogle.banlist"
|
||||
acGreylistKey = "cc.etke.postmoogle.greylist"
|
||||
)
|
||||
|
||||
// List config
|
||||
type List map[string]string
|
||||
|
||||
// Slice returns slice of ban- or greylist items
|
||||
func (l List) Slice() []string {
|
||||
slice := make([]string, 0, len(l))
|
||||
for item := range l {
|
||||
slice = append(slice, item)
|
||||
}
|
||||
sort.Strings(slice)
|
||||
|
||||
return slice
|
||||
}
|
||||
|
||||
// Has addr in ban- or greylist
|
||||
func (l List) Has(addr net.Addr) bool {
|
||||
_, ok := l[utils.AddrIP(addr)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Get when addr was added in ban- or greylist
|
||||
func (l List) Get(addr net.Addr) (time.Time, bool) {
|
||||
from := l[utils.AddrIP(addr)]
|
||||
if from == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC1123Z, from)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
return t, true
|
||||
}
|
||||
|
||||
// Add an addr to ban- or greylist
|
||||
func (l List) Add(addr net.Addr) {
|
||||
key := utils.AddrIP(addr)
|
||||
if _, ok := l[key]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
l[key] = time.Now().UTC().Format(time.RFC1123Z)
|
||||
}
|
||||
|
||||
// Remove an addr from ban- or greylist
|
||||
func (l List) Remove(addr net.Addr) {
|
||||
key := utils.AddrIP(addr)
|
||||
if _, ok := l[key]; !ok {
|
||||
return
|
||||
}
|
||||
|
||||
delete(l, key)
|
||||
}
|
||||
119
bot/config/manager.go
Normal file
119
bot/config/manager.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// Manager of configs
|
||||
type Manager struct {
|
||||
mu utils.Mutex
|
||||
log *zerolog.Logger
|
||||
lp *linkpearl.Linkpearl
|
||||
}
|
||||
|
||||
// New config manager
|
||||
func New(lp *linkpearl.Linkpearl, log *zerolog.Logger) *Manager {
|
||||
m := &Manager{
|
||||
mu: utils.NewMutex(),
|
||||
lp: lp,
|
||||
log: log,
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// GetBot config
|
||||
func (m *Manager) GetBot() Bot {
|
||||
var err error
|
||||
var config Bot
|
||||
config, err = m.lp.GetAccountData(acBotKey)
|
||||
if err != nil {
|
||||
m.log.Error().Err(err).Msg("cannot get bot settings")
|
||||
}
|
||||
if config == nil {
|
||||
config = make(Bot, 0)
|
||||
return config
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// SetBot config
|
||||
func (m *Manager) SetBot(cfg Bot) error {
|
||||
return m.lp.SetAccountData(acBotKey, cfg)
|
||||
}
|
||||
|
||||
// GetRoom config
|
||||
func (m *Manager) GetRoom(roomID id.RoomID) (Room, error) {
|
||||
config, err := m.lp.GetRoomAccountData(roomID, acRoomKey)
|
||||
if err != nil {
|
||||
m.log.Warn().Err(err).Str("room_id", roomID.String()).Msg("cannot get room settings")
|
||||
}
|
||||
if config == nil {
|
||||
config = make(Room, 0)
|
||||
}
|
||||
|
||||
return config, err
|
||||
}
|
||||
|
||||
// SetRoom config
|
||||
func (m *Manager) SetRoom(roomID id.RoomID, cfg Room) error {
|
||||
return m.lp.SetRoomAccountData(roomID, acRoomKey, cfg)
|
||||
}
|
||||
|
||||
// GetBanlist config
|
||||
func (m *Manager) GetBanlist() List {
|
||||
if !m.GetBot().BanlistEnabled() {
|
||||
return make(List, 0)
|
||||
}
|
||||
|
||||
m.mu.Lock("banlist")
|
||||
defer m.mu.Unlock("banlist")
|
||||
config, err := m.lp.GetAccountData(acBanlistKey)
|
||||
if err != nil {
|
||||
m.log.Error().Err(err).Msg("cannot get banlist")
|
||||
}
|
||||
if config == nil {
|
||||
config = make(List, 0)
|
||||
return config
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// SetBanlist config
|
||||
func (m *Manager) SetBanlist(cfg List) error {
|
||||
if !m.GetBot().BanlistEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.Lock("banlist")
|
||||
defer m.mu.Unlock("banlist")
|
||||
if cfg == nil {
|
||||
cfg = make(List, 0)
|
||||
}
|
||||
|
||||
return m.lp.SetAccountData(acBanlistKey, cfg)
|
||||
}
|
||||
|
||||
// GetGreylist config
|
||||
func (m *Manager) GetGreylist() List {
|
||||
config, err := m.lp.GetAccountData(acGreylistKey)
|
||||
if err != nil {
|
||||
m.log.Error().Err(err).Msg("cannot get banlist")
|
||||
}
|
||||
if config == nil {
|
||||
config = make(List, 0)
|
||||
return config
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// SetGreylist config
|
||||
func (m *Manager) SetGreylist(cfg List) error {
|
||||
return m.lp.SetAccountData(acGreylistKey, cfg)
|
||||
}
|
||||
212
bot/config/room.go
Normal file
212
bot/config/room.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// account data key
|
||||
const acRoomKey = "cc.etke.postmoogle.settings"
|
||||
|
||||
type Room map[string]string
|
||||
|
||||
// option keys
|
||||
const (
|
||||
RoomActive = ".active"
|
||||
RoomOwner = "owner"
|
||||
RoomMailbox = "mailbox"
|
||||
RoomDomain = "domain"
|
||||
RoomPassword = "password"
|
||||
RoomSignature = "signature"
|
||||
RoomAutoreply = "autoreply"
|
||||
|
||||
RoomThreadify = "threadify"
|
||||
RoomNoCC = "nocc"
|
||||
RoomNoFiles = "nofiles"
|
||||
RoomNoHTML = "nohtml"
|
||||
RoomNoInlines = "noinlines"
|
||||
RoomNoRecipient = "norecipient"
|
||||
RoomNoReplies = "noreplies"
|
||||
RoomNoSend = "nosend"
|
||||
RoomNoSender = "nosender"
|
||||
RoomNoSubject = "nosubject"
|
||||
RoomNoThreads = "nothreads"
|
||||
|
||||
RoomSpamcheckDKIM = "spamcheck:dkim"
|
||||
RoomSpamcheckMX = "spamcheck:mx"
|
||||
RoomSpamcheckSMTP = "spamcheck:smtp"
|
||||
RoomSpamcheckSPF = "spamcheck:spf"
|
||||
|
||||
RoomSpamlist = "spamlist"
|
||||
)
|
||||
|
||||
// Get option
|
||||
func (s Room) Get(key string) string {
|
||||
return s[strings.ToLower(strings.TrimSpace(key))]
|
||||
}
|
||||
|
||||
// Set option
|
||||
func (s Room) Set(key, value string) {
|
||||
s[strings.ToLower(strings.TrimSpace(key))] = value
|
||||
}
|
||||
|
||||
func (s Room) Mailbox() string {
|
||||
return s.Get(RoomMailbox)
|
||||
}
|
||||
|
||||
func (s Room) Domain() string {
|
||||
return s.Get(RoomDomain)
|
||||
}
|
||||
|
||||
func (s Room) Owner() string {
|
||||
return s.Get(RoomOwner)
|
||||
}
|
||||
|
||||
func (s Room) Active() bool {
|
||||
return utils.Bool(s.Get(RoomActive))
|
||||
}
|
||||
|
||||
func (s Room) Password() string {
|
||||
return s.Get(RoomPassword)
|
||||
}
|
||||
|
||||
func (s Room) Signature() string {
|
||||
return s.Get(RoomSignature)
|
||||
}
|
||||
|
||||
func (s Room) Autoreply() string {
|
||||
return s.Get(RoomAutoreply)
|
||||
}
|
||||
|
||||
func (s Room) Threadify() bool {
|
||||
return utils.Bool(s.Get(RoomThreadify))
|
||||
}
|
||||
|
||||
func (s Room) NoSend() bool {
|
||||
return utils.Bool(s.Get(RoomNoSend))
|
||||
}
|
||||
|
||||
func (s Room) NoReplies() bool {
|
||||
return utils.Bool(s.Get(RoomNoReplies))
|
||||
}
|
||||
|
||||
func (s Room) NoCC() bool {
|
||||
return utils.Bool(s.Get(RoomNoCC))
|
||||
}
|
||||
|
||||
func (s Room) NoSender() bool {
|
||||
return utils.Bool(s.Get(RoomNoSender))
|
||||
}
|
||||
|
||||
func (s Room) NoRecipient() bool {
|
||||
return utils.Bool(s.Get(RoomNoRecipient))
|
||||
}
|
||||
|
||||
func (s Room) NoSubject() bool {
|
||||
return utils.Bool(s.Get(RoomNoSubject))
|
||||
}
|
||||
|
||||
func (s Room) NoHTML() bool {
|
||||
return utils.Bool(s.Get(RoomNoHTML))
|
||||
}
|
||||
|
||||
func (s Room) NoThreads() bool {
|
||||
return utils.Bool(s.Get(RoomNoThreads))
|
||||
}
|
||||
|
||||
func (s Room) NoFiles() bool {
|
||||
return utils.Bool(s.Get(RoomNoFiles))
|
||||
}
|
||||
|
||||
func (s Room) NoInlines() bool {
|
||||
return utils.Bool(s.Get(RoomNoInlines))
|
||||
}
|
||||
|
||||
func (s Room) SpamcheckDKIM() bool {
|
||||
return utils.Bool(s.Get(RoomSpamcheckDKIM))
|
||||
}
|
||||
|
||||
func (s Room) SpamcheckSMTP() bool {
|
||||
return utils.Bool(s.Get(RoomSpamcheckSMTP))
|
||||
}
|
||||
|
||||
func (s Room) SpamcheckSPF() bool {
|
||||
return utils.Bool(s.Get(RoomSpamcheckSPF))
|
||||
}
|
||||
|
||||
func (s Room) SpamcheckMX() bool {
|
||||
return utils.Bool(s.Get(RoomSpamcheckMX))
|
||||
}
|
||||
|
||||
func (s Room) Spamlist() []string {
|
||||
return utils.StringSlice(s.Get(RoomSpamlist))
|
||||
}
|
||||
|
||||
func (s Room) MigrateSpamlistSettings() {
|
||||
uniq := map[string]struct{}{}
|
||||
emails := utils.StringSlice(s.Get("spamlist:emails"))
|
||||
localparts := utils.StringSlice(s.Get("spamlist:localparts"))
|
||||
hosts := utils.StringSlice(s.Get("spamlist:hosts"))
|
||||
list := utils.StringSlice(s.Get(RoomSpamlist))
|
||||
delete(s, "spamlist:emails")
|
||||
delete(s, "spamlist:localparts")
|
||||
delete(s, "spamlist:hosts")
|
||||
|
||||
for _, email := range emails {
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
uniq[email] = struct{}{}
|
||||
}
|
||||
|
||||
for _, localpart := range localparts {
|
||||
if localpart == "" {
|
||||
continue
|
||||
}
|
||||
uniq[localpart+"@*"] = struct{}{}
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
uniq["*@"+host] = struct{}{}
|
||||
}
|
||||
|
||||
for _, item := range list {
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
uniq[item] = struct{}{}
|
||||
}
|
||||
|
||||
spamlist := make([]string, 0, len(uniq))
|
||||
for item := range uniq {
|
||||
spamlist = append(spamlist, item)
|
||||
}
|
||||
s.Set(RoomSpamlist, utils.SliceString(spamlist))
|
||||
}
|
||||
|
||||
// ContentOptions converts room display settings to content options
|
||||
func (s Room) ContentOptions() *email.ContentOptions {
|
||||
return &email.ContentOptions{
|
||||
CC: !s.NoCC(),
|
||||
HTML: !s.NoHTML(),
|
||||
Sender: !s.NoSender(),
|
||||
Recipient: !s.NoRecipient(),
|
||||
Subject: !s.NoSubject(),
|
||||
Threads: !s.NoThreads(),
|
||||
Threadify: s.Threadify(),
|
||||
|
||||
ToKey: "cc.etke.postmoogle.to",
|
||||
CcKey: "cc.etke.postmoogle.cc",
|
||||
FromKey: "cc.etke.postmoogle.from",
|
||||
RcptToKey: "cc.etke.postmoogle.rcptTo",
|
||||
SubjectKey: "cc.etke.postmoogle.subject",
|
||||
InReplyToKey: "cc.etke.postmoogle.inReplyTo",
|
||||
MessageIDKey: "cc.etke.postmoogle.messageID",
|
||||
ReferencesKey: "cc.etke.postmoogle.references",
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type ctxkey int
|
||||
|
||||
const (
|
||||
ctxEvent ctxkey = iota
|
||||
ctxEvent ctxkey = iota
|
||||
ctxThreadID ctxkey = iota
|
||||
)
|
||||
|
||||
func newContext(evt *event.Event) context.Context {
|
||||
@@ -49,3 +51,21 @@ func eventToContext(ctx context.Context, evt *event.Event) context.Context {
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
func threadIDToContext(ctx context.Context, threadID id.EventID) context.Context {
|
||||
return context.WithValue(ctx, ctxThreadID, threadID)
|
||||
}
|
||||
|
||||
func threadIDFromContext(ctx context.Context) id.EventID {
|
||||
v := ctx.Value(ctxThreadID)
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
threadID, ok := v.(id.EventID)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return threadID
|
||||
}
|
||||
|
||||
119
bot/data.go
119
bot/data.go
@@ -1,63 +1,106 @@
|
||||
package bot
|
||||
|
||||
var migrations = []string{}
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
func (b *Bot) migrate() error {
|
||||
b.log.Debug("migrating database...")
|
||||
tx, beginErr := b.lp.GetDB().Begin()
|
||||
if beginErr != nil {
|
||||
b.log.Error("cannot begin transaction: %v", beginErr)
|
||||
return beginErr
|
||||
}
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
for _, query := range migrations {
|
||||
_, execErr := tx.Exec(query)
|
||||
if execErr != nil {
|
||||
b.log.Error("cannot apply migration: %v", execErr)
|
||||
// nolint // we already have the execErr to return
|
||||
tx.Rollback()
|
||||
return execErr
|
||||
}
|
||||
}
|
||||
|
||||
commitErr := tx.Commit()
|
||||
if commitErr != nil {
|
||||
b.log.Error("cannot commit transaction: %v", commitErr)
|
||||
// nolint // we already have the commitErr to return
|
||||
tx.Rollback()
|
||||
return commitErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
)
|
||||
|
||||
func (b *Bot) syncRooms() error {
|
||||
adminRooms := []id.RoomID{}
|
||||
|
||||
adminRoom := b.cfg.GetBot().AdminRoom()
|
||||
if adminRoom != "" {
|
||||
adminRooms = append(adminRooms, adminRoom)
|
||||
}
|
||||
|
||||
resp, err := b.lp.GetClient().JoinedRooms()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, roomID := range resp.JoinedRooms {
|
||||
cfg, serr := b.getRoomSettings(roomID)
|
||||
b.migrateRoomSettings(roomID)
|
||||
cfg, serr := b.cfg.GetRoom(roomID)
|
||||
if serr != nil {
|
||||
continue
|
||||
}
|
||||
b.migrateRoomSettings(roomID)
|
||||
mailbox := cfg.Mailbox()
|
||||
if mailbox != "" {
|
||||
active := cfg.Active()
|
||||
if mailbox != "" && active {
|
||||
b.rooms.Store(mailbox, roomID)
|
||||
}
|
||||
|
||||
if cfg.Owner() != "" && b.allowAdmin(id.UserID(cfg.Owner()), "") {
|
||||
adminRooms = append(adminRooms, roomID)
|
||||
}
|
||||
}
|
||||
b.adminRooms = adminRooms
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) syncBanlist() {
|
||||
b.lock("banlist")
|
||||
defer b.unlock("banlist")
|
||||
|
||||
if !b.getBotSettings().BanlistEnabled() {
|
||||
b.banlist = make(bglist, 0)
|
||||
func (b *Bot) migrateRoomSettings(roomID id.RoomID) {
|
||||
cfg, err := b.cfg.GetRoom(roomID)
|
||||
if err != nil {
|
||||
b.log.Error().Err(err).Msg("cannot retrieve room settings")
|
||||
return
|
||||
}
|
||||
b.banlist = b.getBanlist()
|
||||
if _, ok := cfg[config.RoomActive]; !ok {
|
||||
cfg.Set(config.RoomActive, "true")
|
||||
}
|
||||
|
||||
if cfg["spamlist:emails"] == "" && cfg["spamlist:localparts"] == "" && cfg["spamlist:hosts"] == "" {
|
||||
return
|
||||
}
|
||||
cfg.MigrateSpamlistSettings()
|
||||
err = b.cfg.SetRoom(roomID, cfg)
|
||||
if err != nil {
|
||||
b.log.Error().Err(err).Msg("cannot migrate room settings")
|
||||
}
|
||||
}
|
||||
|
||||
// migrateMautrix015 adds a special timestamp to bot's config
|
||||
// to ignore any message events happened before that timestamp
|
||||
// with migration to maturix 0.15.x the state store has been changed
|
||||
// alongside with other database configs to simplify maintenance,
|
||||
// but with that simplification there is no proper way to migrate
|
||||
// existing sync token and session info. No data loss, tho.
|
||||
func (b *Bot) migrateMautrix015() error {
|
||||
cfg := b.cfg.GetBot()
|
||||
ts := cfg.Mautrix015Migration()
|
||||
// already migrated
|
||||
if ts > 0 {
|
||||
b.ignoreBefore = ts
|
||||
return nil
|
||||
}
|
||||
|
||||
ts = time.Now().UTC().UnixMilli()
|
||||
b.ignoreBefore = ts
|
||||
|
||||
tss := strconv.FormatInt(ts, 10)
|
||||
cfg.Set(config.BotMautrix015Migration, tss)
|
||||
return b.cfg.SetBot(cfg)
|
||||
}
|
||||
|
||||
func (b *Bot) initBotUsers() ([]string, error) {
|
||||
cfg := b.cfg.GetBot()
|
||||
cfgUsers := cfg.Users()
|
||||
if len(cfgUsers) > 0 {
|
||||
return cfgUsers, nil
|
||||
}
|
||||
|
||||
_, homeserver, err := b.lp.GetClient().UserID.Parse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Set(config.BotUsers, "@*:"+homeserver)
|
||||
return cfg.Users(), b.cfg.SetBot(cfg)
|
||||
}
|
||||
|
||||
// SyncRooms and mailboxes
|
||||
func (b *Bot) SyncRooms() {
|
||||
b.syncRooms() //nolint:errcheck // nothing can be done here
|
||||
}
|
||||
|
||||
442
bot/email.go
442
bot/email.go
@@ -3,55 +3,83 @@ package bot
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// account data keys
|
||||
const (
|
||||
acQueueKey = "cc.etke.postmoogle.mailqueue"
|
||||
// account data keys
|
||||
acMessagePrefix = "cc.etke.postmoogle.message"
|
||||
acLastEventPrefix = "cc.etke.postmoogle.last"
|
||||
)
|
||||
|
||||
// event keys
|
||||
const (
|
||||
// event keys
|
||||
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"
|
||||
)
|
||||
|
||||
var ErrNoRoom = errors.New("room not found")
|
||||
|
||||
// SetSendmail sets mail sending func to the bot
|
||||
func (b *Bot) SetSendmail(sendmail func(string, string, string) error) {
|
||||
b.sendmail = sendmail
|
||||
b.q.SetSendmail(sendmail)
|
||||
}
|
||||
|
||||
func (b *Bot) shouldQueue(msg string) bool {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if strings.HasPrefix(msg, "4") { // any temporary issue (4xx SMTP code)
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(msg, "450") || strings.Contains(msg, "451") { // greylisting
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(msg, "greylisted") { // greylisting
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Sendmail tries to send email immediately, but if it gets 4xx error (greylisting),
|
||||
// the email will be added to the queue and retried several times after that
|
||||
func (b *Bot) Sendmail(eventID id.EventID, from, to, data string) (bool, error) {
|
||||
log := b.log.With().Str("from", from).Str("to", to).Str("eventID", eventID.String()).Logger()
|
||||
log.Info().Msg("attempting to deliver email")
|
||||
err := b.sendmail(from, to, data)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "4") {
|
||||
b.log.Debug("email %s (from=%s to=%s) was added to the queue: %v", eventID, from, to, err)
|
||||
return true, b.enqueueEmail(eventID.String(), from, to, data)
|
||||
if b.shouldQueue(err.Error()) {
|
||||
log.Info().Err(err).Msg("email has been added to the queue")
|
||||
return true, b.q.Add(eventID.String(), from, to, data)
|
||||
}
|
||||
log.Warn().Err(err).Msg("email delivery failed")
|
||||
return false, err
|
||||
}
|
||||
|
||||
log.Info().Msg("email delivery succeeded")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetDKIMprivkey returns DKIM private key
|
||||
func (b *Bot) GetDKIMprivkey() string {
|
||||
return b.getBotSettings().DKIMPrivateKey()
|
||||
return b.cfg.GetBot().DKIMPrivateKey()
|
||||
}
|
||||
|
||||
func (b *Bot) getMapping(mailbox string) (id.RoomID, bool) {
|
||||
@@ -72,7 +100,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
|
||||
}
|
||||
@@ -83,171 +111,374 @@ func (b *Bot) GetMapping(mailbox string) (id.RoomID, bool) {
|
||||
}
|
||||
|
||||
// GetIFOptions returns incoming email filtering options (room settings)
|
||||
func (b *Bot) GetIFOptions(roomID id.RoomID) utils.IncomingFilteringOptions {
|
||||
cfg, err := b.getRoomSettings(roomID)
|
||||
func (b *Bot) GetIFOptions(roomID id.RoomID) email.IncomingFilteringOptions {
|
||||
cfg, err := b.cfg.GetRoom(roomID)
|
||||
if err != nil {
|
||||
b.log.Error("cannot retrieve room settings: %v", err)
|
||||
return roomSettings{}
|
||||
b.log.Error().Err(err).Msg("cannot retrieve room settings")
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// IncomingEmail sends incoming email to matrix room
|
||||
func (b *Bot) IncomingEmail(ctx context.Context, email *utils.Email) error {
|
||||
roomID, ok := b.GetMapping(email.Mailbox(true))
|
||||
//
|
||||
//nolint:gocognit // TODO
|
||||
func (b *Bot) IncomingEmail(ctx context.Context, eml *email.Email) error {
|
||||
roomID, ok := b.GetMapping(eml.Mailbox(true))
|
||||
if !ok {
|
||||
return errors.New("room not found")
|
||||
return ErrNoRoom
|
||||
}
|
||||
cfg, err := b.getRoomSettings(roomID)
|
||||
cfg, err := b.cfg.GetRoom(roomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, roomID, "cannot get settings: %v", err)
|
||||
b.Error(ctx, "cannot get settings: %v", err)
|
||||
}
|
||||
|
||||
b.lock(roomID.String())
|
||||
defer b.unlock(roomID.String())
|
||||
b.mu.Lock(roomID.String())
|
||||
defer b.mu.Unlock(roomID.String())
|
||||
|
||||
var threadID id.EventID
|
||||
if email.InReplyTo != "" || email.References != "" {
|
||||
threadID = b.getThreadID(roomID, email.InReplyTo, email.References)
|
||||
newThread := true
|
||||
if eml.InReplyTo != "" || eml.References != "" {
|
||||
threadID = b.getThreadID(roomID, eml.InReplyTo, eml.References)
|
||||
if threadID != "" {
|
||||
b.setThreadID(roomID, email.MessageID, threadID)
|
||||
newThread = false
|
||||
ctx = threadIDToContext(ctx, threadID)
|
||||
b.setThreadID(roomID, eml.MessageID, threadID)
|
||||
}
|
||||
}
|
||||
content := email.Content(threadID, cfg.ContentOptions())
|
||||
content := eml.Content(threadID, cfg.ContentOptions())
|
||||
eventID, serr := b.lp.Send(roomID, content)
|
||||
if serr != nil {
|
||||
return utils.UnwrapError(serr)
|
||||
if !strings.Contains(serr.Error(), "M_UNKNOWN") { // if it's not an unknown event error
|
||||
return serr
|
||||
}
|
||||
threadID = "" // unknown event edge case - remove existing thread ID to avoid complications
|
||||
newThread = true
|
||||
}
|
||||
if threadID == "" {
|
||||
threadID = eventID
|
||||
ctx = threadIDToContext(ctx, threadID)
|
||||
}
|
||||
|
||||
b.setThreadID(roomID, email.MessageID, threadID)
|
||||
b.setThreadID(roomID, eml.MessageID, threadID)
|
||||
b.setLastEventID(roomID, threadID, eventID)
|
||||
threadID = eventID
|
||||
|
||||
if newThread && cfg.Threadify() {
|
||||
_, berr := b.lp.Send(roomID, eml.ContentBody(threadID, cfg.ContentOptions()))
|
||||
if berr != nil {
|
||||
return berr
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg.NoInlines() {
|
||||
b.sendFiles(ctx, roomID, eml.InlineFiles, cfg.NoThreads(), threadID)
|
||||
}
|
||||
|
||||
if !cfg.NoFiles() {
|
||||
b.sendFiles(ctx, roomID, email.Files, cfg.NoThreads(), threadID)
|
||||
b.sendFiles(ctx, roomID, eml.Files, cfg.NoThreads(), threadID)
|
||||
}
|
||||
|
||||
if newThread && cfg.Autoreply() != "" {
|
||||
b.sendAutoreply(roomID, threadID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:gocognit // TODO
|
||||
func (b *Bot) sendAutoreply(roomID id.RoomID, threadID id.EventID) {
|
||||
cfg, err := b.cfg.GetRoom(roomID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
text := cfg.Autoreply()
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
threadEvt, err := b.lp.GetClient().GetEvent(roomID, threadID)
|
||||
if err != nil {
|
||||
b.log.Error().Err(err).Msg("cannot get thread event for autoreply")
|
||||
return
|
||||
}
|
||||
|
||||
evt := &event.Event{
|
||||
ID: id.EventID(fmt.Sprintf("%s-autoreply-%s", threadID, time.Now().UTC().Format("20060102T150405Z"))),
|
||||
RoomID: roomID,
|
||||
Content: event.Content{
|
||||
Parsed: &event.MessageEventContent{
|
||||
RelatesTo: &event.RelatesTo{
|
||||
Type: event.RelThread,
|
||||
EventID: threadID,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
meta := b.getParentEmail(evt, cfg.Mailbox())
|
||||
|
||||
if meta.To == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if meta.ThreadID == "" {
|
||||
meta.ThreadID = threadID
|
||||
}
|
||||
if meta.Subject == "" {
|
||||
meta.Subject = "Automatic response"
|
||||
}
|
||||
content := format.RenderMarkdown(text, true, true)
|
||||
signature := format.RenderMarkdown(cfg.Signature(), true, true)
|
||||
body := content.Body
|
||||
if signature.Body != "" {
|
||||
body += "\n\n---\n" + signature.Body
|
||||
}
|
||||
var htmlBody string
|
||||
if !cfg.NoHTML() {
|
||||
htmlBody = content.FormattedBody
|
||||
if htmlBody != "" && signature.FormattedBody != "" {
|
||||
htmlBody += "<br><hr><br>" + signature.FormattedBody
|
||||
}
|
||||
}
|
||||
|
||||
meta.MessageID = email.MessageID(evt.ID, meta.FromDomain)
|
||||
meta.References = meta.References + " " + meta.MessageID
|
||||
b.log.Info().Any("meta", meta).Msg("sending automatic reply")
|
||||
eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil, nil)
|
||||
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
|
||||
if data == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var queued bool
|
||||
ctx := newContext(threadEvt)
|
||||
recipients := meta.Recipients
|
||||
for _, to := range recipients {
|
||||
queued, err = b.Sendmail(evt.ID, meta.From, to, data)
|
||||
if queued {
|
||||
b.log.Info().Err(err).Str("from", meta.From).Str("to", to).Msg("email has been queued")
|
||||
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg, "Autoreply has been sent to "+to+" (queued)")
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot send email to %q: %v", to, err)
|
||||
continue
|
||||
}
|
||||
|
||||
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg, "Autoreply has been sent to "+to)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) canReply(ctx context.Context) bool {
|
||||
evt := eventFromContext(ctx)
|
||||
return b.allowSend(evt.Sender, evt.RoomID) && b.allowReply(evt.Sender, evt.RoomID)
|
||||
}
|
||||
|
||||
// SendEmailReply sends replies from matrix thread to email thread
|
||||
//
|
||||
//nolint:gocognit // TODO
|
||||
func (b *Bot) SendEmailReply(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
cfg, err := b.getRoomSettings(evt.RoomID)
|
||||
if !b.canReply(ctx) {
|
||||
return
|
||||
}
|
||||
cfg, err := b.cfg.GetRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot retrieve room settings: %v", err)
|
||||
b.Error(ctx, "cannot retrieve room settings: %v", err)
|
||||
return
|
||||
}
|
||||
mailbox := cfg.Mailbox()
|
||||
if mailbox == "" {
|
||||
b.Error(ctx, evt.RoomID, "mailbox is not configured, kupo")
|
||||
b.Error(ctx, "mailbox is not configured, kupo")
|
||||
return
|
||||
}
|
||||
domain := utils.SanitizeDomain(cfg.Domain())
|
||||
|
||||
b.lock(evt.RoomID.String())
|
||||
defer b.unlock(evt.RoomID.String())
|
||||
b.lock(evt.RoomID, evt.ID)
|
||||
defer b.unlock(evt.RoomID, evt.ID)
|
||||
|
||||
fromMailbox := mailbox + "@" + domain
|
||||
meta := b.getParentEmail(evt, domain)
|
||||
// when email was sent from matrix and reply was sent from matrix again
|
||||
if fromMailbox != meta.From {
|
||||
meta.To = meta.From
|
||||
}
|
||||
meta.From = fromMailbox
|
||||
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")
|
||||
b.Error(ctx, "cannot find parent email and continue the thread. Please, start a new email thread")
|
||||
return
|
||||
}
|
||||
|
||||
if meta.ThreadID == "" {
|
||||
meta.ThreadID = b.getThreadID(evt.RoomID, meta.InReplyTo, meta.References)
|
||||
ctx = threadIDToContext(ctx, meta.ThreadID)
|
||||
}
|
||||
content := evt.Content.AsMessage()
|
||||
if meta.Subject == "" {
|
||||
meta.Subject = strings.SplitN(content.Body, "\n", 1)[0]
|
||||
}
|
||||
signature := format.RenderMarkdown(cfg.Signature(), true, true)
|
||||
body := content.Body
|
||||
htmlBody := content.FormattedBody
|
||||
if signature.Body != "" {
|
||||
body += "\n\n---\n" + signature.Body
|
||||
}
|
||||
var htmlBody string
|
||||
if !cfg.NoHTML() {
|
||||
htmlBody = content.FormattedBody
|
||||
if htmlBody != "" && signature.FormattedBody != "" {
|
||||
htmlBody += "<br><hr><br>" + signature.FormattedBody
|
||||
}
|
||||
}
|
||||
|
||||
meta.MessageID = utils.MessageID(evt.ID, domain)
|
||||
meta.MessageID = email.MessageID(evt.ID, meta.FromDomain)
|
||||
meta.References = meta.References + " " + meta.MessageID
|
||||
b.log.Debug("send email reply: %+v", meta)
|
||||
email := utils.NewEmail(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, body, htmlBody, nil)
|
||||
data := email.Compose(b.getBotSettings().DKIMPrivateKey())
|
||||
b.log.Info().Any("meta", meta).Msg("sending email reply")
|
||||
eml := email.New(meta.MessageID, meta.InReplyTo, meta.References, meta.Subject, meta.From, meta.To, meta.RcptTo, meta.CC, body, htmlBody, nil, nil)
|
||||
data := eml.Compose(b.cfg.GetBot().DKIMPrivateKey())
|
||||
if data == "" {
|
||||
b.SendError(ctx, evt.RoomID, "email body is empty")
|
||||
b.lp.SendNotice(evt.RoomID, "email body is empty", linkpearl.RelatesTo(meta.ThreadID, cfg.NoThreads()))
|
||||
return
|
||||
}
|
||||
|
||||
queued, err := b.Sendmail(evt.ID, meta.From, meta.To, data)
|
||||
if queued {
|
||||
b.log.Error("cannot send email: %v", err)
|
||||
b.saveSentMetadata(ctx, queued, meta.ThreadID, email, &cfg)
|
||||
return
|
||||
}
|
||||
var queued bool
|
||||
recipients := meta.Recipients
|
||||
for _, to := range recipients {
|
||||
queued, err = b.Sendmail(evt.ID, meta.From, to, data)
|
||||
if queued {
|
||||
b.log.Info().Err(err).Str("from", meta.From).Str("to", to).Msg("email has been queued")
|
||||
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg)
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot send email: %v", err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot send email to %q: %v", to, err)
|
||||
continue
|
||||
}
|
||||
|
||||
b.saveSentMetadata(ctx, queued, meta.ThreadID, email, &cfg)
|
||||
b.saveSentMetadata(ctx, queued, meta.ThreadID, recipients, eml, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
type parentEmail struct {
|
||||
MessageID string
|
||||
ThreadID id.EventID
|
||||
From string
|
||||
FromDomain string
|
||||
To string
|
||||
RcptTo string
|
||||
CC string
|
||||
InReplyTo string
|
||||
References string
|
||||
Subject string
|
||||
Recipients []string
|
||||
}
|
||||
|
||||
// fixtofrom attempts to "fix" or rather reverse the To, From and CC headers
|
||||
// of parent email by using parent email as metadata source for a new email
|
||||
// that will be sent from postmoogle.
|
||||
// To do so, we need to reverse From and To headers, but Cc should be adjusted as well,
|
||||
// thus that hacky workaround below:
|
||||
func (e *parentEmail) fixtofrom(newSenderMailbox string, domains []string) string {
|
||||
newSenders := make(map[string]string, len(domains))
|
||||
for _, domain := range domains {
|
||||
sender := newSenderMailbox + "@" + domain
|
||||
newSenders[sender] = sender
|
||||
}
|
||||
|
||||
// try to determine previous email of the room mailbox
|
||||
// by matching RCPT TO, To and From fields
|
||||
// why? Because of possible multi-domain setup and we won't leak information
|
||||
var previousSender string
|
||||
rcptToSender, ok := newSenders[e.RcptTo]
|
||||
if ok {
|
||||
previousSender = rcptToSender
|
||||
}
|
||||
toSender, ok := newSenders[e.To]
|
||||
if ok {
|
||||
previousSender = toSender
|
||||
}
|
||||
fromSender, ok := newSenders[e.From]
|
||||
if ok {
|
||||
previousSender = fromSender
|
||||
}
|
||||
|
||||
// Message-Id should not leak information either
|
||||
e.FromDomain = utils.SanitizeDomain(utils.Hostname(previousSender))
|
||||
|
||||
originalFrom := e.From
|
||||
// reverse From if needed
|
||||
if fromSender == "" {
|
||||
e.From = previousSender
|
||||
}
|
||||
// reverse To if needed
|
||||
if toSender != "" {
|
||||
e.To = originalFrom
|
||||
}
|
||||
// replace previous recipient of the email which is sender now with the original From
|
||||
for newSender := range newSenders {
|
||||
if strings.Contains(e.CC, newSender) {
|
||||
e.CC = strings.ReplaceAll(e.CC, newSender, originalFrom)
|
||||
}
|
||||
}
|
||||
|
||||
return previousSender
|
||||
}
|
||||
|
||||
func (e *parentEmail) calculateRecipients(from string, forwardedFrom []string) {
|
||||
recipients := map[string]struct{}{}
|
||||
recipients[email.Address(e.From)] = struct{}{}
|
||||
|
||||
for _, addr := range strings.Split(email.Address(e.To), ",") {
|
||||
recipients[addr] = struct{}{}
|
||||
}
|
||||
for _, addr := range email.AddressList(e.CC) {
|
||||
recipients[addr] = struct{}{}
|
||||
}
|
||||
|
||||
for _, addr := range forwardedFrom {
|
||||
delete(recipients, addr)
|
||||
}
|
||||
delete(recipients, from)
|
||||
|
||||
rcpts := make([]string, 0, len(recipients))
|
||||
for rcpt := range recipients {
|
||||
rcpts = append(rcpts, email.Address(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)
|
||||
threadID := linkpearl.EventParent(evt.ID, content)
|
||||
b.log.Debug().Str("eventID", evt.ID.String()).Str("threadID", threadID.String()).Msg("looking up for the parent event within thread")
|
||||
if threadID == evt.ID {
|
||||
b.log.Debug("event %s is the thread itself")
|
||||
b.log.Debug().Str("eventID", evt.ID.String()).Msg("event is the thread itself")
|
||||
return threadID, evt
|
||||
}
|
||||
lastEventID := b.getLastEventID(evt.RoomID, threadID)
|
||||
b.log.Debug("the last event of the thread %s (and parent of the %s) is %s", threadID, evt.ID, lastEventID)
|
||||
b.log.Debug().Str("eventID", evt.ID.String()).Str("threadID", threadID.String()).Str("lastEventID", lastEventID.String()).Msg("the last event of the thread (and parent of the event) has been found")
|
||||
if lastEventID == evt.ID {
|
||||
return threadID, evt
|
||||
}
|
||||
parentEvt, err := b.lp.GetClient().GetEvent(evt.RoomID, lastEventID)
|
||||
if err != nil {
|
||||
b.log.Error("cannot get parent event: %v", err)
|
||||
b.log.Error().Err(err).Msg("cannot get parent event")
|
||||
return threadID, nil
|
||||
}
|
||||
utils.ParseContent(parentEvt, parentEvt.Type)
|
||||
b.log.Debug("type of the parsed content is: %T", parentEvt.Content.Parsed)
|
||||
linkpearl.ParseContent(parentEvt, b.log)
|
||||
|
||||
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)
|
||||
if !b.lp.GetMachine().StateStore.IsEncrypted(evt.RoomID) {
|
||||
return threadID, parentEvt
|
||||
}
|
||||
|
||||
decrypted, err := b.lp.GetMachine().DecryptMegolmEvent(parentEvt)
|
||||
decrypted, err := b.lp.GetClient().Crypto.Decrypt(parentEvt)
|
||||
if err != nil {
|
||||
b.log.Error("cannot decrypt parent event: %v", err)
|
||||
b.log.Error().Err(err).Msg("cannot decrypt parent event")
|
||||
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, domain string) parentEmail {
|
||||
var parent parentEmail
|
||||
func (b *Bot) getParentEmail(evt *event.Event, newFromMailbox string) *parentEmail {
|
||||
parent := &parentEmail{}
|
||||
threadID, parentEvt := b.getParentEvent(evt)
|
||||
parent.ThreadID = threadID
|
||||
if parentEvt == nil {
|
||||
@@ -257,11 +488,15 @@ func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail {
|
||||
return parent
|
||||
}
|
||||
|
||||
parent.MessageID = utils.MessageID(parentEvt.ID, domain)
|
||||
parent.From = utils.EventField[string](&parentEvt.Content, eventFromKey)
|
||||
parent.To = utils.EventField[string](&parentEvt.Content, eventToKey)
|
||||
parent.InReplyTo = utils.EventField[string](&parentEvt.Content, eventMessageIDkey)
|
||||
parent.References = utils.EventField[string](&parentEvt.Content, eventReferencesKey)
|
||||
parent.From = email.Address(linkpearl.EventField[string](&parentEvt.Content, eventFromKey))
|
||||
parent.To = email.Address(linkpearl.EventField[string](&parentEvt.Content, eventToKey))
|
||||
parent.CC = email.Address(linkpearl.EventField[string](&parentEvt.Content, eventCcKey))
|
||||
parent.RcptTo = email.Address(linkpearl.EventField[string](&parentEvt.Content, eventRcptToKey))
|
||||
parent.InReplyTo = linkpearl.EventField[string](&parentEvt.Content, eventMessageIDkey)
|
||||
parent.References = linkpearl.EventField[string](&parentEvt.Content, eventReferencesKey)
|
||||
senderEmail := parent.fixtofrom(newFromMailbox, b.domains)
|
||||
parent.calculateRecipients(senderEmail, b.mbxc.Forwarded)
|
||||
parent.MessageID = email.MessageID(parentEvt.ID, parent.FromDomain)
|
||||
if parent.InReplyTo == "" {
|
||||
parent.InReplyTo = parent.MessageID
|
||||
}
|
||||
@@ -269,7 +504,7 @@ func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail {
|
||||
parent.References = " " + parent.MessageID
|
||||
}
|
||||
|
||||
parent.Subject = utils.EventField[string](&parentEvt.Content, eventSubjectKey)
|
||||
parent.Subject = linkpearl.EventField[string](&parentEvt.Content, eventSubjectKey)
|
||||
if parent.Subject != "" {
|
||||
parent.Subject = "Re: " + parent.Subject
|
||||
} else {
|
||||
@@ -281,46 +516,51 @@ func (b *Bot) getParentEmail(evt *event.Event, domain string) parentEmail {
|
||||
|
||||
// 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, email *utils.Email, cfg *roomSettings) {
|
||||
text := "Email has been sent to " + email.To
|
||||
func (b *Bot) saveSentMetadata(ctx context.Context, queued bool, threadID id.EventID, recipients []string, eml *email.Email, cfg config.Room, textOverride ...string) {
|
||||
addrs := strings.Join(recipients, ", ")
|
||||
text := "Email has been sent to " + addrs
|
||||
if queued {
|
||||
text = "Email to " + email.To + " has been queued"
|
||||
text = "Email to " + addrs + " has been queued"
|
||||
}
|
||||
if len(textOverride) > 0 {
|
||||
text = textOverride[0]
|
||||
}
|
||||
|
||||
evt := eventFromContext(ctx)
|
||||
content := email.Content(threadID, cfg.ContentOptions())
|
||||
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")
|
||||
b.Error(ctx, "cannot parse message")
|
||||
return
|
||||
}
|
||||
msgContent.MsgType = event.MsgNotice
|
||||
msgContent.Body = notice.Body
|
||||
msgContent.FormattedBody = notice.FormattedBody
|
||||
msgContent.RelatesTo = linkpearl.RelatesTo(threadID, cfg.NoThreads())
|
||||
content.Parsed = msgContent
|
||||
msgID, err := b.lp.Send(evt.RoomID, content)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot send notice: %v", err)
|
||||
b.Error(ctx, "cannot send notice: %v", err)
|
||||
return
|
||||
}
|
||||
domain := utils.SanitizeDomain(cfg.Domain())
|
||||
b.setThreadID(evt.RoomID, utils.MessageID(evt.ID, domain), threadID)
|
||||
b.setThreadID(evt.RoomID, utils.MessageID(msgID, domain), threadID)
|
||||
b.setThreadID(evt.RoomID, email.MessageID(evt.ID, domain), threadID)
|
||||
b.setThreadID(evt.RoomID, email.MessageID(msgID, domain), threadID)
|
||||
b.setLastEventID(evt.RoomID, threadID, msgID)
|
||||
}
|
||||
|
||||
func (b *Bot) sendFiles(ctx context.Context, roomID id.RoomID, files []*utils.File, noThreads bool, parentID id.EventID) {
|
||||
for _, file := range files {
|
||||
req := file.Convert()
|
||||
err := b.lp.SendFile(roomID, req, file.MsgType, utils.RelatesTo(!noThreads, parentID))
|
||||
err := b.lp.SendFile(roomID, req, file.MsgType, linkpearl.RelatesTo(parentID, noThreads))
|
||||
if err != nil {
|
||||
b.Error(ctx, roomID, "cannot upload file %s: %v", req.FileName, err)
|
||||
b.Error(ctx, "cannot upload file %s: %v", req.FileName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) getThreadID(roomID id.RoomID, messageID string, references string) id.EventID {
|
||||
func (b *Bot) getThreadID(roomID id.RoomID, messageID, references string) id.EventID {
|
||||
refs := []string{messageID}
|
||||
if references != "" {
|
||||
refs = append(refs, strings.Split(references, " ")...)
|
||||
@@ -330,12 +570,18 @@ func (b *Bot) getThreadID(roomID id.RoomID, messageID string, references string)
|
||||
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)
|
||||
b.log.Error().Err(err).Str("key", key).Msg("cannot retrieve thread ID")
|
||||
continue
|
||||
}
|
||||
if data["eventID"] != "" {
|
||||
return id.EventID(data["eventID"])
|
||||
if data["eventID"] == "" {
|
||||
continue
|
||||
}
|
||||
resp, err := b.lp.GetClient().GetEvent(roomID, id.EventID(data["eventID"]))
|
||||
if err != nil {
|
||||
b.log.Warn().Err(err).Str("roomID", roomID.String()).Str("eventID", data["eventID"]).Msg("cannot get event by id (may be removed)")
|
||||
continue
|
||||
}
|
||||
return resp.ID
|
||||
}
|
||||
|
||||
return ""
|
||||
@@ -345,7 +591,7 @@ func (b *Bot) setThreadID(roomID id.RoomID, messageID string, eventID id.EventID
|
||||
key := acMessagePrefix + "." + messageID
|
||||
err := b.lp.SetRoomAccountData(roomID, key, map[string]string{"eventID": eventID.String()})
|
||||
if err != nil {
|
||||
b.log.Error("cannot save thread ID to %s: %v", key, err)
|
||||
b.log.Error().Err(err).Str("key", key).Msg("cannot save thread ID")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +599,7 @@ func (b *Bot) getLastEventID(roomID id.RoomID, threadID id.EventID) id.EventID {
|
||||
key := acLastEventPrefix + "." + threadID.String()
|
||||
data, err := b.lp.GetRoomAccountData(roomID, key)
|
||||
if err != nil {
|
||||
b.log.Error("cannot retrieve last event ID from %s: %v", key, err)
|
||||
b.log.Error().Err(err).Str("key", key).Msg("cannot retrieve last event ID")
|
||||
return threadID
|
||||
}
|
||||
if data["eventID"] != "" {
|
||||
@@ -363,10 +609,10 @@ func (b *Bot) getLastEventID(roomID id.RoomID, threadID id.EventID) id.EventID {
|
||||
return threadID
|
||||
}
|
||||
|
||||
func (b *Bot) setLastEventID(roomID id.RoomID, threadID id.EventID, eventID id.EventID) {
|
||||
func (b *Bot) setLastEventID(roomID id.RoomID, threadID, eventID id.EventID) {
|
||||
key := acLastEventPrefix + "." + threadID.String()
|
||||
err := b.lp.SetRoomAccountData(roomID, key, map[string]string{"eventID": eventID.String()})
|
||||
if err != nil {
|
||||
b.log.Error("cannot save thread ID to %s: %v", key, err)
|
||||
b.log.Error().Err(err).Str("key", key).Msg("cannot save thread ID")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +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 {
|
||||
if content.RelatesTo != nil {
|
||||
b.SendEmailReply(ctx)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
b.handleCommand(ctx, evt, cmd)
|
||||
}
|
||||
35
bot/mutex.go
35
bot/mutex.go
@@ -1,24 +1,29 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func (b *Bot) lock(key string) {
|
||||
_, ok := b.mu[key]
|
||||
if !ok {
|
||||
b.mu[key] = &sync.Mutex{}
|
||||
}
|
||||
func (b *Bot) lock(roomID id.RoomID, optionalEventID ...id.EventID) {
|
||||
b.mu.Lock(roomID.String())
|
||||
|
||||
b.mu[key].Lock()
|
||||
}
|
||||
|
||||
func (b *Bot) unlock(key string) {
|
||||
_, ok := b.mu[key]
|
||||
if !ok {
|
||||
if len(optionalEventID) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
b.mu[key].Unlock()
|
||||
delete(b.mu, key)
|
||||
evtID := optionalEventID[0]
|
||||
if _, err := b.lp.GetClient().SendReaction(roomID, evtID, "📨"); err != nil {
|
||||
b.log.Error().Err(err).Str("roomID", roomID.String()).Str("eventID", evtID.String()).Msg("cannot send reaction on lock")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) unlock(roomID id.RoomID, optionalEventID ...id.EventID) {
|
||||
b.mu.Unlock(roomID.String())
|
||||
|
||||
if len(optionalEventID) == 0 {
|
||||
return
|
||||
}
|
||||
evtID := optionalEventID[0]
|
||||
if _, err := b.lp.GetClient().SendReaction(roomID, evtID, "✅"); err != nil {
|
||||
b.log.Error().Err(err).Str("roomID", roomID.String()).Str("eventID", evtID.String()).Msg("cannot send reaction on unlock")
|
||||
}
|
||||
}
|
||||
|
||||
153
bot/queue.go
153
bot/queue.go
@@ -1,153 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultQueueBatch = 1
|
||||
defaultQueueRetries = 3
|
||||
)
|
||||
|
||||
// ProcessQueue starts queue processing
|
||||
func (b *Bot) ProcessQueue() {
|
||||
b.log.Debug("staring queue processing...")
|
||||
cfg := b.getBotSettings()
|
||||
|
||||
batchSize := cfg.QueueBatch()
|
||||
if batchSize == 0 {
|
||||
batchSize = defaultQueueBatch
|
||||
}
|
||||
|
||||
retries := cfg.QueueRetries()
|
||||
if retries == 0 {
|
||||
retries = defaultQueueRetries
|
||||
}
|
||||
|
||||
b.popqueue(batchSize, retries)
|
||||
b.log.Debug("ended queue processing")
|
||||
}
|
||||
|
||||
// popqueue gets emails from queue and tries to send them,
|
||||
// if an email was sent successfully - it will be removed from queue
|
||||
func (b *Bot) popqueue(batchSize, maxTries int) {
|
||||
b.lock(acQueueKey)
|
||||
defer b.unlock(acQueueKey)
|
||||
index, err := b.lp.GetAccountData(acQueueKey)
|
||||
if err != nil {
|
||||
b.log.Error("cannot get queue index: %v", err)
|
||||
}
|
||||
|
||||
i := 0
|
||||
for id, itemkey := range index {
|
||||
if i > batchSize {
|
||||
b.log.Debug("finished re-deliveries from queue")
|
||||
return
|
||||
}
|
||||
if dequeue := b.processQueueItem(itemkey, maxTries); dequeue {
|
||||
b.log.Debug("email %s has been delivered", id)
|
||||
err = b.dequeueEmail(id)
|
||||
if err != nil {
|
||||
b.log.Error("cannot dequeue email %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// processQueueItem tries to process an item from queue
|
||||
// returns should the item be dequeued or not
|
||||
func (b *Bot) processQueueItem(itemkey string, maxRetries int) bool {
|
||||
b.lock(itemkey)
|
||||
defer b.unlock(itemkey)
|
||||
|
||||
item, err := b.lp.GetAccountData(itemkey)
|
||||
if err != nil {
|
||||
b.log.Error("cannot retrieve a queue item %s: %v", itemkey, err)
|
||||
return false
|
||||
}
|
||||
b.log.Debug("processing queue item %+v", item)
|
||||
attempts, err := strconv.Atoi(item["attempts"])
|
||||
if err != nil {
|
||||
b.log.Error("cannot parse attempts of %s: %v", itemkey, err)
|
||||
return false
|
||||
}
|
||||
if attempts > maxRetries {
|
||||
return true
|
||||
}
|
||||
|
||||
err = b.sendmail(item["from"], item["to"], item["data"])
|
||||
if err == nil {
|
||||
b.log.Debug("email %s from queue was delivered")
|
||||
return true
|
||||
}
|
||||
|
||||
b.log.Debug("attempted to deliver email id=%s, retry=%s, but it's not ready yet: %v", item["id"], item["attempts"], err)
|
||||
attempts++
|
||||
item["attempts"] = strconv.Itoa(attempts)
|
||||
err = b.lp.SetAccountData(itemkey, item)
|
||||
if err != nil {
|
||||
b.log.Error("cannot update attempt count on email %s: %v", itemkey, err)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// enqueueEmail adds an email to the queue
|
||||
func (b *Bot) enqueueEmail(id, from, to, data string) error {
|
||||
itemkey := acQueueKey + "." + id
|
||||
item := map[string]string{
|
||||
"attempts": "0",
|
||||
"data": data,
|
||||
"from": from,
|
||||
"to": to,
|
||||
"id": id,
|
||||
}
|
||||
|
||||
b.lock(itemkey)
|
||||
defer b.unlock(itemkey)
|
||||
err := b.lp.SetAccountData(itemkey, item)
|
||||
if err != nil {
|
||||
b.log.Error("cannot enqueue email id=%s: %v", id, err)
|
||||
return err
|
||||
}
|
||||
|
||||
b.lock(acQueueKey)
|
||||
defer b.unlock(acQueueKey)
|
||||
queueIndex, err := b.lp.GetAccountData(acQueueKey)
|
||||
if err != nil {
|
||||
b.log.Error("cannot get queue index: %v", err)
|
||||
return err
|
||||
}
|
||||
queueIndex[id] = itemkey
|
||||
err = b.lp.SetAccountData(acQueueKey, queueIndex)
|
||||
if err != nil {
|
||||
b.log.Error("cannot save queue index: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dequeueEmail removes an email from the queue
|
||||
func (b *Bot) dequeueEmail(id string) error {
|
||||
index, err := b.lp.GetAccountData(acQueueKey)
|
||||
if err != nil {
|
||||
b.log.Error("cannot get queue index: %v", err)
|
||||
return err
|
||||
}
|
||||
itemkey := index[id]
|
||||
if itemkey == "" {
|
||||
itemkey = acQueueKey + "." + id
|
||||
}
|
||||
delete(index, id)
|
||||
err = b.lp.SetAccountData(acQueueKey, index)
|
||||
if err != nil {
|
||||
b.log.Error("cannot update queue index: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
b.lock(itemkey)
|
||||
defer b.unlock(itemkey)
|
||||
return b.lp.SetAccountData(itemkey, nil)
|
||||
}
|
||||
79
bot/queue/manager.go
Normal file
79
bot/queue/manager.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
acQueueKey = "cc.etke.postmoogle.mailqueue"
|
||||
defaultQueueBatch = 10
|
||||
defaultQueueRetries = 100
|
||||
)
|
||||
|
||||
// Queue manager
|
||||
type Queue struct {
|
||||
mu utils.Mutex
|
||||
lp *linkpearl.Linkpearl
|
||||
cfg *config.Manager
|
||||
log *zerolog.Logger
|
||||
sendmail func(string, string, string) error
|
||||
}
|
||||
|
||||
// New queue
|
||||
func New(lp *linkpearl.Linkpearl, cfg *config.Manager, log *zerolog.Logger) *Queue {
|
||||
return &Queue{
|
||||
mu: utils.Mutex{},
|
||||
lp: lp,
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSendmail func
|
||||
func (q *Queue) SetSendmail(function func(string, string, string) error) {
|
||||
q.sendmail = function
|
||||
}
|
||||
|
||||
// Process queue
|
||||
func (q *Queue) Process() {
|
||||
q.log.Debug().Msg("staring queue processing...")
|
||||
cfg := q.cfg.GetBot()
|
||||
|
||||
batchSize := cfg.QueueBatch()
|
||||
if batchSize == 0 {
|
||||
batchSize = defaultQueueBatch
|
||||
}
|
||||
|
||||
maxRetries := cfg.QueueRetries()
|
||||
if maxRetries == 0 {
|
||||
maxRetries = defaultQueueRetries
|
||||
}
|
||||
|
||||
q.mu.Lock(acQueueKey)
|
||||
defer q.mu.Unlock(acQueueKey)
|
||||
index, err := q.lp.GetAccountData(acQueueKey)
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Msg("cannot get queue index")
|
||||
}
|
||||
|
||||
i := 0
|
||||
for id, itemkey := range index {
|
||||
if i > batchSize {
|
||||
q.log.Debug().Msg("finished re-deliveries from queue")
|
||||
return
|
||||
}
|
||||
if dequeue := q.try(itemkey, maxRetries); dequeue {
|
||||
q.log.Info().Str("id", id).Msg("email has been delivered")
|
||||
err = q.Remove(id)
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Str("id", id).Msg("cannot dequeue email")
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
q.log.Debug().Msg("ended queue processing")
|
||||
}
|
||||
101
bot/queue/queue.go
Normal file
101
bot/queue/queue.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Add to queue
|
||||
func (q *Queue) Add(id, from, to, data string) error {
|
||||
itemkey := acQueueKey + "." + id
|
||||
item := map[string]string{
|
||||
"attempts": "0",
|
||||
"data": data,
|
||||
"from": from,
|
||||
"to": to,
|
||||
"id": id,
|
||||
}
|
||||
|
||||
q.mu.Lock(itemkey)
|
||||
defer q.mu.Unlock(itemkey)
|
||||
err := q.lp.SetAccountData(itemkey, item)
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Str("id", id).Msg("cannot enqueue email")
|
||||
return err
|
||||
}
|
||||
|
||||
q.mu.Lock(acQueueKey)
|
||||
defer q.mu.Unlock(acQueueKey)
|
||||
queueIndex, err := q.lp.GetAccountData(acQueueKey)
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Msg("cannot get queue index")
|
||||
return err
|
||||
}
|
||||
queueIndex[id] = itemkey
|
||||
err = q.lp.SetAccountData(acQueueKey, queueIndex)
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Msg("cannot save queue index")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
func (q *Queue) Remove(id string) error {
|
||||
index, err := q.lp.GetAccountData(acQueueKey)
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Msg("cannot get queue index")
|
||||
return err
|
||||
}
|
||||
itemkey := index[id]
|
||||
if itemkey == "" {
|
||||
itemkey = acQueueKey + "." + id
|
||||
}
|
||||
delete(index, id)
|
||||
err = q.lp.SetAccountData(acQueueKey, index)
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Msg("cannot update queue index")
|
||||
return err
|
||||
}
|
||||
|
||||
q.mu.Lock(itemkey)
|
||||
defer q.mu.Unlock(itemkey)
|
||||
return q.lp.SetAccountData(itemkey, map[string]string{})
|
||||
}
|
||||
|
||||
// try to send email
|
||||
func (q *Queue) try(itemkey string, maxRetries int) bool {
|
||||
q.mu.Lock(itemkey)
|
||||
defer q.mu.Unlock(itemkey)
|
||||
|
||||
item, err := q.lp.GetAccountData(itemkey)
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Str("id", itemkey).Msg("cannot retrieve a queue item")
|
||||
return false
|
||||
}
|
||||
q.log.Debug().Any("item", item).Msg("processing queue item")
|
||||
attempts, err := strconv.Atoi(item["attempts"])
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Str("id", itemkey).Msg("cannot parse attempts")
|
||||
return false
|
||||
}
|
||||
if attempts > maxRetries {
|
||||
return true
|
||||
}
|
||||
|
||||
err = q.sendmail(item["from"], item["to"], item["data"])
|
||||
if err == nil {
|
||||
q.log.Info().Str("id", itemkey).Msg("email from queue was delivered")
|
||||
return true
|
||||
}
|
||||
|
||||
q.log.Info().Str("id", itemkey).Str("from", item["from"]).Str("to", item["to"]).Err(err).Msg("attempted to deliver email, but it's not ready yet")
|
||||
attempts++
|
||||
item["attempts"] = strconv.Itoa(attempts)
|
||||
err = q.lp.SetAccountData(itemkey, item)
|
||||
if err != nil {
|
||||
q.log.Error().Err(err).Str("id", itemkey).Msg("cannot update attempt count on email")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
49
bot/reaction.go
Normal file
49
bot/reaction.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
)
|
||||
|
||||
var supportedReactions = map[string]string{
|
||||
"⛔️": commandSpamlistAdd,
|
||||
"🛑": commandSpamlistAdd,
|
||||
"🚫": commandSpamlistAdd,
|
||||
"spam": commandSpamlistAdd,
|
||||
}
|
||||
|
||||
func (b *Bot) handleReaction(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
content := evt.Content.AsReaction()
|
||||
action, ok := supportedReactions[content.GetRelatesTo().Key]
|
||||
if !ok { // cannot do anything with it
|
||||
return
|
||||
}
|
||||
|
||||
srcID := content.GetRelatesTo().EventID
|
||||
srcEvt, err := b.lp.GetClient().GetEvent(evt.RoomID, srcID)
|
||||
if err != nil {
|
||||
b.Error(ctx, "cannot find event %s: %v", srcID, err)
|
||||
return
|
||||
}
|
||||
linkpearl.ParseContent(srcEvt, b.log)
|
||||
if b.lp.GetMachine().StateStore.IsEncrypted(evt.RoomID) {
|
||||
decrypted, derr := b.lp.GetClient().Crypto.Decrypt(srcEvt)
|
||||
if derr == nil {
|
||||
srcEvt = decrypted
|
||||
}
|
||||
}
|
||||
threadID := linkpearl.EventParent(srcID, srcEvt.Content.AsMessage())
|
||||
ctx = threadIDToContext(ctx, threadID)
|
||||
linkpearl.ParseContent(evt, b.log)
|
||||
|
||||
if action == commandSpamlistAdd {
|
||||
sender := linkpearl.EventField[string](&srcEvt.Content, eventFromKey)
|
||||
if sender == "" {
|
||||
b.Error(ctx, "cannot get sender of the email")
|
||||
return
|
||||
}
|
||||
b.runSpamlistAdd(ctx, []string{commandSpamlistAdd, linkpearl.EventField[string](&srcEvt.Content, eventFromKey)})
|
||||
}
|
||||
}
|
||||
@@ -1,114 +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"
|
||||
botOptionQueueBatch = "queue:batch"
|
||||
botOptionQueueRetries = "queue:retries"
|
||||
botOptionBanlistEnabled = "banlist:enabled"
|
||||
botOptionGreylist = "greylist"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// BanlistEnabled option
|
||||
func (s botSettings) BanlistEnabled() bool {
|
||||
return utils.Bool(s.Get(botOptionBanlistEnabled))
|
||||
}
|
||||
|
||||
// Greylist option (duration in minutes)
|
||||
func (s botSettings) Greylist() int {
|
||||
return utils.Int(s.Get(botOptionGreylist))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// QueueBatch option
|
||||
func (s botSettings) QueueBatch() int {
|
||||
return utils.Int(s.Get(botOptionQueueBatch))
|
||||
}
|
||||
|
||||
// QueueRetries option
|
||||
func (s botSettings) QueueRetries() int {
|
||||
return utils.Int(s.Get(botOptionQueueRetries))
|
||||
}
|
||||
|
||||
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,116 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// account data keys
|
||||
const (
|
||||
acBanlistKey = "cc.etke.postmoogle.banlist"
|
||||
acGreylistKey = "cc.etke.postmoogle.greylist"
|
||||
)
|
||||
|
||||
type bglist map[string]string
|
||||
|
||||
// Slice returns slice of ban- or greylist items
|
||||
func (b bglist) Slice() []string {
|
||||
slice := make([]string, 0, len(b))
|
||||
for item := range b {
|
||||
slice = append(slice, item)
|
||||
}
|
||||
sort.Strings(slice)
|
||||
|
||||
return slice
|
||||
}
|
||||
|
||||
func (b bglist) getKey(addr net.Addr) string {
|
||||
key := addr.String()
|
||||
host, _, _ := net.SplitHostPort(key) //nolint:errcheck // either way it's ok
|
||||
if host != "" {
|
||||
key = host
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Has addr in ban- or greylist
|
||||
func (b bglist) Has(addr net.Addr) bool {
|
||||
_, ok := b[b.getKey(addr)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Get when addr was added in ban- or greylist
|
||||
func (b bglist) Get(addr net.Addr) (time.Time, bool) {
|
||||
from := b[b.getKey(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 (b bglist) Add(addr net.Addr) {
|
||||
key := b.getKey(addr)
|
||||
if _, ok := b[key]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
b[key] = time.Now().UTC().Format(time.RFC1123Z)
|
||||
}
|
||||
|
||||
// Remove an addr from ban- or greylist
|
||||
func (b bglist) Remove(addr net.Addr) {
|
||||
key := b.getKey(addr)
|
||||
if _, ok := b[key]; !ok {
|
||||
return
|
||||
}
|
||||
|
||||
delete(b, key)
|
||||
}
|
||||
|
||||
func (b *Bot) getBanlist() bglist {
|
||||
config, err := b.lp.GetAccountData(acBanlistKey)
|
||||
if err != nil {
|
||||
b.log.Error("cannot get banlist: %v", utils.UnwrapError(err))
|
||||
}
|
||||
if config == nil {
|
||||
config = make(bglist, 0)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func (b *Bot) setBanlist(cfg bglist) error {
|
||||
b.lock("banlist")
|
||||
if cfg == nil {
|
||||
cfg = make(bglist, 0)
|
||||
}
|
||||
b.banlist = cfg
|
||||
defer b.unlock("banlist")
|
||||
|
||||
return utils.UnwrapError(b.lp.SetAccountData(acBanlistKey, cfg))
|
||||
}
|
||||
|
||||
func (b *Bot) getGreylist() bglist {
|
||||
config, err := b.lp.GetAccountData(acGreylistKey)
|
||||
if err != nil {
|
||||
b.log.Error("cannot get banlist: %v", utils.UnwrapError(err))
|
||||
}
|
||||
if config == nil {
|
||||
config = make(bglist, 0)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func (b *Bot) setGreylist(cfg bglist) error {
|
||||
return utils.UnwrapError(b.lp.SetAccountData(acGreylistKey, cfg))
|
||||
}
|
||||
@@ -1,191 +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"
|
||||
roomOptionDomain = "domain"
|
||||
roomOptionNoSend = "nosend"
|
||||
roomOptionNoSender = "nosender"
|
||||
roomOptionNoRecipient = "norecipient"
|
||||
roomOptionNoSubject = "nosubject"
|
||||
roomOptionNoHTML = "nohtml"
|
||||
roomOptionNoThreads = "nothreads"
|
||||
roomOptionNoFiles = "nofiles"
|
||||
roomOptionPassword = "password"
|
||||
roomOptionSpamcheckSMTP = "spamcheck:smtp"
|
||||
roomOptionSpamcheckMX = "spamcheck:mx"
|
||||
roomOptionSpamlist = "spamlist"
|
||||
)
|
||||
|
||||
type roomSettings map[string]string
|
||||
|
||||
// Get option
|
||||
func (s roomSettings) Get(key string) string {
|
||||
return s[strings.ToLower(strings.TrimSpace(key))]
|
||||
}
|
||||
|
||||
// Set option
|
||||
func (s roomSettings) Set(key, value string) {
|
||||
s[strings.ToLower(strings.TrimSpace(key))] = value
|
||||
}
|
||||
|
||||
func (s roomSettings) Mailbox() string {
|
||||
return s.Get(roomOptionMailbox)
|
||||
}
|
||||
|
||||
func (s roomSettings) Domain() string {
|
||||
return s.Get(roomOptionDomain)
|
||||
}
|
||||
|
||||
func (s roomSettings) Owner() string {
|
||||
return s.Get(roomOptionOwner)
|
||||
}
|
||||
|
||||
func (s roomSettings) Password() string {
|
||||
return s.Get(roomOptionPassword)
|
||||
}
|
||||
|
||||
func (s roomSettings) NoSend() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoSend))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoSender() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoSender))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoRecipient() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoRecipient))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoSubject() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoSubject))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoHTML() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoHTML))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoThreads() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoThreads))
|
||||
}
|
||||
|
||||
func (s roomSettings) NoFiles() bool {
|
||||
return utils.Bool(s.Get(roomOptionNoFiles))
|
||||
}
|
||||
|
||||
func (s roomSettings) SpamcheckSMTP() bool {
|
||||
return utils.Bool(s.Get(roomOptionSpamcheckSMTP))
|
||||
}
|
||||
|
||||
func (s roomSettings) SpamcheckMX() bool {
|
||||
return utils.Bool(s.Get(roomOptionSpamcheckMX))
|
||||
}
|
||||
|
||||
func (s roomSettings) Spamlist() []string {
|
||||
return utils.StringSlice(s.Get(roomOptionSpamlist))
|
||||
}
|
||||
|
||||
func (s roomSettings) migrateSpamlistSettings() {
|
||||
uniq := map[string]struct{}{}
|
||||
emails := utils.StringSlice(s.Get("spamlist:emails"))
|
||||
localparts := utils.StringSlice(s.Get("spamlist:localparts"))
|
||||
hosts := utils.StringSlice(s.Get("spamlist:hosts"))
|
||||
list := utils.StringSlice(s.Get(roomOptionSpamlist))
|
||||
delete(s, "spamlist:emails")
|
||||
delete(s, "spamlist:localparts")
|
||||
delete(s, "spamlist:hosts")
|
||||
|
||||
for _, email := range emails {
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
uniq[email] = struct{}{}
|
||||
}
|
||||
|
||||
for _, localpart := range localparts {
|
||||
if localpart == "" {
|
||||
continue
|
||||
}
|
||||
uniq[localpart+"@*"] = struct{}{}
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
uniq["*@"+host] = struct{}{}
|
||||
}
|
||||
|
||||
for _, item := range list {
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
uniq[item] = struct{}{}
|
||||
}
|
||||
|
||||
spamlist := make([]string, 0, len(uniq))
|
||||
for item := range uniq {
|
||||
spamlist = append(spamlist, item)
|
||||
}
|
||||
s.Set(roomOptionSpamlist, strings.Join(spamlist, ","))
|
||||
}
|
||||
|
||||
// ContentOptions converts room display settings to content options
|
||||
func (s roomSettings) ContentOptions() *utils.ContentOptions {
|
||||
return &utils.ContentOptions{
|
||||
HTML: !s.NoHTML(),
|
||||
Sender: !s.NoSender(),
|
||||
Recipient: !s.NoRecipient(),
|
||||
Subject: !s.NoSubject(),
|
||||
Threads: !s.NoThreads(),
|
||||
|
||||
ToKey: eventToKey,
|
||||
FromKey: eventFromKey,
|
||||
SubjectKey: eventSubjectKey,
|
||||
MessageIDKey: eventMessageIDkey,
|
||||
InReplyToKey: eventInReplyToKey,
|
||||
ReferencesKey: eventReferencesKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) getRoomSettings(roomID id.RoomID) (roomSettings, error) {
|
||||
config, err := b.lp.GetRoomAccountData(roomID, acRoomSettingsKey)
|
||||
if config == nil {
|
||||
config = map[string]string{}
|
||||
}
|
||||
|
||||
return config, utils.UnwrapError(err)
|
||||
}
|
||||
|
||||
func (b *Bot) setRoomSettings(roomID id.RoomID, cfg roomSettings) error {
|
||||
return utils.UnwrapError(b.lp.SetRoomAccountData(roomID, acRoomSettingsKey, cfg))
|
||||
}
|
||||
|
||||
func (b *Bot) migrateRoomSettings(roomID id.RoomID) {
|
||||
cfg, err := b.getRoomSettings(roomID)
|
||||
if err != nil {
|
||||
b.log.Error("cannot retrieve room settings: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if cfg["spamlist:emails"] == "" && cfg["spamlist:localparts"] == "" && cfg["spamlist:hosts"] == "" {
|
||||
return
|
||||
}
|
||||
cfg.migrateSpamlistSettings()
|
||||
err = b.setRoomSettings(roomID, cfg)
|
||||
if err != nil {
|
||||
b.log.Error("cannot migrate room settings: %v", err)
|
||||
}
|
||||
}
|
||||
59
bot/sync.go
59
bot/sync.go
@@ -3,7 +3,6 @@ package bot
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitlab.com/etke.cc/go/mxidwc"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
)
|
||||
@@ -21,18 +20,20 @@ func (b *Bot) initSync() {
|
||||
event.EventMessage,
|
||||
func(_ mautrix.EventSource, evt *event.Event) {
|
||||
go b.onMessage(evt)
|
||||
})
|
||||
},
|
||||
)
|
||||
b.lp.OnEventType(
|
||||
event.EventEncrypted,
|
||||
event.EventReaction,
|
||||
func(_ mautrix.EventSource, evt *event.Event) {
|
||||
go b.onEncryptedMessage(evt)
|
||||
})
|
||||
go b.onReaction(evt)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// joinPermit is called by linkpearl when processing "invite" events and deciding if rooms should be auto-joined or not
|
||||
func (b *Bot) joinPermit(evt *event.Event) bool {
|
||||
if !mxidwc.Match(evt.Sender.String(), b.allowedUsers) {
|
||||
b.log.Debug("Rejecting room invitation from unallowed user: %s", evt.Sender)
|
||||
if !b.allowUsers(evt.Sender, evt.RoomID) {
|
||||
b.log.Debug().Str("userID", evt.Sender.String()).Msg("Rejecting room invitation from unallowed user")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -40,6 +41,11 @@ func (b *Bot) joinPermit(evt *event.Event) bool {
|
||||
}
|
||||
|
||||
func (b *Bot) onMembership(evt *event.Event) {
|
||||
// mautrix 0.15.x migration
|
||||
if b.ignoreBefore >= evt.Timestamp {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := newContext(evt)
|
||||
|
||||
evtType := evt.Content.AsMember().Membership
|
||||
@@ -60,29 +66,27 @@ func (b *Bot) onMessage(evt *event.Event) {
|
||||
if evt.Sender == b.lp.GetClient().UserID {
|
||||
return
|
||||
}
|
||||
// mautrix 0.15.x migration
|
||||
if b.ignoreBefore >= evt.Timestamp {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := newContext(evt)
|
||||
b.handle(ctx)
|
||||
}
|
||||
|
||||
func (b *Bot) onEncryptedMessage(evt *event.Event) {
|
||||
func (b *Bot) onReaction(evt *event.Event) {
|
||||
// ignore own messages
|
||||
if evt.Sender == b.lp.GetClient().UserID {
|
||||
return
|
||||
}
|
||||
// ignore encrypted events in noecryption mode
|
||||
if b.lp.GetMachine() == nil {
|
||||
// mautrix 0.15.x migration
|
||||
if b.ignoreBefore >= evt.Timestamp {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := newContext(evt)
|
||||
|
||||
decrypted, err := b.lp.GetMachine().DecryptMegolmEvent(evt)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot decrypt a message: %v", err)
|
||||
return
|
||||
}
|
||||
ctx = eventToContext(ctx, decrypted)
|
||||
|
||||
b.handle(ctx)
|
||||
b.handleReaction(ctx)
|
||||
}
|
||||
|
||||
// onBotJoin handles the "bot joined the room" event
|
||||
@@ -92,11 +96,11 @@ func (b *Bot) onBotJoin(ctx context.Context) {
|
||||
// as described in this bug report: https://github.com/matrix-org/synapse/issues/9768
|
||||
_, ok := b.handledMembershipEvents.LoadOrStore(evt.ID, true)
|
||||
if ok {
|
||||
b.log.Info("Suppressing already handled event %s", evt.ID)
|
||||
b.log.Info().Str("eventID", evt.ID.String()).Msg("Suppressing already handled event")
|
||||
return
|
||||
}
|
||||
|
||||
b.sendIntroduction(ctx, evt.RoomID)
|
||||
b.sendIntroduction(evt.RoomID)
|
||||
b.sendHelp(ctx)
|
||||
}
|
||||
|
||||
@@ -104,17 +108,22 @@ func (b *Bot) onLeave(ctx context.Context) {
|
||||
evt := eventFromContext(ctx)
|
||||
_, ok := b.handledMembershipEvents.LoadOrStore(evt.ID, true)
|
||||
if ok {
|
||||
b.log.Info("Suppressing already handled event %s", evt.ID)
|
||||
b.log.Info().Str("eventID", evt.ID.String()).Msg("Suppressing already handled event")
|
||||
return
|
||||
}
|
||||
members := b.lp.GetStore().GetRoomMembers(evt.RoomID)
|
||||
members, err := b.lp.GetClient().StateStore.GetRoomJoinedOrInvitedMembers(evt.RoomID)
|
||||
if err != nil {
|
||||
b.log.Error().Err(err).Str("roomID", evt.RoomID.String()).Msg("cannot get joined or invited members")
|
||||
return
|
||||
}
|
||||
|
||||
count := len(members)
|
||||
if count == 1 && members[0] == b.lp.GetClient().UserID {
|
||||
b.log.Info("no more users left in the %s room", evt.RoomID)
|
||||
b.log.Info().Str("roomID", evt.RoomID.String()).Msg("no more users left in the room")
|
||||
b.runStop(ctx)
|
||||
_, err := b.lp.GetClient().LeaveRoom(evt.RoomID)
|
||||
if err != nil {
|
||||
b.Error(ctx, evt.RoomID, "cannot leave empty room: %v", err)
|
||||
b.Error(ctx, "cannot leave empty room: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
cmd/cmd.go
128
cmd/cmd.go
@@ -2,48 +2,55 @@ package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
zlogsentry "github.com/archdx/zerolog-sentry"
|
||||
"github.com/getsentry/sentry-go"
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mileusna/crontab"
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"github.com/rs/zerolog"
|
||||
"gitlab.com/etke.cc/go/healthchecks"
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
lpcfg "gitlab.com/etke.cc/linkpearl/config"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/bot"
|
||||
mxconfig "gitlab.com/etke.cc/postmoogle/bot/config"
|
||||
"gitlab.com/etke.cc/postmoogle/bot/queue"
|
||||
"gitlab.com/etke.cc/postmoogle/config"
|
||||
"gitlab.com/etke.cc/postmoogle/smtp"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
q *queue.Queue
|
||||
hc *healthchecks.Client
|
||||
mxc *mxconfig.Manager
|
||||
mxb *bot.Bot
|
||||
cron *crontab.Crontab
|
||||
smtpm *smtp.Manager
|
||||
log *logger.Logger
|
||||
log zerolog.Logger
|
||||
)
|
||||
|
||||
func main() {
|
||||
quit := make(chan struct{})
|
||||
|
||||
cfg := config.New()
|
||||
log = logger.New("postmoogle.", cfg.LogLevel)
|
||||
utils.SetLogger(log)
|
||||
initLog(cfg)
|
||||
utils.SetDomains(cfg.Domains)
|
||||
|
||||
log.Info("#############################")
|
||||
log.Info("Postmoogle")
|
||||
log.Info("Matrix: true")
|
||||
log.Info("#############################")
|
||||
log.Info().Msg("#############################")
|
||||
log.Info().Msg("Postmoogle")
|
||||
log.Info().Msg("Matrix: true")
|
||||
log.Info().Msg("#############################")
|
||||
|
||||
log.Debug("starting internal components...")
|
||||
initSentry(cfg)
|
||||
initBot(cfg)
|
||||
log.Debug().Msg("starting internal components...")
|
||||
initHealthchecks(cfg)
|
||||
initMatrix(cfg)
|
||||
initSMTP(cfg)
|
||||
initCron()
|
||||
initShutdown(quit)
|
||||
@@ -53,57 +60,67 @@ func main() {
|
||||
|
||||
if err := smtpm.Start(); err != nil {
|
||||
//nolint:gocritic
|
||||
log.Fatal("SMTP server crashed: %v", err)
|
||||
log.Fatal().Err(err).Msg("SMTP server crashed")
|
||||
}
|
||||
|
||||
<-quit
|
||||
}
|
||||
|
||||
func initSentry(cfg *config.Config) {
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: cfg.Sentry.DSN,
|
||||
AttachStacktrace: true,
|
||||
})
|
||||
func initLog(cfg *config.Config) {
|
||||
loglevel, err := zerolog.ParseLevel(cfg.LogLevel)
|
||||
if err != nil {
|
||||
log.Fatal("cannot initialize sentry: %v", err)
|
||||
loglevel = zerolog.InfoLevel
|
||||
}
|
||||
zerolog.SetGlobalLevel(loglevel)
|
||||
var w io.Writer
|
||||
consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout, PartsExclude: []string{zerolog.TimestampFieldName}}
|
||||
sentryWriter, err := zlogsentry.New(cfg.Monitoring.SentryDSN)
|
||||
if err == nil {
|
||||
w = io.MultiWriter(sentryWriter, consoleWriter)
|
||||
} else {
|
||||
w = consoleWriter
|
||||
}
|
||||
log = zerolog.New(w).With().Timestamp().Caller().Logger()
|
||||
}
|
||||
|
||||
func initBot(cfg *config.Config) {
|
||||
func initHealthchecks(cfg *config.Config) {
|
||||
if cfg.Monitoring.HealchecksUUID == "" {
|
||||
return
|
||||
}
|
||||
hc = healthchecks.New(cfg.Monitoring.HealchecksUUID, func(operation string, err error) {
|
||||
log.Error().Err(err).Str("operation", operation).Msg("healthchecks operation failed")
|
||||
})
|
||||
hc.Start(strings.NewReader("starting postmoogle"))
|
||||
go hc.Auto(cfg.Monitoring.HealthechsDuration)
|
||||
}
|
||||
|
||||
func initMatrix(cfg *config.Config) {
|
||||
db, err := sql.Open(cfg.DB.Dialect, cfg.DB.DSN)
|
||||
if err != nil {
|
||||
log.Fatal("cannot initialize SQL database: %v", err)
|
||||
log.Fatal().Err(err).Msg("cannot initialize SQL database")
|
||||
}
|
||||
mxlog := logger.New("matrix.", cfg.LogLevel)
|
||||
lp, err := linkpearl.New(&lpcfg.Config{
|
||||
|
||||
lp, err := linkpearl.New(&linkpearl.Config{
|
||||
Homeserver: cfg.Homeserver,
|
||||
Login: cfg.Login,
|
||||
Password: cfg.Password,
|
||||
SharedSecret: cfg.SharedSecret,
|
||||
DB: db,
|
||||
Dialect: cfg.DB.Dialect,
|
||||
NoEncryption: cfg.NoEncryption,
|
||||
AccountDataSecret: cfg.DataSecret,
|
||||
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"),
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
// nolint // Fatal = panic, not os.Exit()
|
||||
log.Fatal("cannot initialize matrix bot: %v", err)
|
||||
log.Fatal().Err(err).Msg("cannot initialize matrix bot")
|
||||
}
|
||||
|
||||
mxb, err = bot.New(lp, mxlog, cfg.Prefix, cfg.Domains, cfg.Admins)
|
||||
mxc = mxconfig.New(lp, &log)
|
||||
q = queue.New(lp, mxc, &log)
|
||||
mxb, err = bot.New(q, lp, &log, mxc, cfg.Proxies, cfg.Prefix, cfg.Domains, cfg.Admins, bot.MBXConfig(cfg.Mailboxes))
|
||||
if err != nil {
|
||||
// nolint // Fatal = panic, not os.Exit()
|
||||
log.Fatal("cannot start matrix bot: %v", err)
|
||||
log.Panic().Err(err).Msg("cannot start matrix bot")
|
||||
}
|
||||
log.Debug("bot has been created")
|
||||
log.Debug().Msg("bot has been created")
|
||||
}
|
||||
|
||||
func initSMTP(cfg *config.Config) {
|
||||
@@ -114,18 +131,30 @@ func initSMTP(cfg *config.Config) {
|
||||
TLSKeys: cfg.TLS.Keys,
|
||||
TLSPort: cfg.TLS.Port,
|
||||
TLSRequired: cfg.TLS.Required,
|
||||
LogLevel: cfg.LogLevel,
|
||||
Logger: &log,
|
||||
MaxSize: cfg.MaxSize,
|
||||
Bot: mxb,
|
||||
Callers: []smtp.Caller{mxb, q},
|
||||
Relay: &smtp.RelayConfig{
|
||||
Host: cfg.Relay.Host,
|
||||
Port: cfg.Relay.Port,
|
||||
Usename: cfg.Relay.Username,
|
||||
Password: cfg.Relay.Password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func initCron() {
|
||||
cron = crontab.New()
|
||||
|
||||
err := cron.AddJob("* * * * *", mxb.ProcessQueue)
|
||||
err := cron.AddJob("* * * * *", q.Process)
|
||||
if err != nil {
|
||||
log.Error("cannot start ProcessQueue cronjob: %v", err)
|
||||
log.Error().Err(err).Msg("cannot start queue processing cronjob")
|
||||
}
|
||||
|
||||
err = cron.AddJob("*/5 * * * *", mxb.SyncRooms)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("cannot start sync rooms cronjob")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,30 +171,33 @@ func initShutdown(quit chan struct{}) {
|
||||
}
|
||||
|
||||
func startBot(statusMsg string) {
|
||||
log.Debug("starting matrix bot: %s...", statusMsg)
|
||||
log.Debug().Str("status message", statusMsg).Msg("starting matrix bot...")
|
||||
err := mxb.Start(statusMsg)
|
||||
if err != nil {
|
||||
//nolint:gocritic
|
||||
log.Fatal("cannot start the bot: %v", err)
|
||||
log.Panic().Err(err).Msg("cannot start the bot")
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
log.Info("Shutting down...")
|
||||
log.Info().Msg("Shutting down...")
|
||||
cron.Shutdown()
|
||||
smtpm.Stop()
|
||||
mxb.Stop()
|
||||
if hc != nil {
|
||||
hc.Shutdown()
|
||||
hc.ExitStatus(0, strings.NewReader("shutting down postmoogle"))
|
||||
}
|
||||
|
||||
sentry.Flush(5 * time.Second)
|
||||
log.Info("Postmoogle has been stopped")
|
||||
log.Info().Msg("Postmoogle has been stopped")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func recovery() {
|
||||
defer shutdown()
|
||||
err := recover()
|
||||
// no problem just shutdown
|
||||
if err == nil {
|
||||
if err != nil {
|
||||
sentry.CurrentHub().Recover(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitlab.com/etke.cc/go/env"
|
||||
)
|
||||
|
||||
@@ -14,28 +16,44 @@ func New() *Config {
|
||||
Homeserver: env.String("homeserver", defaultConfig.Homeserver),
|
||||
Login: env.String("login", defaultConfig.Login),
|
||||
Password: env.String("password", defaultConfig.Password),
|
||||
SharedSecret: env.String("sharedsecret", defaultConfig.SharedSecret),
|
||||
Prefix: env.String("prefix", defaultConfig.Prefix),
|
||||
Domains: migrateDomains("domain", "domains"),
|
||||
Port: env.String("port", defaultConfig.Port),
|
||||
Proxies: env.Slice("proxies"),
|
||||
NoEncryption: env.Bool("noencryption"),
|
||||
DataSecret: env.String("data.secret", defaultConfig.DataSecret),
|
||||
MaxSize: env.Int("maxsize", defaultConfig.MaxSize),
|
||||
StatusMsg: env.String("statusmsg", defaultConfig.StatusMsg),
|
||||
Admins: env.Slice("admins"),
|
||||
Mailboxes: Mailboxes{
|
||||
Reserved: env.Slice("mailboxes.reserved"),
|
||||
Forwarded: env.Slice("mailboxes.forwarded"),
|
||||
Activation: env.String("mailboxes.activation", defaultConfig.Mailboxes.Activation),
|
||||
},
|
||||
TLS: TLS{
|
||||
Certs: env.Slice("tls.cert"),
|
||||
Keys: env.Slice("tls.key"),
|
||||
Required: env.Bool("tls.required"),
|
||||
Port: env.String("tls.port", defaultConfig.TLS.Port),
|
||||
},
|
||||
Sentry: Sentry{
|
||||
DSN: env.String("sentry.dsn", defaultConfig.Sentry.DSN),
|
||||
Monitoring: Monitoring{
|
||||
SentryDSN: env.String("monitoring.sentry.dsn", env.String("sentry.dsn", "")),
|
||||
SentrySampleRate: env.Int("monitoring.sentry.rate", env.Int("sentry.rate", 0)),
|
||||
HealchecksUUID: env.String("monitoring.healthchecks.uuid", ""),
|
||||
HealthechsDuration: time.Duration(env.Int("monitoring.healthchecks.duration", int(defaultConfig.Monitoring.HealthechsDuration))) * time.Second,
|
||||
},
|
||||
LogLevel: env.String("loglevel", defaultConfig.LogLevel),
|
||||
DB: DB{
|
||||
DSN: env.String("db.dsn", defaultConfig.DB.DSN),
|
||||
Dialect: env.String("db.dialect", defaultConfig.DB.Dialect),
|
||||
},
|
||||
Relay: Relay{
|
||||
Host: env.String("relay.host", defaultConfig.Relay.Host),
|
||||
Port: env.String("relay.port", defaultConfig.Relay.Port),
|
||||
Username: env.String("relay.username", defaultConfig.Relay.Username),
|
||||
Password: env.String("relay.password", defaultConfig.Relay.Password),
|
||||
},
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
||||
@@ -7,10 +7,17 @@ var defaultConfig = &Config{
|
||||
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,17 +1,23 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Config of Postmoogle
|
||||
type Config struct {
|
||||
// Homeserver url
|
||||
Homeserver string
|
||||
// Login is a MXID localpart (scheduler - OK, @scheduler:example.com - wrong)
|
||||
// Login is a localpart if logging in with password (postmoogle) OR full MXID if logging in with shared secret (@postmoogle:example.com)
|
||||
Login string
|
||||
// Password for login/password auth only
|
||||
Password string
|
||||
// SharedSecret for login/sharedsecret auth only
|
||||
SharedSecret string
|
||||
// Domains for SMTP
|
||||
Domains []string
|
||||
// Port for SMTP
|
||||
Port string
|
||||
// Proxies is list of trusted SMTP proxies
|
||||
Proxies []string
|
||||
// RoomID of the admin room
|
||||
LogLevel string
|
||||
// DataSecret is account data secret key (password) to encrypt all account data values
|
||||
@@ -24,6 +30,8 @@ type Config struct {
|
||||
MaxSize int
|
||||
// StatusMsg of the bot
|
||||
StatusMsg string
|
||||
// Mailboxes config
|
||||
Mailboxes Mailboxes
|
||||
// Admins holds list of admin users (wildcards supported), e.g.: @*:example.com, @bot.*:example.com, @admin:*. Empty = no admins
|
||||
Admins []string
|
||||
|
||||
@@ -33,8 +41,10 @@ type Config struct {
|
||||
// TLS config
|
||||
TLS TLS
|
||||
|
||||
// Sentry config
|
||||
Sentry Sentry
|
||||
// Monitoring config
|
||||
Monitoring Monitoring
|
||||
|
||||
Relay Relay
|
||||
}
|
||||
|
||||
// DB config
|
||||
@@ -53,7 +63,25 @@ type TLS struct {
|
||||
Required bool
|
||||
}
|
||||
|
||||
// Sentry config
|
||||
type Sentry struct {
|
||||
DSN string
|
||||
// Monitoring config
|
||||
type Monitoring struct {
|
||||
SentryDSN string
|
||||
SentrySampleRate int
|
||||
HealchecksUUID string
|
||||
HealthechsDuration time.Duration
|
||||
}
|
||||
|
||||
// Mailboxes config
|
||||
type Mailboxes struct {
|
||||
Reserved []string
|
||||
Forwarded []string
|
||||
Activation string
|
||||
}
|
||||
|
||||
// Relay config
|
||||
type Relay struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
25
docs/mailboxes.md
Normal file
25
docs/mailboxes.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Mailboxes configuration
|
||||
|
||||
## `POSTMOOGLE_MAILBOXES_RESERVED`
|
||||
|
||||
Space separated list of reserved mailboxes, example:
|
||||
|
||||
```bash
|
||||
export POSTMOOGLE_MAILBOXES_RESERVED=admin root postmaster
|
||||
```
|
||||
|
||||
Nobody can create a mailbox from that list
|
||||
|
||||
## `POSTMOOGLE_MAILBOXES_ACTIVATION`
|
||||
|
||||
Type of activation flow:
|
||||
|
||||
### `none` (default)
|
||||
|
||||
If `POSTMOOGLE_MAILBOXES_ACTIVATION=none` mailbox will be just created as is, without any additional checks.
|
||||
|
||||
### `notify`
|
||||
|
||||
If `POSTMOOGLE_MAILBOXES_ACTIVATION=notify`, mailbox will be created as in `none` case **and** notification will be sent to one of the mailboxes managed by a postmoogle admin.
|
||||
|
||||
To make it work, a postmoogle admin (or multiple admins) should either set `!pm adminroom` or create at least one mailbox.
|
||||
42
docs/tricks.md
Normal file
42
docs/tricks.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# tricks
|
||||
|
||||
<!-- vim-markdown-toc GitLab -->
|
||||
|
||||
* [Logs](#logs)
|
||||
* [get most active hosts](#get-most-active-hosts)
|
||||
|
||||
<!-- vim-markdown-toc -->
|
||||
|
||||
## Logs
|
||||
|
||||
### get most active hosts
|
||||
|
||||
Even if you use postmoogle as an internal mail server and contact "outside internet" quite rarely,
|
||||
you will see lots of connections to your SMTP servers from random hosts over internet that do... nothing?
|
||||
They don't send any valid emails or do something meaningful, thus you can safely assume they are spammers.
|
||||
|
||||
To get top X (in example: top 10) hosts with biggest count of attempts to connect to your postmoogle instance, follow the steps:
|
||||
|
||||
1. enable debug log: `export POSTMOOGLE_LOGLEVEL=debug`
|
||||
2. restart postmoogle and wait some time to get stats
|
||||
3. run the following bash one-liner to show top 10 hosts by connections count:
|
||||
|
||||
```bash
|
||||
journalctl -o cat -u postmoogle | grep "smtp.DEBUG accepted connection from " | grep -oE "[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}" | sort | uniq -ci | sort -rn | head -n 10
|
||||
253 111.111.111.111
|
||||
183 222.222.222.222
|
||||
39 333.333.333.333
|
||||
38 444.444.444.444
|
||||
18 555.555.555.555
|
||||
16 666.666.666.666
|
||||
8 777.777.777.777
|
||||
5 888.888.888.888
|
||||
5 999.999.999.999
|
||||
4 010.010.010.010
|
||||
```
|
||||
|
||||
of course, IP addresses above are crafted just to visualize their place in that top, according to the number of connections done.
|
||||
In reality, you will see real IP addresses here. Usually, only hosts with hundreds or thousands of connections for the last 7 days worth checking.
|
||||
|
||||
What's next?
|
||||
Do **not** ban them right away. Check WHOIS info for each host and only after that decide if you really want to ban that host or not.
|
||||
2
e2e/send
2
e2e/send
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
ssmtp -v test@localhost < $1
|
||||
ssmtp -v test+sub@localhost < $1
|
||||
|
||||
@@ -3,7 +3,7 @@ Content-Type: multipart/alternative; boundary="Apple-Mail=_E091454E-BCFA-43B4-99
|
||||
Subject: MIME test 1
|
||||
Date: Sat, 13 Oct 2012 15:33:07 -0700
|
||||
Message-Id: <4E2E5A48-1A2C-4450-8663-D41B451DA93A@makita.skynet>
|
||||
To: test@localhost
|
||||
To: test+sub@localhost
|
||||
Mime-Version: 1.0 (Apple Message framework v1283)
|
||||
X-Mailer: Apple Mail (2.1283)
|
||||
|
||||
|
||||
283
email/email.go
Normal file
283
email/email.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"gitlab.com/etke.cc/linkpearl"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/format"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// Email object
|
||||
type Email struct {
|
||||
Date string
|
||||
MessageID string
|
||||
InReplyTo string
|
||||
References string
|
||||
From string
|
||||
To string
|
||||
RcptTo string
|
||||
CC []string
|
||||
Subject string
|
||||
Text string
|
||||
HTML string
|
||||
Files []*utils.File
|
||||
InlineFiles []*utils.File
|
||||
}
|
||||
|
||||
// New constructs Email object
|
||||
func New(messageID, inReplyTo, references, subject, from, to, rcptto, cc, text, html string, files, inline []*utils.File) *Email {
|
||||
email := &Email{
|
||||
Date: dateNow(),
|
||||
MessageID: messageID,
|
||||
InReplyTo: inReplyTo,
|
||||
References: references,
|
||||
From: Address(from),
|
||||
To: Address(to),
|
||||
CC: AddressList(cc),
|
||||
RcptTo: Address(rcptto),
|
||||
Subject: subject,
|
||||
Text: text,
|
||||
HTML: html,
|
||||
Files: files,
|
||||
InlineFiles: inline,
|
||||
}
|
||||
|
||||
if html != "" {
|
||||
html = styleRegex.ReplaceAllString(html, "")
|
||||
email.HTML = html
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
// FromEnvelope constructs Email object from envelope
|
||||
func FromEnvelope(rcptto string, envelope *enmime.Envelope) *Email {
|
||||
datetime, _ := envelope.Date() //nolint:errcheck // handled in dateNow()
|
||||
date := dateNow(datetime)
|
||||
|
||||
var html string
|
||||
if envelope.HTML != "" {
|
||||
html = styleRegex.ReplaceAllString(envelope.HTML, "")
|
||||
}
|
||||
|
||||
files := make([]*utils.File, 0, len(envelope.Attachments))
|
||||
for _, attachment := range envelope.Attachments {
|
||||
file := utils.NewFile(attachment.FileName, attachment.Content)
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
inlines := make([]*utils.File, 0, len(envelope.Inlines))
|
||||
for _, inline := range envelope.Inlines {
|
||||
file := utils.NewFile(inline.FileName, inline.Content)
|
||||
inlines = append(inlines, file)
|
||||
}
|
||||
|
||||
email := &Email{
|
||||
Date: date,
|
||||
MessageID: envelope.GetHeader("Message-Id"),
|
||||
InReplyTo: envelope.GetHeader("In-Reply-To"),
|
||||
References: envelope.GetHeader("References"),
|
||||
From: Address(envelope.GetHeader("From")),
|
||||
To: Address(envelope.GetHeader("To")),
|
||||
RcptTo: Address(rcptto),
|
||||
CC: AddressList(envelope.GetHeader("Cc")),
|
||||
Subject: envelope.GetHeader("Subject"),
|
||||
Text: envelope.Text,
|
||||
HTML: html,
|
||||
Files: files,
|
||||
InlineFiles: inlines,
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
// Mailbox returns postmoogle's mailbox, parsing it from FROM (if incoming=false) or TO (incoming=true)
|
||||
func (e *Email) Mailbox(incoming bool) string {
|
||||
if incoming {
|
||||
return utils.Mailbox(e.RcptTo)
|
||||
}
|
||||
return utils.Mailbox(e.From)
|
||||
}
|
||||
|
||||
func (e *Email) contentHeader(threadID id.EventID, text *strings.Builder, options *ContentOptions) {
|
||||
if options.Sender {
|
||||
text.WriteString(e.From)
|
||||
}
|
||||
if options.Recipient {
|
||||
mailbox, sub, host := utils.EmailParts(e.To)
|
||||
text.WriteString(" ➡️ ")
|
||||
text.WriteString(mailbox)
|
||||
text.WriteString("@")
|
||||
text.WriteString(host)
|
||||
if sub != "" {
|
||||
text.WriteString(" (")
|
||||
text.WriteString(sub)
|
||||
text.WriteString(")")
|
||||
}
|
||||
}
|
||||
if options.CC && len(e.CC) > 0 {
|
||||
text.WriteString("\ncc: ")
|
||||
text.WriteString(strings.Join(e.CC, ", "))
|
||||
}
|
||||
if options.Sender || options.Recipient || options.CC {
|
||||
text.WriteString("\n\n")
|
||||
}
|
||||
if options.Subject && threadID == "" {
|
||||
if options.Threadify {
|
||||
text.WriteString("**")
|
||||
text.WriteString(e.Subject)
|
||||
text.WriteString("**")
|
||||
} else {
|
||||
text.WriteString("# ")
|
||||
text.WriteString(e.Subject)
|
||||
|
||||
}
|
||||
text.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Content converts the email object to a Matrix event content
|
||||
func (e *Email) Content(threadID id.EventID, options *ContentOptions) *event.Content {
|
||||
var text strings.Builder
|
||||
|
||||
e.contentHeader(threadID, &text, options)
|
||||
|
||||
if threadID != "" || (threadID == "" && !options.Threadify) {
|
||||
if e.HTML != "" && options.HTML {
|
||||
text.WriteString(format.HTMLToMarkdown(e.HTML))
|
||||
} else {
|
||||
text.WriteString(e.Text)
|
||||
}
|
||||
}
|
||||
|
||||
parsed := format.RenderMarkdown(text.String(), true, true)
|
||||
parsed.RelatesTo = linkpearl.RelatesTo(threadID, !options.Threads)
|
||||
|
||||
var cc string
|
||||
if len(e.CC) > 0 {
|
||||
cc = strings.Join(e.CC, ", ")
|
||||
}
|
||||
|
||||
content := event.Content{
|
||||
Raw: map[string]any{
|
||||
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
|
||||
}
|
||||
|
||||
// ContentBody converts the email object to a Matrix event content that contains email body only
|
||||
// NOTE: returns nil if threadify is disabled
|
||||
func (e *Email) ContentBody(threadID id.EventID, options *ContentOptions) *event.Content {
|
||||
if !options.Threadify {
|
||||
return nil
|
||||
}
|
||||
var text string
|
||||
if e.HTML != "" && options.HTML {
|
||||
text = format.HTMLToMarkdown(e.HTML)
|
||||
} else {
|
||||
text = e.Text
|
||||
}
|
||||
|
||||
parsed := format.RenderMarkdown(text, true, true)
|
||||
parsed.RelatesTo = linkpearl.RelatesTo(threadID, !options.Threads)
|
||||
|
||||
content := event.Content{
|
||||
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, ok := parsedkey.(crypto.Signer)
|
||||
if !ok {
|
||||
return data.String()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
32
email/options.go
Normal file
32
email/options.go
Normal file
@@ -0,0 +1,32 @@
|
||||
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
|
||||
Threadify 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)
|
||||
}
|
||||
47
go.mod
47
go.mod
@@ -5,52 +5,57 @@ go 1.18
|
||||
// replace gitlab.com/etke.cc/linkpearl => ../linkpearl
|
||||
|
||||
require (
|
||||
github.com/archdx/zerolog-sentry v1.2.0
|
||||
github.com/emersion/go-msgauth v0.6.6
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||
github.com/emersion/go-smtp v0.15.0
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.1
|
||||
github.com/getsentry/sentry-go v0.13.0
|
||||
github.com/jhillyerd/enmime v0.10.0
|
||||
github.com/lib/pq v1.10.7
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.19
|
||||
github.com/mcnijman/go-emailaddress v1.1.0
|
||||
github.com/mileusna/crontab v1.2.0
|
||||
github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39
|
||||
github.com/rs/zerolog v1.31.0
|
||||
gitlab.com/etke.cc/go/env v1.0.0
|
||||
gitlab.com/etke.cc/go/logger v1.1.0
|
||||
gitlab.com/etke.cc/go/fswatcher v1.0.0
|
||||
gitlab.com/etke.cc/go/healthchecks v1.0.1
|
||||
gitlab.com/etke.cc/go/mxidwc v1.0.0
|
||||
gitlab.com/etke.cc/go/secgen v1.1.1
|
||||
gitlab.com/etke.cc/go/trysmtp v1.0.0
|
||||
gitlab.com/etke.cc/go/validator v1.0.4
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6
|
||||
golang.org/x/net v0.2.0
|
||||
maunium.net/go/mautrix v0.12.3
|
||||
gitlab.com/etke.cc/go/validator v1.0.6
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20231121221431-72443f33d266
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
|
||||
maunium.net/go/mautrix v0.16.2
|
||||
)
|
||||
|
||||
require (
|
||||
blitiri.com.ar/go/spf v1.5.1 // indirect
|
||||
github.com/buger/jsonparser v1.0.0 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect
|
||||
github.com/google/go-cmp v0.5.8 // indirect
|
||||
github.com/gorilla/mux v1.8.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.1 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.12 // indirect
|
||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/rs/zerolog v1.28.0 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/tidwall/gjson v1.14.3 // indirect
|
||||
github.com/tidwall/gjson v1.17.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/yuin/goldmark v1.5.3 // indirect
|
||||
golang.org/x/crypto v0.3.0 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.3.2 // indirect
|
||||
github.com/yuin/goldmark v1.6.0 // indirect
|
||||
gitlab.com/etke.cc/go/trysmtp v1.1.3 // indirect
|
||||
go.mau.fi/util v0.2.1 // indirect
|
||||
golang.org/x/crypto v0.15.0 // indirect
|
||||
golang.org/x/net v0.18.0 // indirect
|
||||
golang.org/x/sys v0.14.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.4.1 // indirect
|
||||
)
|
||||
|
||||
110
go.sum
110
go.sum
@@ -1,7 +1,13 @@
|
||||
blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE=
|
||||
blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/archdx/zerolog-sentry v1.2.0 h1:FDFqlo5XvL/jpDAPoAWI15EjJQVFvixn70v3IH//eTM=
|
||||
github.com/archdx/zerolog-sentry v1.2.0/go.mod h1:3H8gClGFafB90fKMsvfP017bdmkG5MD6UiA+6iPEwGw=
|
||||
github.com/buger/jsonparser v1.0.0 h1:etJTGF5ESxjI0Ic2UaLQs2LQQpa8G9ykQScukbh4L8A=
|
||||
github.com/buger/jsonparser v1.0.0/go.mod h1:tgcrVJ81GPSF0mz+0nu1Xaz0fazGPrmmJfJtxjbHhUQ=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -16,6 +22,8 @@ github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDm
|
||||
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
|
||||
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
|
||||
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
|
||||
@@ -27,31 +35,32 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 h1:gBeyun7mySAKWg7Fb0GOcv0upX9bdaZScs8QcRo8mEY=
|
||||
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.1 h1:5pv5N1lT1fjLg2VQ5KWc7kmucp2x/kvFOnxuVTqZ6x4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:g0fAGBisHaEQ0TRq1iBvemFRf+8AEWEmBESSiWB3Vsc=
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
|
||||
github.com/jhillyerd/enmime v0.10.0/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
|
||||
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
|
||||
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
|
||||
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mcnijman/go-emailaddress v1.1.0 h1:7/Uxgn9pXwXmvXsFSgORo6XoRTrttj7AGmmB2yFArAg=
|
||||
github.com/mcnijman/go-emailaddress v1.1.0/go.mod h1:m+aauxGmv31sB5zZ1I8ICcMoa9ZHOA9RiurCijfvkhI=
|
||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE=
|
||||
github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ=
|
||||
github.com/mileusna/crontab v1.2.0 h1:x9ZmE2A4p6CDqMEGQ+GbqsNtnmbdmWMQYShdQu8LvrU=
|
||||
@@ -68,18 +77,18 @@ github.com/raja/argon2pw v1.0.2-0.20210910183755-a391af63bd39/go.mod h1:idX/fPqw
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
|
||||
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
|
||||
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
|
||||
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
|
||||
github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
@@ -87,54 +96,61 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
|
||||
github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
|
||||
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
gitlab.com/etke.cc/go/env v1.0.0 h1:J98BwzOuELnjsVPFvz5wa79L7IoRV9CmrS41xLYXtSw=
|
||||
gitlab.com/etke.cc/go/env v1.0.0/go.mod h1:e1l4RM5MA1sc0R1w/RBDAESWRwgo5cOG9gx8BKUn2C4=
|
||||
gitlab.com/etke.cc/go/logger v1.1.0 h1:Yngp/DDLmJ0jJNLvLXrfan5Gi5QV+r7z6kCczTv8t4U=
|
||||
gitlab.com/etke.cc/go/logger v1.1.0/go.mod h1:8Vw5HFXlZQ5XeqvUs5zan+GnhrQyYtm/xe+yj8H/0zk=
|
||||
gitlab.com/etke.cc/go/fswatcher v1.0.0 h1:uyiVn+1NVCjOLZrXSZouIDBDZBMwVipS4oYuvAFpPzo=
|
||||
gitlab.com/etke.cc/go/fswatcher v1.0.0/go.mod h1:MqTOxyhXfvaVZQUL9/Ksbl2ow1PTBVu3eqIldvMq0RE=
|
||||
gitlab.com/etke.cc/go/healthchecks v1.0.1 h1:IxPB+r4KtEM6wf4K7MeQoH1XnuBITMGUqFaaRIgxeUY=
|
||||
gitlab.com/etke.cc/go/healthchecks v1.0.1/go.mod h1:EzQjwSawh8tQEX43Ls0dI9mND6iWd5NHtmapdO24fMI=
|
||||
gitlab.com/etke.cc/go/mxidwc v1.0.0 h1:6EAlJXvs3nU4RaMegYq6iFlyVvLw7JZYnZmNCGMYQP0=
|
||||
gitlab.com/etke.cc/go/mxidwc v1.0.0/go.mod h1:E/0kh45SAN9+ntTG0cwkAEKdaPxzvxVmnjwivm9nmz8=
|
||||
gitlab.com/etke.cc/go/secgen v1.1.1 h1:RmKOki725HIhWJHzPtAc9X4YvBneczndchpMgoDkE8w=
|
||||
gitlab.com/etke.cc/go/secgen v1.1.1/go.mod h1:3pJqRGeWApzx7qXjABqz2o2SMCNpKSZao/gXVdasqE8=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.0.0 h1:f/7gSmzohKniVeLSLevI+ZsySYcPUGkT9cRlOTwjOr8=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.0.0/go.mod h1:KqRuIB2IPElEEbAxXmFyKtm7S5YiuEb4lxwWthccqyE=
|
||||
gitlab.com/etke.cc/go/validator v1.0.4 h1:2HIBP12f/RZr/7KTNH5/PgPTzl1vi7Co3lmhNTWB31A=
|
||||
gitlab.com/etke.cc/go/validator v1.0.4/go.mod h1:3vdssRG4LwgdTr9IHz9MjGSEO+3/FO9hXPGMuSeweJ8=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6 h1:+HDT2/bx3Hug++aeDE/PaoRRcnKdYzEm6i2RlOAzPXo=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6/go.mod h1:Dgtu0qvymNjjky4Bu5WC8+iSohcb5xZ9CtkD3ezDqIA=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.1.3 h1:e2EHond77onMaecqCg6mWumffTSEf+ycgj88nbeefDI=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.1.3/go.mod h1:lOO7tTdAE0a3ETV3wN3GJ7I1Tqewu7YTpPWaOmTteV0=
|
||||
gitlab.com/etke.cc/go/validator v1.0.6 h1:w0Muxf9Pqw7xvF7NaaswE6d7r9U3nB2t2l5PnFMrecQ=
|
||||
gitlab.com/etke.cc/go/validator v1.0.6/go.mod h1:Id0SxRj0J3IPhiKlj0w1plxVLZfHlkwipn7HfRZsDts=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20231121221431-72443f33d266 h1:mGbLQkdE35WeyinqP38HC0dqUOJ7FItEAumVIOz7Gg8=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20231121221431-72443f33d266/go.mod h1:wFEvngglb6ZTlE58/2a9gwYYs6V3FTYclYn5Pf5EGyQ=
|
||||
go.mau.fi/util v0.2.1 h1:eazulhFE/UmjOFtPrGg6zkF5YfAyiDzQb8ihLMbsPWw=
|
||||
go.mau.fi/util v0.2.1/go.mod h1:MjlzCQEMzJ+G8RsPawHzpLB8rwTo3aPIjG5FzBvQT/c=
|
||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
|
||||
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
||||
golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
|
||||
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM=
|
||||
golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8=
|
||||
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/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
|
||||
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
|
||||
maunium.net/go/mautrix v0.12.3 h1:pUeO1ThhtZxE6XibGCzDhRuxwDIFNugsreVr1yYq96k=
|
||||
maunium.net/go/mautrix v0.12.3/go.mod h1:uOUjkOjm2C+nQS3mr9B5ATjqemZfnPHvjdd1kZezAwg=
|
||||
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
|
||||
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
|
||||
maunium.net/go/mautrix v0.16.2 h1:a6GUJXNWsTEOO8VE4dROBfCIfPp50mqaqzv7KPzChvg=
|
||||
maunium.net/go/mautrix v0.16.2/go.mod h1:YL4l4rZB46/vj/ifRMEjcibbvHjgxHftOF1SgmruLu4=
|
||||
|
||||
56
justfile
Normal file
56
justfile
Normal file
@@ -0,0 +1,56 @@
|
||||
platforms := env_var_or_default("PLATFORMS", "linux/amd64")
|
||||
tag := if env_var_or_default("CI_COMMIT_TAG", "main") == "main" { "latest" } else { env_var_or_default("CI_COMMIT_TAG", "latest") }
|
||||
repo := trim_end_match(replace(replace_regex(env_var_or_default("CI_REPOSITORY_URL", `git remote get-url origin`), ".*@|", ""), ":", "/"), ".git")
|
||||
project := file_name(repo)
|
||||
gitlab_image := "registry." + repo + ":" + tag
|
||||
etke_image := replace(gitlab_image, "gitlab.com", "etke.cc")
|
||||
|
||||
# show help by default
|
||||
default:
|
||||
@just --list --justfile {{ justfile() }}
|
||||
|
||||
# update go deps
|
||||
update *flags:
|
||||
go get {{flags}} ./cmd
|
||||
go get gitlab.com/etke.cc/linkpearl@latest
|
||||
go mod tidy
|
||||
go mod vendor
|
||||
|
||||
# run linter
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# automatically fix liter issues
|
||||
lintfix:
|
||||
golangci-lint run --fix ./...
|
||||
|
||||
# generate mocks
|
||||
mocks:
|
||||
@mockery --all --inpackage --testonly --exclude vendor
|
||||
|
||||
# run cpu or mem profiler UI
|
||||
profile type:
|
||||
go tool pprof -http 127.0.0.1:8000 .pprof/{{ type }}.prof
|
||||
|
||||
# run unit tests
|
||||
test:
|
||||
@go test -cover -coverprofile=cover.out -coverpkg=./... -covermode=set ./...
|
||||
@go tool cover -func=cover.out
|
||||
-@rm -f cover.out
|
||||
|
||||
# run app
|
||||
run:
|
||||
@go run ./cmd
|
||||
|
||||
# build app
|
||||
build:
|
||||
go build -v -o {{ project }} ./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 --pull --provenance=false --platform {{ platforms }} --push -t {{ gitlab_image }} -t {{ etke_image }} .
|
||||
188
smtp/client.go
Normal file
188
smtp/client.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type MailSender interface {
|
||||
Send(from, to, data string) error
|
||||
}
|
||||
|
||||
// SMTP client
|
||||
type Client struct {
|
||||
config *RelayConfig
|
||||
log *zerolog.Logger
|
||||
}
|
||||
|
||||
var errNoSMTP = fmt.Errorf("cannot connect to any SMTP server")
|
||||
|
||||
func newClient(cfg *RelayConfig, log *zerolog.Logger) *Client {
|
||||
return &Client{
|
||||
config: cfg,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Send email
|
||||
func (c Client) Send(from, to, data string) error {
|
||||
log := c.log.With().Str("from", from).Str("to", to).Logger()
|
||||
log.Debug().Msg("sending email")
|
||||
|
||||
var conn *smtp.Client
|
||||
var err error
|
||||
if c.config.Host != "" {
|
||||
log.Debug().Msg("creating relay client...")
|
||||
conn, err = c.createRelayClient(from, to)
|
||||
} else {
|
||||
log.Debug().Msg("trying direct SMTP connection...")
|
||||
conn, err = c.createDirectClient(from, to)
|
||||
}
|
||||
|
||||
if conn == nil {
|
||||
log.Error().Err(err).Str("server_of", to).Msg("cannot connect to SMTP server")
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("server_of", to).Msg("connection to the SMTP server returned non-fatal error(-s)")
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var w io.WriteCloser
|
||||
w, err = conn.Data()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("cannot send DATA command")
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
log.Debug().Str("DATA", data).Msg("sending command")
|
||||
_, err = strings.NewReader(data).WriteTo(w)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("cannot write DATA")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().Msg("email has been sent")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createRelayClientconnects directly to the provided smtp host
|
||||
func (c *Client) createRelayClient(from, to string) (*smtp.Client, error) {
|
||||
localname := strings.SplitN(from, "@", 2)[1]
|
||||
target := c.config.Host + ":" + c.config.Port
|
||||
conn, err := smtp.Dial(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = conn.Hello(localname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ok, _ := conn.Extension("STARTTLS"); ok {
|
||||
config := &tls.Config{ServerName: c.config.Host} //nolint:gosec // it's smtp, even that is too strict sometimes
|
||||
conn.StartTLS(config) //nolint:errcheck // if it doesn't work - we can't do anything anyway
|
||||
}
|
||||
|
||||
if c.config.Usename != "" {
|
||||
err = conn.Auth(smtp.PlainAuth("", c.config.Usename, c.config.Password, c.config.Host))
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err = conn.Mail(from)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = conn.Rcpt(to)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *Client) createDirectClient(from, to string) (*smtp.Client, error) {
|
||||
localname := strings.SplitN(from, "@", 2)[1]
|
||||
hostname := strings.SplitN(to, "@", 2)[1]
|
||||
client, cerr := c.trySMTP(localname, hostname)
|
||||
if client == nil {
|
||||
c.log.Warn().Err(cerr).Str("from", from).Str("to", to).Msg("cannot create direct SMTP client")
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
err := client.Mail(from)
|
||||
if err != nil {
|
||||
c.log.Warn().Err(err).Str("from", from).Str("to", to).Msg("cannot send MAIL command")
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = client.Rcpt(to)
|
||||
if err != nil {
|
||||
c.log.Warn().Err(err).Str("from", from).Str("to", to).Msg("cannot send RCPT command")
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, cerr
|
||||
}
|
||||
|
||||
func (c *Client) trySMTP(localname, hostname string) (*smtp.Client, error) {
|
||||
mxs, err := net.LookupMX(hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var client *smtp.Client
|
||||
for _, mx := range mxs {
|
||||
if mx.Host == "." {
|
||||
continue // no records case
|
||||
}
|
||||
client = c.connect(localname, hostname, strings.TrimSuffix(mx.Host, "."))
|
||||
if client != nil {
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no MX records, according to https://datatracker.ietf.org/doc/html/rfc5321#section-5.1,
|
||||
// we're supposed to try talking directly to the host.
|
||||
client = c.connect(localname, hostname, hostname)
|
||||
if client != nil {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
return nil, errNoSMTP
|
||||
}
|
||||
|
||||
func (c *Client) connect(localname, serverOf, mxhost string) *smtp.Client {
|
||||
target := mxhost + ":25"
|
||||
conn, err := smtp.Dial(target)
|
||||
if err != nil {
|
||||
c.log.Warn().Err(err).Str("target", serverOf).Str("host", mxhost).Msg("cannot dial SMTP server")
|
||||
return nil
|
||||
}
|
||||
err = conn.Hello(localname)
|
||||
if err != nil {
|
||||
c.log.Warn().Err(err).Str("target", serverOf).Str("host", mxhost).Msg("cannot hello SMTP server")
|
||||
return nil
|
||||
}
|
||||
if ok, _ := conn.Extension("STARTTLS"); ok {
|
||||
config := &tls.Config{ServerName: mxhost} //nolint:gosec // it's smtp, even that is too strict sometimes
|
||||
conn.StartTLS(config) //nolint:errcheck // if it doesn't work - we can't do anything anyway
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
@@ -1,46 +1,83 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// Listener that rejects connections from banned hosts
|
||||
type Listener struct {
|
||||
log *logger.Logger
|
||||
log *zerolog.Logger
|
||||
done chan struct{}
|
||||
tls *tls.Config
|
||||
tlsMu sync.Mutex
|
||||
listener net.Listener
|
||||
isBanned func(net.Addr) bool
|
||||
}
|
||||
|
||||
func NewListener(actual net.Listener, isBanned func(net.Addr) bool, log *logger.Logger) *Listener {
|
||||
func NewListener(port string, tlsConfig *tls.Config, isBanned func(net.Addr) bool, log *zerolog.Logger) (*Listener, error) {
|
||||
actual, err := net.Listen("tcp", ":"+port)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Listener{
|
||||
log: log,
|
||||
done: make(chan struct{}, 1),
|
||||
tls: tlsConfig,
|
||||
listener: actual,
|
||||
isBanned: isBanned,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *Listener) SetTLSConfig(cfg *tls.Config) {
|
||||
l.tlsMu.Lock()
|
||||
l.tls = cfg
|
||||
l.tlsMu.Unlock()
|
||||
}
|
||||
|
||||
// Accept waits for and returns the next connection to the listener.
|
||||
func (l *Listener) Accept() (net.Conn, error) {
|
||||
conn, err := l.listener.Accept()
|
||||
if err != nil {
|
||||
return conn, err
|
||||
}
|
||||
if l.isBanned(conn.RemoteAddr()) {
|
||||
conn.Close()
|
||||
l.log.Info("rejected connection from %q (already banned)", conn.RemoteAddr())
|
||||
// Due to go-smtp design, any error returned here will crash whole server,
|
||||
// thus we have to forge a connection
|
||||
return &net.TCPConn{}, nil
|
||||
}
|
||||
for {
|
||||
conn, err := l.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-l.done:
|
||||
return conn, err
|
||||
default:
|
||||
l.log.Warn().Err(err).Msg("cannot accept connection")
|
||||
continue
|
||||
}
|
||||
}
|
||||
if l.isBanned(conn.RemoteAddr()) {
|
||||
conn.Close()
|
||||
l.log.Info().Str("addr", conn.RemoteAddr().String()).Msg("rejected connection (already banned)")
|
||||
continue
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
l.log.Info().Str("addr", conn.RemoteAddr().String()).Msg("accepted connection")
|
||||
|
||||
if l.tls != nil {
|
||||
return l.acceptTLS(conn)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Listener) acceptTLS(conn net.Conn) (net.Conn, error) {
|
||||
l.tlsMu.Lock()
|
||||
defer l.tlsMu.Unlock()
|
||||
|
||||
return tls.Server(conn, l.tls), nil
|
||||
}
|
||||
|
||||
// Close closes the listener.
|
||||
// Any blocked Accept operations will be unblocked and return errors.
|
||||
func (l *Listener) Close() error {
|
||||
close(l.done)
|
||||
return l.listener.Close()
|
||||
}
|
||||
|
||||
|
||||
44
smtp/logger.go
Normal file
44
smtp/logger.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// validatorLoggerWrapper is a wrapper around zerolog.Logger to implement validator.Logger interface
|
||||
type validatorLoggerWrapper struct {
|
||||
log *zerolog.Logger
|
||||
}
|
||||
|
||||
func (l validatorLoggerWrapper) Info(msg string, args ...any) {
|
||||
l.log.Info().Msgf(msg, args...)
|
||||
}
|
||||
|
||||
func (l validatorLoggerWrapper) Error(msg string, args ...any) {
|
||||
l.log.Error().Msgf(msg, args...)
|
||||
}
|
||||
|
||||
// loggerWrapper is a wrapper around any logger to implement smtp.Logger interface
|
||||
type loggerWrapper struct {
|
||||
log func(string, ...any)
|
||||
}
|
||||
|
||||
func (l loggerWrapper) Printf(format string, v ...any) {
|
||||
l.log(format, v...)
|
||||
}
|
||||
|
||||
func (l loggerWrapper) Println(v ...any) {
|
||||
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
|
||||
}
|
||||
168
smtp/manager.go
168
smtp/manager.go
@@ -4,14 +4,16 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/rs/zerolog"
|
||||
"gitlab.com/etke.cc/go/fswatcher"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -23,45 +25,72 @@ type Config struct {
|
||||
TLSPort string
|
||||
TLSRequired bool
|
||||
|
||||
LogLevel string
|
||||
MaxSize int
|
||||
Bot matrixbot
|
||||
Logger *zerolog.Logger
|
||||
MaxSize int
|
||||
Bot matrixbot
|
||||
Callers []Caller
|
||||
Relay *RelayConfig
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
Listener *Listener
|
||||
Config *tls.Config
|
||||
Certs []string
|
||||
Keys []string
|
||||
Port string
|
||||
Mu sync.Mutex
|
||||
}
|
||||
|
||||
type RelayConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Usename string
|
||||
Password string
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
log *logger.Logger
|
||||
log *zerolog.Logger
|
||||
bot matrixbot
|
||||
fsw *fswatcher.Watcher
|
||||
smtp *smtp.Server
|
||||
errs chan error
|
||||
|
||||
port string
|
||||
tlsPort string
|
||||
tlsCfg *tls.Config
|
||||
port string
|
||||
tls TLSConfig
|
||||
}
|
||||
|
||||
type matrixbot interface {
|
||||
AllowAuth(string, string) bool
|
||||
AllowAuth(string, string) (id.RoomID, bool)
|
||||
IsGreylisted(net.Addr) bool
|
||||
IsBanned(net.Addr) bool
|
||||
Ban(net.Addr)
|
||||
IsTrusted(net.Addr) bool
|
||||
BanAuto(net.Addr)
|
||||
BanAuth(net.Addr)
|
||||
GetMapping(string) (id.RoomID, bool)
|
||||
GetIFOptions(id.RoomID) utils.IncomingFilteringOptions
|
||||
IncomingEmail(context.Context, *utils.Email) error
|
||||
SetSendmail(func(string, string, string) error)
|
||||
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,
|
||||
log: cfg.Logger,
|
||||
bot: cfg.Bot,
|
||||
domains: cfg.Domains,
|
||||
sender: newClient(cfg.Relay, cfg.Logger),
|
||||
}
|
||||
for _, caller := range cfg.Callers {
|
||||
caller.SetSendmail(mailsrv.sender.Send)
|
||||
}
|
||||
cfg.Bot.SetSendmail(mailsrv.SendEmail)
|
||||
|
||||
s := smtp.NewServer(mailsrv)
|
||||
s.ErrorLog = loggerWrapper{func(s string, i ...any) { cfg.Logger.Error().Msgf(s, i...) }}
|
||||
s.ReadTimeout = 10 * time.Second
|
||||
s.WriteTimeout = 10 * time.Second
|
||||
s.MaxMessageBytes = cfg.MaxSize * 1024 * 1024
|
||||
@@ -72,18 +101,44 @@ func NewManager(cfg *Config) *Manager {
|
||||
if len(cfg.Domains) == 1 {
|
||||
s.Domain = cfg.Domains[0]
|
||||
}
|
||||
if log.GetLevel() == "DEBUG" || log.GetLevel() == "TRACE" {
|
||||
s.Debug = os.Stdout
|
||||
loglevel := cfg.Logger.GetLevel()
|
||||
if loglevel == zerolog.InfoLevel || loglevel == zerolog.DebugLevel || loglevel == zerolog.TraceLevel {
|
||||
s.Debug = loggerWriter{func(s string) { cfg.Logger.Info().Msg(s) }}
|
||||
}
|
||||
|
||||
fsw, err := fswatcher.New(append(cfg.TLSCerts, cfg.TLSKeys...), 0)
|
||||
if err != nil {
|
||||
cfg.Logger.Error().Err(err).Msg("cannot start FS watcher")
|
||||
}
|
||||
|
||||
m := &Manager{
|
||||
smtp: s,
|
||||
bot: cfg.Bot,
|
||||
log: log,
|
||||
port: cfg.Port,
|
||||
tlsPort: cfg.TLSPort,
|
||||
smtp: s,
|
||||
bot: cfg.Bot,
|
||||
log: cfg.Logger,
|
||||
fsw: fsw,
|
||||
port: cfg.Port,
|
||||
tls: TLSConfig{
|
||||
Certs: cfg.TLSCerts,
|
||||
Keys: cfg.TLSKeys,
|
||||
Port: cfg.TLSPort,
|
||||
},
|
||||
}
|
||||
|
||||
m.tls.Mu.Lock()
|
||||
m.loadTLSConfig()
|
||||
m.tls.Mu.Unlock()
|
||||
|
||||
if m.fsw != nil {
|
||||
go m.fsw.Start(func(_ fsnotify.Event) {
|
||||
m.tls.Mu.Lock()
|
||||
defer m.tls.Mu.Unlock()
|
||||
|
||||
ok := m.loadTLSConfig()
|
||||
if ok {
|
||||
m.tls.Listener.SetTLSConfig(m.tls.Config)
|
||||
}
|
||||
})
|
||||
}
|
||||
m.loadTLSConfig(cfg.TLSCerts, cfg.TLSKeys)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -91,8 +146,8 @@ func NewManager(cfg *Config) *Manager {
|
||||
func (m *Manager) Start() error {
|
||||
m.errs = make(chan error, 1)
|
||||
go m.listen(m.port, nil)
|
||||
if m.tlsCfg != nil {
|
||||
go m.listen(m.tlsPort, m.tlsCfg)
|
||||
if m.tls.Config != nil {
|
||||
go m.listen(m.tls.Port, m.tls.Config)
|
||||
}
|
||||
|
||||
return <-m.errs
|
||||
@@ -100,56 +155,61 @@ func (m *Manager) Start() error {
|
||||
|
||||
// Stop SMTP server
|
||||
func (m *Manager) Stop() {
|
||||
err := m.smtp.Close()
|
||||
err := m.fsw.Stop()
|
||||
if err != nil {
|
||||
m.log.Error("cannot stop SMTP server properly: %v", err)
|
||||
m.log.Error().Err(err).Msg("cannot stop filesystem watcher properly")
|
||||
}
|
||||
m.log.Info("SMTP server has been stopped")
|
||||
|
||||
err = m.smtp.Close()
|
||||
if err != nil {
|
||||
m.log.Error().Err(err).Msg("cannot stop SMTP server properly")
|
||||
}
|
||||
|
||||
m.log.Info().Msg("SMTP server has been stopped")
|
||||
}
|
||||
|
||||
func (m *Manager) listen(port string, 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)
|
||||
}
|
||||
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.log.Error().Err(err).Str("port", port).Msg("cannot start listener")
|
||||
m.errs <- err
|
||||
return
|
||||
}
|
||||
lwrapper := NewListener(l, m.bot.IsBanned, m.log)
|
||||
m.log.Info("Starting SMTP server on port %s", port)
|
||||
if tlsConfig != nil {
|
||||
m.tls.Listener = lwrapper
|
||||
}
|
||||
m.log.Info().Str("port", port).Msg("Starting SMTP server")
|
||||
|
||||
err = m.smtp.Serve(lwrapper)
|
||||
if err != nil {
|
||||
m.log.Error("cannot start SMTP server on %s: %v", port, err)
|
||||
m.log.Error().Str("port", port).Err(err).Msg("cannot start SMTP server")
|
||||
m.errs <- err
|
||||
close(m.errs)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) loadTLSConfig(certs, keys []string) {
|
||||
if len(certs) == 0 || len(keys) == 0 {
|
||||
m.log.Warn("SSL certificates are not provided")
|
||||
return
|
||||
// loadTLSConfig returns true if certs were loaded and false if not
|
||||
func (m *Manager) loadTLSConfig() bool {
|
||||
m.log.Info().Msg("(re)loading TLS config")
|
||||
if len(m.tls.Certs) == 0 || len(m.tls.Keys) == 0 {
|
||||
m.log.Warn().Msg("SSL certificates are not provided")
|
||||
return false
|
||||
}
|
||||
|
||||
certificates := make([]tls.Certificate, 0, len(certs))
|
||||
for i, path := range certs {
|
||||
tlsCert, err := tls.LoadX509KeyPair(path, keys[i])
|
||||
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)
|
||||
m.log.Error().Err(err).Msg("cannot load SSL certificate")
|
||||
continue
|
||||
}
|
||||
certificates = append(certificates, tlsCert)
|
||||
}
|
||||
if len(certificates) == 0 {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
m.tlsCfg = &tls.Config{Certificates: certificates}
|
||||
m.smtp.TLSConfig = m.tlsCfg
|
||||
m.tls.Config = &tls.Config{Certificates: certificates} //nolint:gosec // it's email, even that config is too strict sometimes
|
||||
m.smtp.TLSConfig = m.tls.Config
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2,70 +2,83 @@ package smtp
|
||||
|
||||
import (
|
||||
"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"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
)
|
||||
|
||||
const (
|
||||
// NoUserCode SMTP code
|
||||
NoUserCode = 550
|
||||
// BannedCode SMTP code
|
||||
BannedCode = 554
|
||||
)
|
||||
|
||||
var (
|
||||
// NoUserEnhancedCode enhanced SMTP code
|
||||
NoUserEnhancedCode = smtp.EnhancedCode{5, 5, 0}
|
||||
// BannedEnhancedCode enhanced SMTP code
|
||||
BannedEnhancedCode = smtp.EnhancedCode{5, 5, 4}
|
||||
// ErrBanned returned to banned hosts
|
||||
ErrBanned = &smtp.SMTPError{
|
||||
Code: 554,
|
||||
EnhancedCode: smtp.EnhancedCode{5, 5, 4},
|
||||
Code: BannedCode,
|
||||
EnhancedCode: BannedEnhancedCode,
|
||||
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},
|
||||
Code: NoUserCode,
|
||||
EnhancedCode: NoUserEnhancedCode,
|
||||
Message: "no such user here, kupo.",
|
||||
}
|
||||
)
|
||||
|
||||
type mailServer struct {
|
||||
bot matrixbot
|
||||
log *logger.Logger
|
||||
log *zerolog.Logger
|
||||
domains []string
|
||||
sender MailSender
|
||||
}
|
||||
|
||||
// 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)
|
||||
m.log.Debug().Str("username", username).Any("state", state).Msg("Login")
|
||||
if m.bot.IsBanned(state.RemoteAddr) {
|
||||
return nil, ErrBanned
|
||||
}
|
||||
|
||||
if !utils.AddressValid(username) {
|
||||
m.log.Debug("address %s is invalid", username)
|
||||
m.bot.Ban(state.RemoteAddr)
|
||||
if !email.AddressValid(username) {
|
||||
m.log.Debug().Str("address", username).Msg("address is invalid")
|
||||
m.bot.BanAuth(state.RemoteAddr)
|
||||
return nil, ErrBanned
|
||||
}
|
||||
|
||||
if !m.bot.AllowAuth(username, password) {
|
||||
m.log.Debug("username=%s or password=<redacted> is invalid", username)
|
||||
m.bot.Ban(state.RemoteAddr)
|
||||
roomID, allow := m.bot.AllowAuth(username, password)
|
||||
if !allow {
|
||||
m.log.Debug().Str("username", username).Msg("username or password is invalid")
|
||||
m.bot.BanAuth(state.RemoteAddr)
|
||||
return nil, ErrBanned
|
||||
}
|
||||
|
||||
return &outgoingSession{
|
||||
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
|
||||
sendmail: m.SendEmail,
|
||||
privkey: m.bot.GetDKIMprivkey(),
|
||||
from: username,
|
||||
log: m.log,
|
||||
domains: m.domains,
|
||||
ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()),
|
||||
sendmail: m.sender.Send,
|
||||
privkey: m.bot.GetDKIMprivkey(),
|
||||
from: username,
|
||||
log: m.log,
|
||||
domains: m.domains,
|
||||
getRoomID: m.bot.GetMapping,
|
||||
fromRoom: roomID,
|
||||
tos: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AnonymousLogin used for incoming mail submissions only
|
||||
func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||
m.log.Debug("AnonymousLogin state=%+v", state)
|
||||
m.log.Debug().Any("state", state).Msg("AnonymousLogin")
|
||||
if m.bot.IsBanned(state.RemoteAddr) {
|
||||
return nil, ErrBanned
|
||||
}
|
||||
@@ -75,43 +88,17 @@ func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session,
|
||||
getRoomID: m.bot.GetMapping,
|
||||
getFilters: m.bot.GetIFOptions,
|
||||
receiveEmail: m.ReceiveEmail,
|
||||
ban: m.bot.Ban,
|
||||
ban: m.bot.BanAuto,
|
||||
greylisted: m.bot.IsGreylisted,
|
||||
trusted: m.bot.IsTrusted,
|
||||
log: m.log,
|
||||
domains: m.domains,
|
||||
addr: state.RemoteAddr,
|
||||
tos: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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 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
|
||||
}
|
||||
|
||||
// ReceiveEmail - incoming mail into matrix room
|
||||
func (m *mailServer) ReceiveEmail(ctx context.Context, email *utils.Email) error {
|
||||
return m.bot.IncomingEmail(ctx, email)
|
||||
func (m *mailServer) ReceiveEmail(ctx context.Context, eml *email.Email) error {
|
||||
return m.bot.IncomingEmail(ctx, eml)
|
||||
}
|
||||
|
||||
249
smtp/session.go
249
smtp/session.go
@@ -1,185 +1,260 @@
|
||||
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"
|
||||
"github.com/rs/zerolog"
|
||||
"gitlab.com/etke.cc/go/validator"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
)
|
||||
|
||||
// GraylistCode SMTP code
|
||||
const GraylistCode = 451
|
||||
|
||||
var (
|
||||
// ErrInvalidEmail for invalid emails :)
|
||||
ErrInvalidEmail = errors.New("please, provide valid email address")
|
||||
// GraylistEnhancedCode is GraylistCode in enhanced code notation
|
||||
GraylistEnhancedCode = smtp.EnhancedCode{4, 5, 1}
|
||||
)
|
||||
|
||||
// incomingSession represents an SMTP-submission session receiving emails from remote servers
|
||||
type incomingSession struct {
|
||||
log *logger.Logger
|
||||
log *zerolog.Logger
|
||||
getRoomID func(string) (id.RoomID, bool)
|
||||
getFilters func(id.RoomID) utils.IncomingFilteringOptions
|
||||
receiveEmail func(context.Context, *utils.Email) error
|
||||
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
|
||||
ctx context.Context //nolint:containedctx // that's session
|
||||
addr net.Addr
|
||||
to string
|
||||
tos []string
|
||||
from string
|
||||
}
|
||||
|
||||
func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error {
|
||||
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
|
||||
if !utils.AddressValid(from) {
|
||||
s.log.Debug("address %s is invalid", from)
|
||||
if !email.AddressValid(from) {
|
||||
s.log.Debug().Str("from", from).Msg("address is invalid")
|
||||
s.ban(s.addr)
|
||||
return ErrBanned
|
||||
}
|
||||
s.from = from
|
||||
s.log.Debug("mail from %s, options: %+v", from, opts)
|
||||
s.from = email.Address(from)
|
||||
s.log.Debug().Str("from", from).Any("options", opts).Msg("incoming mail")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *incomingSession) Rcpt(to string) error {
|
||||
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
|
||||
s.to = to
|
||||
s.tos = append(s.tos, to)
|
||||
hostname := utils.Hostname(to)
|
||||
var domainok bool
|
||||
for _, domain := range s.domains {
|
||||
if utils.Hostname(to) == domain {
|
||||
if hostname == domain {
|
||||
domainok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !domainok {
|
||||
s.log.Debug("wrong domain of %s", to)
|
||||
s.log.Debug().Str("to", to).Msg("wrong domain")
|
||||
return ErrNoUser
|
||||
}
|
||||
|
||||
roomID, ok := s.getRoomID(utils.Mailbox(to))
|
||||
var ok bool
|
||||
s.roomID, ok = s.getRoomID(utils.Mailbox(to))
|
||||
if !ok {
|
||||
s.log.Debug("mapping for %s not found", to)
|
||||
s.log.Debug().Str("to", to).Msg("mapping not found")
|
||||
return ErrNoUser
|
||||
}
|
||||
|
||||
validations := s.getFilters(roomID)
|
||||
if !validateEmail(s.from, s.to, s.log, validations) {
|
||||
s.ban(s.addr)
|
||||
return ErrBanned
|
||||
}
|
||||
|
||||
s.log.Debug("mail to %s", to)
|
||||
s.log.Debug().Str("to", to).Msg("mail")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *incomingSession) Data(r io.Reader) error {
|
||||
if s.greylisted(s.addr) {
|
||||
return &smtp.SMTPError{
|
||||
Code: 451,
|
||||
EnhancedCode: smtp.EnhancedCode{4, 5, 1},
|
||||
Message: "You have been greylisted, try again a bit later.",
|
||||
}
|
||||
// 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 // it is real addr
|
||||
if host == "" {
|
||||
return s.addr
|
||||
}
|
||||
|
||||
var port int
|
||||
port, _ = strconv.Atoi(portString) //nolint:errcheck // it's a real addr
|
||||
|
||||
realAddr := &net.TCPAddr{IP: net.ParseIP(host), Port: port}
|
||||
s.log.Info().Str("addr", realAddr.String()).Msg("real address")
|
||||
return realAddr
|
||||
}
|
||||
|
||||
func (s *incomingSession) Data(r io.Reader) error {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("cannot read DATA")
|
||||
return err
|
||||
}
|
||||
reader := bytes.NewReader(data)
|
||||
parser := enmime.NewParser()
|
||||
eml, err := parser.ReadEnvelope(r)
|
||||
envelope, err := parser.ReadEnvelope(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr := s.getAddr(envelope)
|
||||
reader.Seek(0, io.SeekStart) //nolint:errcheck // becase we're sure that's ok
|
||||
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: GraylistCode,
|
||||
EnhancedCode: GraylistEnhancedCode,
|
||||
Message: "You have been greylisted, try again a bit later.",
|
||||
}
|
||||
}
|
||||
if validations.SpamcheckDKIM() {
|
||||
results, verr := dkim.Verify(reader)
|
||||
if verr != nil {
|
||||
s.log.Error().Err(verr).Msg("cannot verify DKIM")
|
||||
return verr
|
||||
}
|
||||
for _, result := range results {
|
||||
if result.Err != nil {
|
||||
s.log.Info().Str("domain", result.Domain).Err(result.Err).Msg("DKIM verification failed")
|
||||
return result.Err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files := parseAttachments(eml.Attachments, s.log)
|
||||
|
||||
email := utils.NewEmail(
|
||||
eml.GetHeader("Message-Id"),
|
||||
eml.GetHeader("In-Reply-To"),
|
||||
eml.GetHeader("References"),
|
||||
eml.GetHeader("Subject"),
|
||||
s.from,
|
||||
s.to,
|
||||
eml.Text,
|
||||
eml.HTML,
|
||||
files)
|
||||
|
||||
return s.receiveEmail(s.ctx, email)
|
||||
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
|
||||
log *zerolog.Logger
|
||||
sendmail func(string, string, string) error
|
||||
privkey string
|
||||
domains []string
|
||||
getRoomID func(string) (id.RoomID, bool)
|
||||
|
||||
ctx context.Context
|
||||
to string
|
||||
from string
|
||||
ctx context.Context //nolint:containedctx // that's session
|
||||
tos []string
|
||||
from string
|
||||
fromRoom id.RoomID
|
||||
}
|
||||
|
||||
func (s *outgoingSession) Mail(from string, opts smtp.MailOptions) error {
|
||||
func (s *outgoingSession) Mail(from string, _ smtp.MailOptions) error {
|
||||
sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from)
|
||||
if !utils.AddressValid(from) {
|
||||
return errors.New("please, provide email address")
|
||||
if !email.AddressValid(from) {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
hostname := utils.Hostname(from)
|
||||
var domainok bool
|
||||
for _, domain := range s.domains {
|
||||
if hostname == domain {
|
||||
domainok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !domainok {
|
||||
s.log.Debug().Str("from", from).Msg("wrong domain")
|
||||
return ErrNoUser
|
||||
}
|
||||
|
||||
roomID, ok := s.getRoomID(utils.Mailbox(from))
|
||||
if !ok {
|
||||
s.log.Debug().Str("from", from).Msg("mapping not found")
|
||||
return ErrNoUser
|
||||
}
|
||||
if s.fromRoom != roomID {
|
||||
s.log.Warn().Str("from_roomID", s.fromRoom.String()).Str("roomID", roomID.String()).Msg("sender from different room tries to impersonate another mailbox")
|
||||
return ErrNoUser
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *outgoingSession) Rcpt(to string) error {
|
||||
sentry.GetHubFromContext(s.ctx).Scope().SetTag("to", to)
|
||||
s.to = to
|
||||
s.tos = append(s.tos, to)
|
||||
|
||||
s.log.Debug("mail to %s", to)
|
||||
s.log.Debug().Str("to", to).Msg("mail")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *outgoingSession) Data(r io.Reader) error {
|
||||
parser := enmime.NewParser()
|
||||
eml, err := parser.ReadEnvelope(r)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
files := parseAttachments(eml.Attachments, s.log)
|
||||
|
||||
email := utils.NewEmail(
|
||||
eml.GetHeader("Message-Id"),
|
||||
eml.GetHeader("In-Reply-To"),
|
||||
eml.GetHeader("References"),
|
||||
eml.GetHeader("Subject"),
|
||||
s.from,
|
||||
s.to,
|
||||
eml.Text,
|
||||
eml.HTML,
|
||||
files)
|
||||
|
||||
return s.sendmail(email.From, email.To, email.Compose(s.privkey))
|
||||
return nil
|
||||
}
|
||||
func (s *outgoingSession) Reset() {}
|
||||
func (s *outgoingSession) Logout() error { return nil }
|
||||
|
||||
func validateEmail(from, to string, log *logger.Logger, options utils.IncomingFilteringOptions) bool {
|
||||
func validateIncoming(from, to string, senderAddr net.Addr, log *zerolog.Logger, options email.IncomingFilteringOptions) bool {
|
||||
var sender net.IP
|
||||
switch netaddr := senderAddr.(type) {
|
||||
case *net.TCPAddr:
|
||||
sender = netaddr.IP
|
||||
default:
|
||||
host, _, _ := net.SplitHostPort(senderAddr.String()) //nolint:errcheck // interface constraints
|
||||
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)
|
||||
v := validator.New(options.Spamlist(), enforce, to, &validatorLoggerWrapper{log: log})
|
||||
|
||||
return v.Email(from)
|
||||
}
|
||||
|
||||
func parseAttachments(parts []*enmime.Part, log *logger.Logger) []*utils.File {
|
||||
files := make([]*utils.File, 0, len(parts))
|
||||
for _, attachment := range parts {
|
||||
for _, err := range attachment.Errors {
|
||||
log.Warn("attachment error: %v", err)
|
||||
}
|
||||
file := utils.NewFile(attachment.FileName, attachment.Content)
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files
|
||||
return v.Email(from, sender)
|
||||
}
|
||||
|
||||
@@ -5,21 +5,24 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MinSendCommandParts is minimal count of space-separated parts for !pm send command
|
||||
const MinSendCommandParts = 3
|
||||
|
||||
// ErrInvalidArgs returned when a command's arguments are invalid
|
||||
var ErrInvalidArgs = fmt.Errorf("invalid arguments")
|
||||
|
||||
// ParseSend parses "!pm send" command, returns to, subject, body, err
|
||||
func ParseSend(commandSlice []string) (string, string, string, error) {
|
||||
func ParseSend(commandSlice []string) (to, subject, body string, err error) {
|
||||
message := strings.Join(commandSlice, " ")
|
||||
lines := strings.Split(message, "\n")
|
||||
if len(lines) < 3 {
|
||||
if len(lines) < MinSendCommandParts {
|
||||
return "", "", "", ErrInvalidArgs
|
||||
}
|
||||
|
||||
commandSlice = strings.Split(lines[0], " ")
|
||||
to := commandSlice[1]
|
||||
subject := lines[1]
|
||||
body := strings.Join(lines[2:], "\n")
|
||||
to = commandSlice[1]
|
||||
subject = lines[1]
|
||||
body = strings.Join(lines[2:], "\n")
|
||||
|
||||
return to, subject, body, nil
|
||||
}
|
||||
|
||||
213
utils/email.go
213
utils/email.go
@@ -1,213 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// IncomingFilteringOptions for incoming mail
|
||||
type IncomingFilteringOptions interface {
|
||||
SpamcheckSMTP() bool
|
||||
SpamcheckMX() bool
|
||||
Spamlist() []string
|
||||
}
|
||||
|
||||
// Email object
|
||||
type Email struct {
|
||||
Date string
|
||||
MessageID string
|
||||
InReplyTo string
|
||||
References 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
|
||||
ReferencesKey string
|
||||
SubjectKey string
|
||||
FromKey string
|
||||
ToKey string
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// NewEmail constructs Email object
|
||||
func NewEmail(messageID, inReplyTo, references, subject, from, to, text, html string, files []*File) *Email {
|
||||
email := &Email{
|
||||
Date: time.Now().UTC().Format(time.RFC1123Z),
|
||||
MessageID: messageID,
|
||||
InReplyTo: inReplyTo,
|
||||
References: references,
|
||||
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(e.From)
|
||||
}
|
||||
if options.Recipient {
|
||||
text.WriteString(" ➡️ ")
|
||||
text.WriteString(e.To)
|
||||
}
|
||||
if options.Sender || options.Recipient {
|
||||
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 = RelatesTo(options.Threads, threadID)
|
||||
|
||||
content := event.Content{
|
||||
Raw: map[string]interface{}{
|
||||
options.MessageIDKey: e.MessageID,
|
||||
options.InReplyToKey: e.InReplyTo,
|
||||
options.ReferencesKey: e.References,
|
||||
options.SubjectKey: e.Subject,
|
||||
options.FromKey: e.From,
|
||||
options.ToKey: e.To,
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
||||
root, err := mail.Build()
|
||||
if err != nil {
|
||||
log.Error("cannot compose email: %v", err)
|
||||
return ""
|
||||
}
|
||||
var data strings.Builder
|
||||
err = root.Encode(&data)
|
||||
if err != nil {
|
||||
log.Error("cannot encode email: %v", err)
|
||||
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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
76
utils/mail.go
Normal file
76
utils/mail.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mcnijman/go-emailaddress"
|
||||
)
|
||||
|
||||
// Mailbox returns mailbox part from email address
|
||||
func Mailbox(email string) string {
|
||||
mailbox, _, _ := EmailParts(email)
|
||||
return mailbox
|
||||
}
|
||||
|
||||
// Subaddress returns sub address part form email address
|
||||
func Subaddress(email string) string {
|
||||
_, sub, _ := EmailParts(email)
|
||||
return sub
|
||||
}
|
||||
|
||||
// Hostname returns hostname part from email address
|
||||
func Hostname(email string) string {
|
||||
_, _, hostname := EmailParts(email)
|
||||
return hostname
|
||||
}
|
||||
|
||||
// EmailParts parses email address into mailbox, subaddress, and hostname
|
||||
func EmailParts(email string) (mailbox, sub, hostname string) {
|
||||
address, err := emailaddress.Parse(email)
|
||||
if err == nil {
|
||||
mailbox = address.LocalPart
|
||||
hostname = address.Domain
|
||||
} else {
|
||||
mailbox = email
|
||||
hostname = email
|
||||
mIdx := strings.Index(email, "@")
|
||||
hIdx := strings.LastIndex(email, "@")
|
||||
if mIdx != -1 {
|
||||
mailbox = email[:mIdx]
|
||||
}
|
||||
if hIdx != -1 {
|
||||
hostname = email[hIdx+1:]
|
||||
}
|
||||
}
|
||||
|
||||
idx := strings.Index(mailbox, "+")
|
||||
if idx != -1 {
|
||||
sub = strings.ReplaceAll(mailbox[idx:], "+", "")
|
||||
mailbox = strings.ReplaceAll(mailbox[:idx], "+", "")
|
||||
}
|
||||
return mailbox, sub, hostname
|
||||
}
|
||||
|
||||
// EmailsList returns human-readable list of mailbox's emails for all available domains
|
||||
func EmailsList(mailbox, domain string) string {
|
||||
var msg strings.Builder
|
||||
domain = SanitizeDomain(domain)
|
||||
msg.WriteString(mailbox)
|
||||
msg.WriteString("@")
|
||||
msg.WriteString(domain)
|
||||
|
||||
count := len(domains) - 1
|
||||
for i, aliasDomain := range domains {
|
||||
if i < count {
|
||||
msg.WriteString(", ")
|
||||
}
|
||||
if aliasDomain == domain {
|
||||
continue
|
||||
}
|
||||
msg.WriteString(mailbox)
|
||||
msg.WriteString("@")
|
||||
msg.WriteString(aliasDomain)
|
||||
}
|
||||
|
||||
return msg.String()
|
||||
}
|
||||
70
utils/mail_test.go
Normal file
70
utils/mail_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMailbox(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"mailbox@example.com": "mailbox",
|
||||
"mail-box@example.com": "mail-box",
|
||||
"mailbox": "mailbox",
|
||||
"mail@box@example.com": "mail",
|
||||
"mailbox+@example.com": "mailbox",
|
||||
"mailbox+sub@example.com": "mailbox",
|
||||
"mailbox+++sub@example.com": "mailbox",
|
||||
}
|
||||
|
||||
for in, expected := range tests {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
output := Mailbox(in)
|
||||
if output != expected {
|
||||
t.Error(expected, "!=", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubaddress(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"mailbox@example@example.com": "",
|
||||
"mail-box@example.com": "",
|
||||
"mailbox+": "",
|
||||
"mailbox+sub@example.com": "sub",
|
||||
"mailbox+++sub@example.com": "sub",
|
||||
}
|
||||
|
||||
for in, expected := range tests {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
output := Subaddress(in)
|
||||
if output != expected {
|
||||
t.Error(expected, "!=", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostname(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"mailbox@example.com": "example.com",
|
||||
"mailbox": "mailbox",
|
||||
"mail@box@example.com": "example.com",
|
||||
}
|
||||
|
||||
for in, expected := range tests {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
output := Hostname(in)
|
||||
if output != expected {
|
||||
t.Error(expected, "!=", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailList(t *testing.T) {
|
||||
domains = []string{"example.com", "example.org"}
|
||||
expected := "test@example.org, test@example.com"
|
||||
|
||||
actual := EmailsList("test", "example.org")
|
||||
if actual != expected {
|
||||
t.Error(expected, "!=", actual)
|
||||
}
|
||||
}
|
||||
102
utils/matrix.go
102
utils/matrix.go
@@ -1,102 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// RelatesTo returns relation object of a matrix event (either threads or reply-to)
|
||||
func RelatesTo(threads bool, parentID id.EventID) *event.RelatesTo {
|
||||
if parentID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if threads {
|
||||
return &event.RelatesTo{
|
||||
Type: event.RelThread,
|
||||
EventID: parentID,
|
||||
}
|
||||
}
|
||||
|
||||
return &event.RelatesTo{
|
||||
InReplyTo: &event.InReplyTo{
|
||||
EventID: parentID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EventParent returns parent event ID (either from thread or from reply-to relation)
|
||||
func EventParent(currentID id.EventID, content *event.MessageEventContent) id.EventID {
|
||||
if content == nil {
|
||||
return currentID
|
||||
}
|
||||
|
||||
if content.GetRelatesTo() == nil {
|
||||
return currentID
|
||||
}
|
||||
|
||||
threadParent := content.RelatesTo.GetThreadParent()
|
||||
if threadParent != "" {
|
||||
return threadParent
|
||||
}
|
||||
|
||||
replyParent := content.RelatesTo.GetReplyTo()
|
||||
if replyParent != "" {
|
||||
return replyParent
|
||||
}
|
||||
|
||||
return currentID
|
||||
}
|
||||
|
||||
// EventField returns field value from raw event content
|
||||
func EventField[T comparable](content *event.Content, field string) T {
|
||||
var zero T
|
||||
raw := content.Raw[field]
|
||||
if raw == nil {
|
||||
return zero
|
||||
}
|
||||
|
||||
v, ok := raw.(T)
|
||||
if !ok {
|
||||
return zero
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
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) {
|
||||
case nil:
|
||||
return nil
|
||||
case mautrix.HTTPError:
|
||||
return unwrapHTTPError(err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func unwrapHTTPError(err error) error {
|
||||
httperr, ok := err.(mautrix.HTTPError)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
uwerr := httperr.Unwrap()
|
||||
if uwerr != nil {
|
||||
return uwerr
|
||||
}
|
||||
|
||||
return httperr
|
||||
}
|
||||
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)
|
||||
}
|
||||
121
utils/utils.go
121
utils/utils.go
@@ -1,63 +1,27 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/etke.cc/go/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
log *logger.Logger
|
||||
domains []string
|
||||
)
|
||||
|
||||
// SetLogger for utils
|
||||
func SetLogger(loggerInstance *logger.Logger) {
|
||||
log = loggerInstance
|
||||
}
|
||||
var domains []string
|
||||
|
||||
// SetDomains for later use
|
||||
func SetDomains(slice []string) {
|
||||
domains = slice
|
||||
}
|
||||
|
||||
// Mailbox returns mailbox part from email address
|
||||
func Mailbox(email string) string {
|
||||
index := strings.LastIndex(email, "@")
|
||||
if index == -1 {
|
||||
return email
|
||||
// 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 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:]
|
||||
return key
|
||||
}
|
||||
|
||||
// SanitizeDomain checks that input domain is available for use
|
||||
@@ -105,11 +69,38 @@ func Int(str string) int {
|
||||
return i
|
||||
}
|
||||
|
||||
// Int64 converts string into int64
|
||||
func Int64(str string) int64 {
|
||||
if str == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
i, err := strconv.ParseInt(str, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// SanitizeBoolString converts string to integer and back to string
|
||||
func SanitizeIntString(str string) string {
|
||||
return strconv.Itoa(Int(str))
|
||||
}
|
||||
|
||||
// SliceString converts slice into comma-separated string
|
||||
func SliceString(strs []string) string {
|
||||
res := []string{}
|
||||
for _, str := range strs {
|
||||
str = strings.TrimSpace(str)
|
||||
if str == "" {
|
||||
continue
|
||||
}
|
||||
res = append(res, str)
|
||||
}
|
||||
sort.Strings(res)
|
||||
return strings.Join(res, ",")
|
||||
}
|
||||
|
||||
// StringSlice converts comma-separated string to slice
|
||||
func StringSlice(str string) []string {
|
||||
if str == "" {
|
||||
@@ -121,19 +112,35 @@ func StringSlice(str string) []string {
|
||||
return []string{str}
|
||||
}
|
||||
|
||||
return strings.Split(str, ",")
|
||||
}
|
||||
|
||||
// SanitizeBoolString converts string to slice and back to string
|
||||
func SanitizeStringSlice(str string) string {
|
||||
parts := StringSlice(str)
|
||||
if len(parts) == 0 {
|
||||
return str
|
||||
}
|
||||
|
||||
parts := strings.Split(str, ",")
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.TrimSpace(part)
|
||||
}
|
||||
|
||||
return strings.Join(parts, ",")
|
||||
return parts
|
||||
}
|
||||
|
||||
// SanitizeBoolString converts string to slice and back to string
|
||||
func SanitizeStringSlice(str string) string {
|
||||
return SliceString(StringSlice(str))
|
||||
}
|
||||
|
||||
// MapKeys returns sorted keys of the map
|
||||
func MapKeys[V any](data map[string]V) []string {
|
||||
keys := make([]string, 0, len(data))
|
||||
for k := range data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// Chunks divides slice by chunks with specified size
|
||||
func Chunks[T any](slice []T, chunkSize int) [][]T {
|
||||
chunks := make([][]T, 0, (len(slice)+chunkSize-1)/chunkSize)
|
||||
|
||||
for chunkSize < len(slice) {
|
||||
slice, chunks = slice[chunkSize:], append(chunks, slice[0:chunkSize:chunkSize])
|
||||
}
|
||||
return append(chunks, slice)
|
||||
}
|
||||
|
||||
10
vendor/blitiri.com.ar/go/spf/.gitignore
generated
vendored
Normal file
10
vendor/blitiri.com.ar/go/spf/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Ignore anything beginning with a dot: these are usually temporary or
|
||||
# unimportant.
|
||||
.*
|
||||
|
||||
# Exceptions to the rule above: files we care about that would otherwise be
|
||||
# excluded.
|
||||
!.gitignore
|
||||
|
||||
# go-fuzz build artifacts.
|
||||
*-fuzz.zip
|
||||
27
vendor/blitiri.com.ar/go/spf/LICENSE
generated
vendored
Normal file
27
vendor/blitiri.com.ar/go/spf/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
Licensed under the MIT licence, which is reproduced below (from
|
||||
https://opensource.org/licenses/MIT).
|
||||
|
||||
-----
|
||||
|
||||
Copyright (c) 2016
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
49
vendor/blitiri.com.ar/go/spf/README.md
generated
vendored
Normal file
49
vendor/blitiri.com.ar/go/spf/README.md
generated
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
# blitiri.com.ar/go/spf
|
||||
|
||||
[](https://pkg.go.dev/blitiri.com.ar/go/spf)
|
||||
[](https://gitlab.com/albertito/spf/-/pipelines)
|
||||
[](https://goreportcard.com/report/github.com/albertito/spf)
|
||||
[](https://coveralls.io/github/albertito/spf)
|
||||
|
||||
[spf](https://godoc.org/blitiri.com.ar/go/spf) is an open source
|
||||
implementation of the [Sender Policy Framework
|
||||
(SPF)](https://en.wikipedia.org/wiki/Sender_Policy_Framework) in Go.
|
||||
|
||||
It is used by the [chasquid](https://blitiri.com.ar/p/chasquid/) and
|
||||
[maddy](https://maddy.email) SMTP servers.
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
```go
|
||||
// Check if `sender` is authorized to send from the given `ip`. The `domain`
|
||||
// is used if the sender doesn't have one.
|
||||
result, err := spf.CheckHostWithSender(ip, domain, sender)
|
||||
if result == spf.Fail {
|
||||
// Not authorized to send.
|
||||
}
|
||||
```
|
||||
|
||||
See the [package documentation](https://pkg.go.dev/blitiri.com.ar/go/spf) for
|
||||
more details.
|
||||
|
||||
|
||||
## Status
|
||||
|
||||
All SPF mechanisms, modifiers, and macros are supported.
|
||||
|
||||
The API should be considered stable. Major version changes will be announced
|
||||
to the mailing list (details below).
|
||||
|
||||
|
||||
## Contact
|
||||
|
||||
If you have any questions, comments or patches please send them to the mailing
|
||||
list, `chasquid@googlegroups.com`.
|
||||
|
||||
To subscribe, send an email to `chasquid+subscribe@googlegroups.com`.
|
||||
|
||||
You can also browse the
|
||||
[archives](https://groups.google.com/forum/#!forum/chasquid).
|
||||
|
||||
58
vendor/blitiri.com.ar/go/spf/fuzz.go
generated
vendored
Normal file
58
vendor/blitiri.com.ar/go/spf/fuzz.go
generated
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
// Fuzz testing for package spf.
|
||||
//
|
||||
// Run it with:
|
||||
//
|
||||
// go-fuzz-build blitiri.com.ar/go/spf
|
||||
// go-fuzz -bin=./spf-fuzz.zip -workdir=testdata/fuzz
|
||||
//
|
||||
|
||||
//go:build gofuzz
|
||||
// +build gofuzz
|
||||
|
||||
package spf
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"blitiri.com.ar/go/spf/internal/dnstest"
|
||||
)
|
||||
|
||||
// Parsed IP addresses, for convenience.
|
||||
var (
|
||||
ip1110 = net.ParseIP("1.1.1.0")
|
||||
ip1111 = net.ParseIP("1.1.1.1")
|
||||
ip6666 = net.ParseIP("2001:db8::68")
|
||||
ip6660 = net.ParseIP("2001:db8::0")
|
||||
)
|
||||
|
||||
// DNS resolver to use. Will be initialized once with the expected fixtures,
|
||||
// and then reused on each fuzz run.
|
||||
var dns = dnstest.NewResolver()
|
||||
|
||||
func init() {
|
||||
dns.Ip["d1111"] = []net.IP{ip1111}
|
||||
dns.Ip["d1110"] = []net.IP{ip1110}
|
||||
dns.Mx["d1110"] = []*net.MX{{"d1110", 5}, {"nothing", 10}}
|
||||
dns.Ip["d6666"] = []net.IP{ip6666}
|
||||
dns.Ip["d6660"] = []net.IP{ip6660}
|
||||
dns.Mx["d6660"] = []*net.MX{{"d6660", 5}, {"nothing", 10}}
|
||||
dns.Addr["2001:db8::68"] = []string{"sonlas6.", "domain.", "d6666."}
|
||||
dns.Addr["1.1.1.1"] = []string{"lalala.", "domain.", "d1111."}
|
||||
}
|
||||
|
||||
func Fuzz(data []byte) int {
|
||||
// The domain's TXT record comes from the fuzzer.
|
||||
dns.Txt["domain"] = []string{string(data)}
|
||||
|
||||
v4result, _ := CheckHostWithSender(
|
||||
ip1111, "helo", "domain", WithResolver(dns))
|
||||
v6result, _ := CheckHostWithSender(
|
||||
ip6666, "helo", "domain", WithResolver(dns))
|
||||
|
||||
// Raise priority if any of the results was something other than
|
||||
// PermError, as it means the data was better formed.
|
||||
if v4result != PermError || v6result != PermError {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
111
vendor/blitiri.com.ar/go/spf/internal/dnstest/dns.go
generated
vendored
Normal file
111
vendor/blitiri.com.ar/go/spf/internal/dnstest/dns.go
generated
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
// DNS resolver for testing purposes.
|
||||
//
|
||||
// In the future, when go fuzz can make use of _test.go files, we can rename
|
||||
// this file dns_test.go and remove this extra package entirely.
|
||||
// Until then, unfortunately this is the most reasonable way to share these
|
||||
// helpers between go and fuzz tests.
|
||||
package dnstest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Testing DNS resolver.
|
||||
//
|
||||
// Not exported since this is not part of the public API and only used
|
||||
// internally on tests.
|
||||
//
|
||||
type TestResolver struct {
|
||||
Txt map[string][]string
|
||||
Mx map[string][]*net.MX
|
||||
Ip map[string][]net.IP
|
||||
Addr map[string][]string
|
||||
Cname map[string]string
|
||||
Errors map[string]error
|
||||
}
|
||||
|
||||
func NewResolver() *TestResolver {
|
||||
return &TestResolver{
|
||||
Txt: map[string][]string{},
|
||||
Mx: map[string][]*net.MX{},
|
||||
Ip: map[string][]net.IP{},
|
||||
Addr: map[string][]string{},
|
||||
Cname: map[string]string{},
|
||||
Errors: map[string]error{},
|
||||
}
|
||||
}
|
||||
|
||||
var nxDomainErr = &net.DNSError{
|
||||
Err: "domain not found (for testing)",
|
||||
IsNotFound: true,
|
||||
}
|
||||
|
||||
func (r *TestResolver) LookupTXT(ctx context.Context, domain string) (txts []string, err error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
domain = strings.ToLower(domain)
|
||||
domain = strings.TrimRight(domain, ".")
|
||||
if cname, ok := r.Cname[domain]; ok {
|
||||
return r.LookupTXT(ctx, cname)
|
||||
}
|
||||
if _, ok := r.Txt[domain]; !ok && r.Errors[domain] == nil {
|
||||
return nil, nxDomainErr
|
||||
}
|
||||
return r.Txt[domain], r.Errors[domain]
|
||||
}
|
||||
|
||||
func (r *TestResolver) LookupMX(ctx context.Context, domain string) (mxs []*net.MX, err error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
domain = strings.ToLower(domain)
|
||||
domain = strings.TrimRight(domain, ".")
|
||||
if cname, ok := r.Cname[domain]; ok {
|
||||
return r.LookupMX(ctx, cname)
|
||||
}
|
||||
if _, ok := r.Mx[domain]; !ok && r.Errors[domain] == nil {
|
||||
return nil, nxDomainErr
|
||||
}
|
||||
return r.Mx[domain], r.Errors[domain]
|
||||
}
|
||||
|
||||
func (r *TestResolver) LookupIPAddr(ctx context.Context, host string) (as []net.IPAddr, err error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
host = strings.ToLower(host)
|
||||
host = strings.TrimRight(host, ".")
|
||||
if cname, ok := r.Cname[host]; ok {
|
||||
return r.LookupIPAddr(ctx, cname)
|
||||
}
|
||||
if _, ok := r.Ip[host]; !ok && r.Errors[host] == nil {
|
||||
return nil, nxDomainErr
|
||||
}
|
||||
return ipsToAddrs(r.Ip[host]), r.Errors[host]
|
||||
}
|
||||
|
||||
func ipsToAddrs(ips []net.IP) []net.IPAddr {
|
||||
as := []net.IPAddr{}
|
||||
for _, ip := range ips {
|
||||
as = append(as, net.IPAddr{IP: ip, Zone: ""})
|
||||
}
|
||||
return as
|
||||
}
|
||||
|
||||
func (r *TestResolver) LookupAddr(ctx context.Context, host string) (addrs []string, err error) {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
host = strings.ToLower(host)
|
||||
host = strings.TrimRight(host, ".")
|
||||
if cname, ok := r.Cname[host]; ok {
|
||||
return r.LookupAddr(ctx, cname)
|
||||
}
|
||||
if _, ok := r.Addr[host]; !ok && r.Errors[host] == nil {
|
||||
return nil, nxDomainErr
|
||||
}
|
||||
return r.Addr[host], r.Errors[host]
|
||||
}
|
||||
1044
vendor/blitiri.com.ar/go/spf/spf.go
generated
vendored
Normal file
1044
vendor/blitiri.com.ar/go/spf/spf.go
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
vendor/github.com/archdx/zerolog-sentry/.gitignore
generated
vendored
Normal file
1
vendor/github.com/archdx/zerolog-sentry/.gitignore
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
cover.out
|
||||
20
vendor/github.com/archdx/zerolog-sentry/.golangci.yml
generated
vendored
Normal file
20
vendor/github.com/archdx/zerolog-sentry/.golangci.yml
generated
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
linters:
|
||||
enable-all: false
|
||||
enable:
|
||||
- unparam
|
||||
- whitespace
|
||||
- unconvert
|
||||
- bodyclose
|
||||
- gofmt
|
||||
- nakedret
|
||||
- prealloc
|
||||
- rowserrcheck
|
||||
- unconvert
|
||||
- gocritic
|
||||
- godox
|
||||
- errcheck
|
||||
- ineffassign
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
check-shadowing: true
|
||||
175
vendor/github.com/archdx/zerolog-sentry/LICENSE
generated
vendored
Normal file
175
vendor/github.com/archdx/zerolog-sentry/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,175 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
18
vendor/github.com/archdx/zerolog-sentry/Makefile
generated
vendored
Normal file
18
vendor/github.com/archdx/zerolog-sentry/Makefile
generated
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
GO?=go
|
||||
|
||||
modules:
|
||||
@$(GO) mod tidy -v
|
||||
|
||||
test:
|
||||
@$(GO) test -v -race -cover
|
||||
|
||||
lint:
|
||||
golangci-lint run --deadline=5m -v
|
||||
|
||||
benchmarks:
|
||||
@$(GO) test -bench=. -benchmem
|
||||
|
||||
coverage:
|
||||
@$(GO) test -race -covermode=atomic -coverprofile=cover.out
|
||||
|
||||
.PHONY: modules test lint benchmarks coverage
|
||||
30
vendor/github.com/archdx/zerolog-sentry/README.md
generated
vendored
Normal file
30
vendor/github.com/archdx/zerolog-sentry/README.md
generated
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# zerolog-sentry
|
||||
[](https://github.com/archdx/zerolog-sentry/actions)
|
||||
[](https://codecov.io/gh/archdx/zerolog-sentry)
|
||||
|
||||
### Example
|
||||
```go
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
stdlog "log"
|
||||
"os"
|
||||
|
||||
"github.com/archdx/zerolog-sentry"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
w, err := zlogsentry.New("http://e35657dcf4fb4d7c98a1c0b8a9125088@localhost:9000/2")
|
||||
if err != nil {
|
||||
stdlog.Fatal(err)
|
||||
}
|
||||
|
||||
defer w.Close()
|
||||
|
||||
logger := zerolog.New(io.MultiWriter(w, os.Stdout)).With().Timestamp().Logger()
|
||||
|
||||
logger.Error().Err(errors.New("dial timeout")).Msg("test message")
|
||||
}
|
||||
|
||||
```
|
||||
260
vendor/github.com/archdx/zerolog-sentry/writer.go
generated
vendored
Normal file
260
vendor/github.com/archdx/zerolog-sentry/writer.go
generated
vendored
Normal file
@@ -0,0 +1,260 @@
|
||||
package zlogsentry
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/buger/jsonparser"
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var levelsMapping = map[zerolog.Level]sentry.Level{
|
||||
zerolog.DebugLevel: sentry.LevelDebug,
|
||||
zerolog.InfoLevel: sentry.LevelInfo,
|
||||
zerolog.WarnLevel: sentry.LevelWarning,
|
||||
zerolog.ErrorLevel: sentry.LevelError,
|
||||
zerolog.FatalLevel: sentry.LevelFatal,
|
||||
zerolog.PanicLevel: sentry.LevelFatal,
|
||||
}
|
||||
|
||||
var _ = io.WriteCloser(new(Writer))
|
||||
|
||||
var now = time.Now
|
||||
|
||||
// Writer is a sentry events writer with std io.Writer iface.
|
||||
type Writer struct {
|
||||
hub *sentry.Hub
|
||||
|
||||
levels map[zerolog.Level]struct{}
|
||||
flushTimeout time.Duration
|
||||
}
|
||||
|
||||
// Write handles zerolog's json and sends events to sentry.
|
||||
func (w *Writer) Write(data []byte) (int, error) {
|
||||
event, ok := w.parseLogEvent(data)
|
||||
if ok {
|
||||
w.hub.CaptureEvent(event)
|
||||
// should flush before os.Exit
|
||||
if event.Level == sentry.LevelFatal {
|
||||
w.hub.Flush(w.flushTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
// Close forces client to flush all pending events.
|
||||
// Can be useful before application exits.
|
||||
func (w *Writer) Close() error {
|
||||
w.hub.Flush(w.flushTimeout)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Writer) parseLogEvent(data []byte) (*sentry.Event, bool) {
|
||||
const logger = "zerolog"
|
||||
|
||||
lvlStr, err := jsonparser.GetUnsafeString(data, zerolog.LevelFieldName)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
lvl, err := zerolog.ParseLevel(lvlStr)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
_, enabled := w.levels[lvl]
|
||||
if !enabled {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sentryLvl, ok := levelsMapping[lvl]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
event := sentry.Event{
|
||||
Timestamp: now(),
|
||||
Level: sentryLvl,
|
||||
Logger: logger,
|
||||
Extra: map[string]interface{}{},
|
||||
}
|
||||
|
||||
err = jsonparser.ObjectEach(data, func(key, value []byte, vt jsonparser.ValueType, offset int) error {
|
||||
switch string(key) {
|
||||
case zerolog.MessageFieldName:
|
||||
event.Message = bytesToStrUnsafe(value)
|
||||
case zerolog.ErrorFieldName:
|
||||
event.Exception = append(event.Exception, sentry.Exception{
|
||||
Value: bytesToStrUnsafe(value),
|
||||
Stacktrace: newStacktrace(),
|
||||
})
|
||||
case zerolog.LevelFieldName, zerolog.TimestampFieldName:
|
||||
default:
|
||||
event.Extra[string(key)] = bytesToStrUnsafe(value)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &event, true
|
||||
}
|
||||
|
||||
func newStacktrace() *sentry.Stacktrace {
|
||||
const (
|
||||
module = "github.com/archdx/zerolog-sentry"
|
||||
loggerModule = "github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
st := sentry.NewStacktrace()
|
||||
|
||||
threshold := len(st.Frames) - 1
|
||||
// drop current module frames
|
||||
for ; threshold > 0 && st.Frames[threshold].Module == module; threshold-- {
|
||||
}
|
||||
|
||||
outer:
|
||||
// try to drop zerolog module frames after logger call point
|
||||
for i := threshold; i > 0; i-- {
|
||||
if st.Frames[i].Module == loggerModule {
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
if st.Frames[j].Module != loggerModule {
|
||||
threshold = j
|
||||
break outer
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
st.Frames = st.Frames[:threshold+1]
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
func bytesToStrUnsafe(data []byte) string {
|
||||
return *(*string)(unsafe.Pointer(&data))
|
||||
}
|
||||
|
||||
// WriterOption configures sentry events writer.
|
||||
type WriterOption interface {
|
||||
apply(*config)
|
||||
}
|
||||
|
||||
type optionFunc func(*config)
|
||||
|
||||
func (fn optionFunc) apply(c *config) { fn(c) }
|
||||
|
||||
type config struct {
|
||||
levels []zerolog.Level
|
||||
sampleRate float64
|
||||
release string
|
||||
environment string
|
||||
serverName string
|
||||
ignoreErrors []string
|
||||
debug bool
|
||||
flushTimeout time.Duration
|
||||
}
|
||||
|
||||
// WithLevels configures zerolog levels that have to be sent to Sentry.
|
||||
// Default levels are: error, fatal, panic.
|
||||
func WithLevels(levels ...zerolog.Level) WriterOption {
|
||||
return optionFunc(func(cfg *config) {
|
||||
cfg.levels = levels
|
||||
})
|
||||
}
|
||||
|
||||
// WithSampleRate configures the sample rate as a percentage of events to be sent in the range of 0.0 to 1.0.
|
||||
func WithSampleRate(rate float64) WriterOption {
|
||||
return optionFunc(func(cfg *config) {
|
||||
cfg.sampleRate = rate
|
||||
})
|
||||
}
|
||||
|
||||
// WithRelease configures the release to be sent with events.
|
||||
func WithRelease(release string) WriterOption {
|
||||
return optionFunc(func(cfg *config) {
|
||||
cfg.release = release
|
||||
})
|
||||
}
|
||||
|
||||
// WithEnvironment configures the environment to be sent with events.
|
||||
func WithEnvironment(environment string) WriterOption {
|
||||
return optionFunc(func(cfg *config) {
|
||||
cfg.environment = environment
|
||||
})
|
||||
}
|
||||
|
||||
// WithServerName configures the server name field for events. Default value is OS hostname.
|
||||
func WithServerName(serverName string) WriterOption {
|
||||
return optionFunc(func(cfg *config) {
|
||||
cfg.serverName = serverName
|
||||
})
|
||||
}
|
||||
|
||||
// WithIgnoreErrors configures the list of regexp strings that will be used to match against event's message
|
||||
// and if applicable, caught errors type and value. If the match is found, then a whole event will be dropped.
|
||||
func WithIgnoreErrors(reList []string) WriterOption {
|
||||
return optionFunc(func(cfg *config) {
|
||||
cfg.ignoreErrors = reList
|
||||
})
|
||||
}
|
||||
|
||||
// WithDebug enables sentry client debug logs.
|
||||
func WithDebug() WriterOption {
|
||||
return optionFunc(func(cfg *config) {
|
||||
cfg.debug = true
|
||||
})
|
||||
}
|
||||
|
||||
// New creates writer with provided DSN and options.
|
||||
func New(dsn string, opts ...WriterOption) (*Writer, error) {
|
||||
cfg := newDefaultConfig()
|
||||
for _, opt := range opts {
|
||||
opt.apply(&cfg)
|
||||
}
|
||||
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: dsn,
|
||||
SampleRate: cfg.sampleRate,
|
||||
Release: cfg.release,
|
||||
Environment: cfg.environment,
|
||||
ServerName: cfg.serverName,
|
||||
IgnoreErrors: cfg.ignoreErrors,
|
||||
Debug: cfg.debug,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
levels := make(map[zerolog.Level]struct{}, len(cfg.levels))
|
||||
for _, lvl := range cfg.levels {
|
||||
levels[lvl] = struct{}{}
|
||||
}
|
||||
|
||||
return &Writer{
|
||||
hub: sentry.CurrentHub(),
|
||||
levels: levels,
|
||||
flushTimeout: cfg.flushTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newDefaultConfig() config {
|
||||
return config{
|
||||
levels: []zerolog.Level{
|
||||
zerolog.ErrorLevel,
|
||||
zerolog.FatalLevel,
|
||||
zerolog.PanicLevel,
|
||||
},
|
||||
sampleRate: 1.0,
|
||||
flushTimeout: 3 * time.Second,
|
||||
}
|
||||
}
|
||||
12
vendor/github.com/buger/jsonparser/.gitignore
generated
vendored
Normal file
12
vendor/github.com/buger/jsonparser/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
*.test
|
||||
|
||||
*.out
|
||||
|
||||
*.mprof
|
||||
|
||||
.idea
|
||||
|
||||
vendor/github.com/buger/goterm/
|
||||
prof.cpu
|
||||
prof.mem
|
||||
8
vendor/github.com/buger/jsonparser/.travis.yml
generated
vendored
Normal file
8
vendor/github.com/buger/jsonparser/.travis.yml
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
language: go
|
||||
go:
|
||||
- 1.7.x
|
||||
- 1.8.x
|
||||
- 1.9.x
|
||||
- 1.10.x
|
||||
- 1.11.x
|
||||
script: go test -v ./.
|
||||
12
vendor/github.com/buger/jsonparser/Dockerfile
generated
vendored
Normal file
12
vendor/github.com/buger/jsonparser/Dockerfile
generated
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.6
|
||||
|
||||
RUN go get github.com/Jeffail/gabs
|
||||
RUN go get github.com/bitly/go-simplejson
|
||||
RUN go get github.com/pquerna/ffjson
|
||||
RUN go get github.com/antonholmquist/jason
|
||||
RUN go get github.com/mreiferson/go-ujson
|
||||
RUN go get -tags=unsafe -u github.com/ugorji/go/codec
|
||||
RUN go get github.com/mailru/easyjson
|
||||
|
||||
WORKDIR /go/src/github.com/buger/jsonparser
|
||||
ADD . /go/src/github.com/buger/jsonparser
|
||||
21
vendor/github.com/buger/jsonparser/LICENSE
generated
vendored
Normal file
21
vendor/github.com/buger/jsonparser/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016 Leonid Bugaev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
36
vendor/github.com/buger/jsonparser/Makefile
generated
vendored
Normal file
36
vendor/github.com/buger/jsonparser/Makefile
generated
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
SOURCE = parser.go
|
||||
CONTAINER = jsonparser
|
||||
SOURCE_PATH = /go/src/github.com/buger/jsonparser
|
||||
BENCHMARK = JsonParser
|
||||
BENCHTIME = 5s
|
||||
TEST = .
|
||||
DRUN = docker run -v `pwd`:$(SOURCE_PATH) -i -t $(CONTAINER)
|
||||
|
||||
build:
|
||||
docker build -t $(CONTAINER) .
|
||||
|
||||
race:
|
||||
$(DRUN) --env GORACE="halt_on_error=1" go test ./. $(ARGS) -v -race -timeout 15s
|
||||
|
||||
bench:
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -benchtime $(BENCHTIME) -v
|
||||
|
||||
bench_local:
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench . $(ARGS) -benchtime $(BENCHTIME) -v
|
||||
|
||||
profile:
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -memprofile mem.mprof -v
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -cpuprofile cpu.out -v
|
||||
$(DRUN) go test $(LDFLAGS) -test.benchmem -bench $(BENCHMARK) ./benchmark/ $(ARGS) -c
|
||||
|
||||
test:
|
||||
$(DRUN) go test $(LDFLAGS) ./ -run $(TEST) -timeout 10s $(ARGS) -v
|
||||
|
||||
fmt:
|
||||
$(DRUN) go fmt ./...
|
||||
|
||||
vet:
|
||||
$(DRUN) go vet ./.
|
||||
|
||||
bash:
|
||||
$(DRUN) /bin/bash
|
||||
365
vendor/github.com/buger/jsonparser/README.md
generated
vendored
Normal file
365
vendor/github.com/buger/jsonparser/README.md
generated
vendored
Normal file
@@ -0,0 +1,365 @@
|
||||
[](https://goreportcard.com/report/github.com/buger/jsonparser) 
|
||||
# Alternative JSON parser for Go (so far fastest)
|
||||
|
||||
It does not require you to know the structure of the payload (eg. create structs), and allows accessing fields by providing the path to them. It is up to **10 times faster** than standard `encoding/json` package (depending on payload size and usage), **allocates no memory**. See benchmarks below.
|
||||
|
||||
## Rationale
|
||||
Originally I made this for a project that relies on a lot of 3rd party APIs that can be unpredictable and complex.
|
||||
I love simplicity and prefer to avoid external dependecies. `encoding/json` requires you to know exactly your data structures, or if you prefer to use `map[string]interface{}` instead, it will be very slow and hard to manage.
|
||||
I investigated what's on the market and found that most libraries are just wrappers around `encoding/json`, there is few options with own parsers (`ffjson`, `easyjson`), but they still requires you to create data structures.
|
||||
|
||||
|
||||
Goal of this project is to push JSON parser to the performance limits and not sacrifice with compliance and developer user experience.
|
||||
|
||||
## Example
|
||||
For the given JSON our goal is to extract the user's full name, number of github followers and avatar.
|
||||
|
||||
```go
|
||||
import "github.com/buger/jsonparser"
|
||||
|
||||
...
|
||||
|
||||
data := []byte(`{
|
||||
"person": {
|
||||
"name": {
|
||||
"first": "Leonid",
|
||||
"last": "Bugaev",
|
||||
"fullName": "Leonid Bugaev"
|
||||
},
|
||||
"github": {
|
||||
"handle": "buger",
|
||||
"followers": 109
|
||||
},
|
||||
"avatars": [
|
||||
{ "url": "https://avatars1.githubusercontent.com/u/14009?v=3&s=460", "type": "thumbnail" }
|
||||
]
|
||||
},
|
||||
"company": {
|
||||
"name": "Acme"
|
||||
}
|
||||
}`)
|
||||
|
||||
// You can specify key path by providing arguments to Get function
|
||||
jsonparser.Get(data, "person", "name", "fullName")
|
||||
|
||||
// There is `GetInt` and `GetBoolean` helpers if you exactly know key data type
|
||||
jsonparser.GetInt(data, "person", "github", "followers")
|
||||
|
||||
// When you try to get object, it will return you []byte slice pointer to data containing it
|
||||
// In `company` it will be `{"name": "Acme"}`
|
||||
jsonparser.Get(data, "company")
|
||||
|
||||
// If the key doesn't exist it will throw an error
|
||||
var size int64
|
||||
if value, err := jsonparser.GetInt(data, "company", "size"); err == nil {
|
||||
size = value
|
||||
}
|
||||
|
||||
// You can use `ArrayEach` helper to iterate items [item1, item2 .... itemN]
|
||||
jsonparser.ArrayEach(data, func(value []byte, dataType jsonparser.ValueType, offset int, err error) {
|
||||
fmt.Println(jsonparser.Get(value, "url"))
|
||||
}, "person", "avatars")
|
||||
|
||||
// Or use can access fields by index!
|
||||
jsonparser.GetString(data, "person", "avatars", "[0]", "url")
|
||||
|
||||
// You can use `ObjectEach` helper to iterate objects { "key1":object1, "key2":object2, .... "keyN":objectN }
|
||||
jsonparser.ObjectEach(data, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error {
|
||||
fmt.Printf("Key: '%s'\n Value: '%s'\n Type: %s\n", string(key), string(value), dataType)
|
||||
return nil
|
||||
}, "person", "name")
|
||||
|
||||
// The most efficient way to extract multiple keys is `EachKey`
|
||||
|
||||
paths := [][]string{
|
||||
[]string{"person", "name", "fullName"},
|
||||
[]string{"person", "avatars", "[0]", "url"},
|
||||
[]string{"company", "url"},
|
||||
}
|
||||
jsonparser.EachKey(data, func(idx int, value []byte, vt jsonparser.ValueType, err error){
|
||||
switch idx {
|
||||
case 0: // []string{"person", "name", "fullName"}
|
||||
...
|
||||
case 1: // []string{"person", "avatars", "[0]", "url"}
|
||||
...
|
||||
case 2: // []string{"company", "url"},
|
||||
...
|
||||
}
|
||||
}, paths...)
|
||||
|
||||
// For more information see docs below
|
||||
```
|
||||
|
||||
## Need to speedup your app?
|
||||
|
||||
I'm available for consulting and can help you push your app performance to the limits. Ping me at: leonsbox@gmail.com.
|
||||
|
||||
## Reference
|
||||
|
||||
Library API is really simple. You just need the `Get` method to perform any operation. The rest is just helpers around it.
|
||||
|
||||
You also can view API at [godoc.org](https://godoc.org/github.com/buger/jsonparser)
|
||||
|
||||
|
||||
### **`Get`**
|
||||
```go
|
||||
func Get(data []byte, keys ...string) (value []byte, dataType jsonparser.ValueType, offset int, err error)
|
||||
```
|
||||
Receives data structure, and key path to extract value from.
|
||||
|
||||
Returns:
|
||||
* `value` - Pointer to original data structure containing key value, or just empty slice if nothing found or error
|
||||
* `dataType` - Can be: `NotExist`, `String`, `Number`, `Object`, `Array`, `Boolean` or `Null`
|
||||
* `offset` - Offset from provided data structure where key value ends. Used mostly internally, for example for `ArrayEach` helper.
|
||||
* `err` - If the key is not found or any other parsing issue, it should return error. If key not found it also sets `dataType` to `NotExist`
|
||||
|
||||
Accepts multiple keys to specify path to JSON value (in case of quering nested structures).
|
||||
If no keys are provided it will try to extract the closest JSON value (simple ones or object/array), useful for reading streams or arrays, see `ArrayEach` implementation.
|
||||
|
||||
Note that keys can be an array indexes: `jsonparser.GetInt("person", "avatars", "[0]", "url")`, pretty cool, yeah?
|
||||
|
||||
### **`GetString`**
|
||||
```go
|
||||
func GetString(data []byte, keys ...string) (val string, err error)
|
||||
```
|
||||
Returns strings properly handing escaped and unicode characters. Note that this will cause additional memory allocations.
|
||||
|
||||
### **`GetUnsafeString`**
|
||||
If you need string in your app, and ready to sacrifice with support of escaped symbols in favor of speed. It returns string mapped to existing byte slice memory, without any allocations:
|
||||
```go
|
||||
s, _, := jsonparser.GetUnsafeString(data, "person", "name", "title")
|
||||
switch s {
|
||||
case 'CEO':
|
||||
...
|
||||
case 'Engineer'
|
||||
...
|
||||
...
|
||||
}
|
||||
```
|
||||
Note that `unsafe` here means that your string will exist until GC will free underlying byte slice, for most of cases it means that you can use this string only in current context, and should not pass it anywhere externally: through channels or any other way.
|
||||
|
||||
|
||||
### **`GetBoolean`**, **`GetInt`** and **`GetFloat`**
|
||||
```go
|
||||
func GetBoolean(data []byte, keys ...string) (val bool, err error)
|
||||
|
||||
func GetFloat(data []byte, keys ...string) (val float64, err error)
|
||||
|
||||
func GetInt(data []byte, keys ...string) (val int64, err error)
|
||||
```
|
||||
If you know the key type, you can use the helpers above.
|
||||
If key data type do not match, it will return error.
|
||||
|
||||
### **`ArrayEach`**
|
||||
```go
|
||||
func ArrayEach(data []byte, cb func(value []byte, dataType jsonparser.ValueType, offset int, err error), keys ...string)
|
||||
```
|
||||
Needed for iterating arrays, accepts a callback function with the same return arguments as `Get`.
|
||||
|
||||
### **`ObjectEach`**
|
||||
```go
|
||||
func ObjectEach(data []byte, callback func(key []byte, value []byte, dataType ValueType, offset int) error, keys ...string) (err error)
|
||||
```
|
||||
Needed for iterating object, accepts a callback function. Example:
|
||||
```go
|
||||
var handler func([]byte, []byte, jsonparser.ValueType, int) error
|
||||
handler = func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error {
|
||||
//do stuff here
|
||||
}
|
||||
jsonparser.ObjectEach(myJson, handler)
|
||||
```
|
||||
|
||||
|
||||
### **`EachKey`**
|
||||
```go
|
||||
func EachKey(data []byte, cb func(idx int, value []byte, dataType jsonparser.ValueType, err error), paths ...[]string)
|
||||
```
|
||||
When you need to read multiple keys, and you do not afraid of low-level API `EachKey` is your friend. It read payload only single time, and calls callback function once path is found. For example when you call multiple times `Get`, it has to process payload multiple times, each time you call it. Depending on payload `EachKey` can be multiple times faster than `Get`. Path can use nested keys as well!
|
||||
|
||||
```go
|
||||
paths := [][]string{
|
||||
[]string{"uuid"},
|
||||
[]string{"tz"},
|
||||
[]string{"ua"},
|
||||
[]string{"st"},
|
||||
}
|
||||
var data SmallPayload
|
||||
|
||||
jsonparser.EachKey(smallFixture, func(idx int, value []byte, vt jsonparser.ValueType, err error){
|
||||
switch idx {
|
||||
case 0:
|
||||
data.Uuid, _ = value
|
||||
case 1:
|
||||
v, _ := jsonparser.ParseInt(value)
|
||||
data.Tz = int(v)
|
||||
case 2:
|
||||
data.Ua, _ = value
|
||||
case 3:
|
||||
v, _ := jsonparser.ParseInt(value)
|
||||
data.St = int(v)
|
||||
}
|
||||
}, paths...)
|
||||
```
|
||||
|
||||
### **`Set`**
|
||||
```go
|
||||
func Set(data []byte, setValue []byte, keys ...string) (value []byte, err error)
|
||||
```
|
||||
Receives existing data structure, key path to set, and value to set at that key. *This functionality is experimental.*
|
||||
|
||||
Returns:
|
||||
* `value` - Pointer to original data structure with updated or added key value.
|
||||
* `err` - If any parsing issue, it should return error.
|
||||
|
||||
Accepts multiple keys to specify path to JSON value (in case of updating or creating nested structures).
|
||||
|
||||
Note that keys can be an array indexes: `jsonparser.Set(data, []byte("http://github.com"), "person", "avatars", "[0]", "url")`
|
||||
|
||||
### **`Delete`**
|
||||
```go
|
||||
func Delete(data []byte, keys ...string) value []byte
|
||||
```
|
||||
Receives existing data structure, and key path to delete. *This functionality is experimental.*
|
||||
|
||||
Returns:
|
||||
* `value` - Pointer to original data structure with key path deleted if it can be found. If there is no key path, then the whole data structure is deleted.
|
||||
|
||||
Accepts multiple keys to specify path to JSON value (in case of updating or creating nested structures).
|
||||
|
||||
Note that keys can be an array indexes: `jsonparser.Delete(data, "person", "avatars", "[0]", "url")`
|
||||
|
||||
|
||||
## What makes it so fast?
|
||||
* It does not rely on `encoding/json`, `reflection` or `interface{}`, the only real package dependency is `bytes`.
|
||||
* Operates with JSON payload on byte level, providing you pointers to the original data structure: no memory allocation.
|
||||
* No automatic type conversions, by default everything is a []byte, but it provides you value type, so you can convert by yourself (there is few helpers included).
|
||||
* Does not parse full record, only keys you specified
|
||||
|
||||
|
||||
## Benchmarks
|
||||
|
||||
There are 3 benchmark types, trying to simulate real-life usage for small, medium and large JSON payloads.
|
||||
For each metric, the lower value is better. Time/op is in nanoseconds. Values better than standard encoding/json marked as bold text.
|
||||
Benchmarks run on standard Linode 1024 box.
|
||||
|
||||
Compared libraries:
|
||||
* https://golang.org/pkg/encoding/json
|
||||
* https://github.com/Jeffail/gabs
|
||||
* https://github.com/a8m/djson
|
||||
* https://github.com/bitly/go-simplejson
|
||||
* https://github.com/antonholmquist/jason
|
||||
* https://github.com/mreiferson/go-ujson
|
||||
* https://github.com/ugorji/go/codec
|
||||
* https://github.com/pquerna/ffjson
|
||||
* https://github.com/mailru/easyjson
|
||||
* https://github.com/buger/jsonparser
|
||||
|
||||
#### TLDR
|
||||
If you want to skip next sections we have 2 winner: `jsonparser` and `easyjson`.
|
||||
`jsonparser` is up to 10 times faster than standard `encoding/json` package (depending on payload size and usage), and almost infinitely (literally) better in memory consumption because it operates with data on byte level, and provide direct slice pointers.
|
||||
`easyjson` wins in CPU in medium tests and frankly i'm impressed with this package: it is remarkable results considering that it is almost drop-in replacement for `encoding/json` (require some code generation).
|
||||
|
||||
It's hard to fully compare `jsonparser` and `easyjson` (or `ffson`), they a true parsers and fully process record, unlike `jsonparser` which parse only keys you specified.
|
||||
|
||||
If you searching for replacement of `encoding/json` while keeping structs, `easyjson` is an amazing choice. If you want to process dynamic JSON, have memory constrains, or more control over your data you should try `jsonparser`.
|
||||
|
||||
`jsonparser` performance heavily depends on usage, and it works best when you do not need to process full record, only some keys. The more calls you need to make, the slower it will be, in contrast `easyjson` (or `ffjson`, `encoding/json`) parser record only 1 time, and then you can make as many calls as you want.
|
||||
|
||||
With great power comes great responsibility! :)
|
||||
|
||||
|
||||
#### Small payload
|
||||
|
||||
Each test processes 190 bytes of http log as a JSON record.
|
||||
It should read multiple fields.
|
||||
https://github.com/buger/jsonparser/blob/master/benchmark/benchmark_small_payload_test.go
|
||||
|
||||
Library | time/op | bytes/op | allocs/op
|
||||
------ | ------- | -------- | -------
|
||||
encoding/json struct | 7879 | 880 | 18
|
||||
encoding/json interface{} | 8946 | 1521 | 38
|
||||
Jeffail/gabs | 10053 | 1649 | 46
|
||||
bitly/go-simplejson | 10128 | 2241 | 36
|
||||
antonholmquist/jason | 27152 | 7237 | 101
|
||||
github.com/ugorji/go/codec | 8806 | 2176 | 31
|
||||
mreiferson/go-ujson | **7008** | **1409** | 37
|
||||
a8m/djson | 3862 | 1249 | 30
|
||||
pquerna/ffjson | **3769** | **624** | **15**
|
||||
mailru/easyjson | **2002** | **192** | **9**
|
||||
buger/jsonparser | **1367** | **0** | **0**
|
||||
buger/jsonparser (EachKey API) | **809** | **0** | **0**
|
||||
|
||||
Winners are ffjson, easyjson and jsonparser, where jsonparser is up to 9.8x faster than encoding/json and 4.6x faster than ffjson, and slightly faster than easyjson.
|
||||
If you look at memory allocation, jsonparser has no rivals, as it makes no data copy and operates with raw []byte structures and pointers to it.
|
||||
|
||||
#### Medium payload
|
||||
|
||||
Each test processes a 2.4kb JSON record (based on Clearbit API).
|
||||
It should read multiple nested fields and 1 array.
|
||||
|
||||
https://github.com/buger/jsonparser/blob/master/benchmark/benchmark_medium_payload_test.go
|
||||
|
||||
| Library | time/op | bytes/op | allocs/op |
|
||||
| ------- | ------- | -------- | --------- |
|
||||
| encoding/json struct | 57749 | 1336 | 29 |
|
||||
| encoding/json interface{} | 79297 | 10627 | 215 |
|
||||
| Jeffail/gabs | 83807 | 11202 | 235 |
|
||||
| bitly/go-simplejson | 88187 | 17187 | 220 |
|
||||
| antonholmquist/jason | 94099 | 19013 | 247 |
|
||||
| github.com/ugorji/go/codec | 114719 | 6712 | 152 |
|
||||
| mreiferson/go-ujson | **56972** | 11547 | 270 |
|
||||
| a8m/djson | 28525 | 10196 | 198 |
|
||||
| pquerna/ffjson | **20298** | **856** | **20** |
|
||||
| mailru/easyjson | **10512** | **336** | **12** |
|
||||
| buger/jsonparser | **15955** | **0** | **0** |
|
||||
| buger/jsonparser (EachKey API) | **8916** | **0** | **0** |
|
||||
|
||||
The difference between ffjson and jsonparser in CPU usage is smaller, while the memory consumption difference is growing. On the other hand `easyjson` shows remarkable performance for medium payload.
|
||||
|
||||
`gabs`, `go-simplejson` and `jason` are based on encoding/json and map[string]interface{} and actually only helpers for unstructured JSON, their performance correlate with `encoding/json interface{}`, and they will skip next round.
|
||||
`go-ujson` while have its own parser, shows same performance as `encoding/json`, also skips next round. Same situation with `ugorji/go/codec`, but it showed unexpectedly bad performance for complex payloads.
|
||||
|
||||
|
||||
#### Large payload
|
||||
|
||||
Each test processes a 24kb JSON record (based on Discourse API)
|
||||
It should read 2 arrays, and for each item in array get a few fields.
|
||||
Basically it means processing a full JSON file.
|
||||
|
||||
https://github.com/buger/jsonparser/blob/master/benchmark/benchmark_large_payload_test.go
|
||||
|
||||
| Library | time/op | bytes/op | allocs/op |
|
||||
| --- | --- | --- | --- |
|
||||
| encoding/json struct | 748336 | 8272 | 307 |
|
||||
| encoding/json interface{} | 1224271 | 215425 | 3395 |
|
||||
| a8m/djson | 510082 | 213682 | 2845 |
|
||||
| pquerna/ffjson | **312271** | **7792** | **298** |
|
||||
| mailru/easyjson | **154186** | **6992** | **288** |
|
||||
| buger/jsonparser | **85308** | **0** | **0** |
|
||||
|
||||
`jsonparser` now is a winner, but do not forget that it is way more lightweight parser than `ffson` or `easyjson`, and they have to parser all the data, while `jsonparser` parse only what you need. All `ffjson`, `easysjon` and `jsonparser` have their own parsing code, and does not depend on `encoding/json` or `interface{}`, thats one of the reasons why they are so fast. `easyjson` also use a bit of `unsafe` package to reduce memory consuption (in theory it can lead to some unexpected GC issue, but i did not tested enough)
|
||||
|
||||
Also last benchmark did not included `EachKey` test, because in this particular case we need to read lot of Array values, and using `ArrayEach` is more efficient.
|
||||
|
||||
## Questions and support
|
||||
|
||||
All bug-reports and suggestions should go though Github Issues.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork it
|
||||
2. Create your feature branch (git checkout -b my-new-feature)
|
||||
3. Commit your changes (git commit -am 'Added some feature')
|
||||
4. Push to the branch (git push origin my-new-feature)
|
||||
5. Create new Pull Request
|
||||
|
||||
## Development
|
||||
|
||||
All my development happens using Docker, and repo include some Make tasks to simplify development.
|
||||
|
||||
* `make build` - builds docker image, usually can be called only once
|
||||
* `make test` - run tests
|
||||
* `make fmt` - run go fmt
|
||||
* `make bench` - run benchmarks (if you need to run only single benchmark modify `BENCHMARK` variable in make file)
|
||||
* `make profile` - runs benchmark and generate 3 files- `cpu.out`, `mem.mprof` and `benchmark.test` binary, which can be used for `go tool pprof`
|
||||
* `make bash` - enter container (i use it for running `go tool pprof` above)
|
||||
47
vendor/github.com/buger/jsonparser/bytes.go
generated
vendored
Normal file
47
vendor/github.com/buger/jsonparser/bytes.go
generated
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
package jsonparser
|
||||
|
||||
import (
|
||||
bio "bytes"
|
||||
)
|
||||
|
||||
// minInt64 '-9223372036854775808' is the smallest representable number in int64
|
||||
const minInt64 = `9223372036854775808`
|
||||
|
||||
// About 2x faster then strconv.ParseInt because it only supports base 10, which is enough for JSON
|
||||
func parseInt(bytes []byte) (v int64, ok bool, overflow bool) {
|
||||
if len(bytes) == 0 {
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
var neg bool = false
|
||||
if bytes[0] == '-' {
|
||||
neg = true
|
||||
bytes = bytes[1:]
|
||||
}
|
||||
|
||||
var b int64 = 0
|
||||
for _, c := range bytes {
|
||||
if c >= '0' && c <= '9' {
|
||||
b = (10 * v) + int64(c-'0')
|
||||
} else {
|
||||
return 0, false, false
|
||||
}
|
||||
if overflow = (b < v); overflow {
|
||||
break
|
||||
}
|
||||
v = b
|
||||
}
|
||||
|
||||
if overflow {
|
||||
if neg && bio.Equal(bytes, []byte(minInt64)) {
|
||||
return b, true, false
|
||||
}
|
||||
return 0, false, true
|
||||
}
|
||||
|
||||
if neg {
|
||||
return -v, true, false
|
||||
} else {
|
||||
return v, true, false
|
||||
}
|
||||
}
|
||||
25
vendor/github.com/buger/jsonparser/bytes_safe.go
generated
vendored
Normal file
25
vendor/github.com/buger/jsonparser/bytes_safe.go
generated
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
// +build appengine appenginevm
|
||||
|
||||
package jsonparser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// See fastbytes_unsafe.go for explanation on why *[]byte is used (signatures must be consistent with those in that file)
|
||||
|
||||
func equalStr(b *[]byte, s string) bool {
|
||||
return string(*b) == s
|
||||
}
|
||||
|
||||
func parseFloat(b *[]byte) (float64, error) {
|
||||
return strconv.ParseFloat(string(*b), 64)
|
||||
}
|
||||
|
||||
func bytesToString(b *[]byte) string {
|
||||
return string(*b)
|
||||
}
|
||||
|
||||
func StringToBytes(s string) []byte {
|
||||
return []byte(s)
|
||||
}
|
||||
42
vendor/github.com/buger/jsonparser/bytes_unsafe.go
generated
vendored
Normal file
42
vendor/github.com/buger/jsonparser/bytes_unsafe.go
generated
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
// +build !appengine,!appenginevm
|
||||
|
||||
package jsonparser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//
|
||||
// The reason for using *[]byte rather than []byte in parameters is an optimization. As of Go 1.6,
|
||||
// the compiler cannot perfectly inline the function when using a non-pointer slice. That is,
|
||||
// the non-pointer []byte parameter version is slower than if its function body is manually
|
||||
// inlined, whereas the pointer []byte version is equally fast to the manually inlined
|
||||
// version. Instruction count in assembly taken from "go tool compile" confirms this difference.
|
||||
//
|
||||
// TODO: Remove hack after Go 1.7 release
|
||||
//
|
||||
func equalStr(b *[]byte, s string) bool {
|
||||
return *(*string)(unsafe.Pointer(b)) == s
|
||||
}
|
||||
|
||||
func parseFloat(b *[]byte) (float64, error) {
|
||||
return strconv.ParseFloat(*(*string)(unsafe.Pointer(b)), 64)
|
||||
}
|
||||
|
||||
// A hack until issue golang/go#2632 is fixed.
|
||||
// See: https://github.com/golang/go/issues/2632
|
||||
func bytesToString(b *[]byte) string {
|
||||
return *(*string)(unsafe.Pointer(b))
|
||||
}
|
||||
|
||||
func StringToBytes(s string) []byte {
|
||||
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
|
||||
bh := reflect.SliceHeader{
|
||||
Data: sh.Data,
|
||||
Len: sh.Len,
|
||||
Cap: sh.Len,
|
||||
}
|
||||
return *(*[]byte)(unsafe.Pointer(&bh))
|
||||
}
|
||||
173
vendor/github.com/buger/jsonparser/escape.go
generated
vendored
Normal file
173
vendor/github.com/buger/jsonparser/escape.go
generated
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
package jsonparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// JSON Unicode stuff: see https://tools.ietf.org/html/rfc7159#section-7
|
||||
|
||||
const supplementalPlanesOffset = 0x10000
|
||||
const highSurrogateOffset = 0xD800
|
||||
const lowSurrogateOffset = 0xDC00
|
||||
|
||||
const basicMultilingualPlaneReservedOffset = 0xDFFF
|
||||
const basicMultilingualPlaneOffset = 0xFFFF
|
||||
|
||||
func combineUTF16Surrogates(high, low rune) rune {
|
||||
return supplementalPlanesOffset + (high-highSurrogateOffset)<<10 + (low - lowSurrogateOffset)
|
||||
}
|
||||
|
||||
const badHex = -1
|
||||
|
||||
func h2I(c byte) int {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return int(c - '0')
|
||||
case c >= 'A' && c <= 'F':
|
||||
return int(c - 'A' + 10)
|
||||
case c >= 'a' && c <= 'f':
|
||||
return int(c - 'a' + 10)
|
||||
}
|
||||
return badHex
|
||||
}
|
||||
|
||||
// decodeSingleUnicodeEscape decodes a single \uXXXX escape sequence. The prefix \u is assumed to be present and
|
||||
// is not checked.
|
||||
// In JSON, these escapes can either come alone or as part of "UTF16 surrogate pairs" that must be handled together.
|
||||
// This function only handles one; decodeUnicodeEscape handles this more complex case.
|
||||
func decodeSingleUnicodeEscape(in []byte) (rune, bool) {
|
||||
// We need at least 6 characters total
|
||||
if len(in) < 6 {
|
||||
return utf8.RuneError, false
|
||||
}
|
||||
|
||||
// Convert hex to decimal
|
||||
h1, h2, h3, h4 := h2I(in[2]), h2I(in[3]), h2I(in[4]), h2I(in[5])
|
||||
if h1 == badHex || h2 == badHex || h3 == badHex || h4 == badHex {
|
||||
return utf8.RuneError, false
|
||||
}
|
||||
|
||||
// Compose the hex digits
|
||||
return rune(h1<<12 + h2<<8 + h3<<4 + h4), true
|
||||
}
|
||||
|
||||
// isUTF16EncodedRune checks if a rune is in the range for non-BMP characters,
|
||||
// which is used to describe UTF16 chars.
|
||||
// Source: https://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane
|
||||
func isUTF16EncodedRune(r rune) bool {
|
||||
return highSurrogateOffset <= r && r <= basicMultilingualPlaneReservedOffset
|
||||
}
|
||||
|
||||
func decodeUnicodeEscape(in []byte) (rune, int) {
|
||||
if r, ok := decodeSingleUnicodeEscape(in); !ok {
|
||||
// Invalid Unicode escape
|
||||
return utf8.RuneError, -1
|
||||
} else if r <= basicMultilingualPlaneOffset && !isUTF16EncodedRune(r) {
|
||||
// Valid Unicode escape in Basic Multilingual Plane
|
||||
return r, 6
|
||||
} else if r2, ok := decodeSingleUnicodeEscape(in[6:]); !ok { // Note: previous decodeSingleUnicodeEscape success guarantees at least 6 bytes remain
|
||||
// UTF16 "high surrogate" without manditory valid following Unicode escape for the "low surrogate"
|
||||
return utf8.RuneError, -1
|
||||
} else if r2 < lowSurrogateOffset {
|
||||
// Invalid UTF16 "low surrogate"
|
||||
return utf8.RuneError, -1
|
||||
} else {
|
||||
// Valid UTF16 surrogate pair
|
||||
return combineUTF16Surrogates(r, r2), 12
|
||||
}
|
||||
}
|
||||
|
||||
// backslashCharEscapeTable: when '\X' is found for some byte X, it is to be replaced with backslashCharEscapeTable[X]
|
||||
var backslashCharEscapeTable = [...]byte{
|
||||
'"': '"',
|
||||
'\\': '\\',
|
||||
'/': '/',
|
||||
'b': '\b',
|
||||
'f': '\f',
|
||||
'n': '\n',
|
||||
'r': '\r',
|
||||
't': '\t',
|
||||
}
|
||||
|
||||
// unescapeToUTF8 unescapes the single escape sequence starting at 'in' into 'out' and returns
|
||||
// how many characters were consumed from 'in' and emitted into 'out'.
|
||||
// If a valid escape sequence does not appear as a prefix of 'in', (-1, -1) to signal the error.
|
||||
func unescapeToUTF8(in, out []byte) (inLen int, outLen int) {
|
||||
if len(in) < 2 || in[0] != '\\' {
|
||||
// Invalid escape due to insufficient characters for any escape or no initial backslash
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc7159#section-7
|
||||
switch e := in[1]; e {
|
||||
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
|
||||
// Valid basic 2-character escapes (use lookup table)
|
||||
out[0] = backslashCharEscapeTable[e]
|
||||
return 2, 1
|
||||
case 'u':
|
||||
// Unicode escape
|
||||
if r, inLen := decodeUnicodeEscape(in); inLen == -1 {
|
||||
// Invalid Unicode escape
|
||||
return -1, -1
|
||||
} else {
|
||||
// Valid Unicode escape; re-encode as UTF8
|
||||
outLen := utf8.EncodeRune(out, r)
|
||||
return inLen, outLen
|
||||
}
|
||||
}
|
||||
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// unescape unescapes the string contained in 'in' and returns it as a slice.
|
||||
// If 'in' contains no escaped characters:
|
||||
// Returns 'in'.
|
||||
// Else, if 'out' is of sufficient capacity (guaranteed if cap(out) >= len(in)):
|
||||
// 'out' is used to build the unescaped string and is returned with no extra allocation
|
||||
// Else:
|
||||
// A new slice is allocated and returned.
|
||||
func Unescape(in, out []byte) ([]byte, error) {
|
||||
firstBackslash := bytes.IndexByte(in, '\\')
|
||||
if firstBackslash == -1 {
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// Get a buffer of sufficient size (allocate if needed)
|
||||
if cap(out) < len(in) {
|
||||
out = make([]byte, len(in))
|
||||
} else {
|
||||
out = out[0:len(in)]
|
||||
}
|
||||
|
||||
// Copy the first sequence of unescaped bytes to the output and obtain a buffer pointer (subslice)
|
||||
copy(out, in[:firstBackslash])
|
||||
in = in[firstBackslash:]
|
||||
buf := out[firstBackslash:]
|
||||
|
||||
for len(in) > 0 {
|
||||
// Unescape the next escaped character
|
||||
inLen, bufLen := unescapeToUTF8(in, buf)
|
||||
if inLen == -1 {
|
||||
return nil, MalformedStringEscapeError
|
||||
}
|
||||
|
||||
in = in[inLen:]
|
||||
buf = buf[bufLen:]
|
||||
|
||||
// Copy everything up until the next backslash
|
||||
nextBackslash := bytes.IndexByte(in, '\\')
|
||||
if nextBackslash == -1 {
|
||||
copy(buf, in)
|
||||
buf = buf[len(in):]
|
||||
break
|
||||
} else {
|
||||
copy(buf, in[:nextBackslash])
|
||||
buf = buf[nextBackslash:]
|
||||
in = in[nextBackslash:]
|
||||
}
|
||||
}
|
||||
|
||||
// Trim the out buffer to the amount that was actually emitted
|
||||
return out[:len(out)-len(buf)], nil
|
||||
}
|
||||
9
vendor/github.com/buger/jsonparser/fuzz.go
generated
vendored
Normal file
9
vendor/github.com/buger/jsonparser/fuzz.go
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
package jsonparser
|
||||
|
||||
func FuzzParseString(data []byte) int {
|
||||
r, err := ParseString(data)
|
||||
if err != nil || r == "" {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
1237
vendor/github.com/buger/jsonparser/parser.go
generated
vendored
Normal file
1237
vendor/github.com/buger/jsonparser/parser.go
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
12
vendor/github.com/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
|
||||
}
|
||||
459
vendor/github.com/fsnotify/fsnotify/backend_inotify.go
generated
vendored
Normal file
459
vendor/github.com/fsnotify/fsnotify/backend_inotify.go
generated
vendored
Normal file
@@ -0,0 +1,459 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package fsnotify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// Store fd here as os.File.Read() will no longer return on close after
|
||||
// calling Fd(). See: https://github.com/golang/go/issues/26439
|
||||
fd int
|
||||
mu sync.Mutex // Map access
|
||||
inotifyFile *os.File
|
||||
watches map[string]*watch // Map of inotify watches (key: path)
|
||||
paths map[int]string // Map of watched paths (key: watch descriptor)
|
||||
done chan struct{} // Channel for sending a "quit message" to the reader goroutine
|
||||
doneResp chan struct{} // Channel to respond to Close
|
||||
}
|
||||
|
||||
// NewWatcher creates a new Watcher.
|
||||
func NewWatcher() (*Watcher, error) {
|
||||
// Create inotify fd
|
||||
// Need to set the FD to nonblocking mode in order for SetDeadline methods to work
|
||||
// Otherwise, blocking i/o operations won't terminate on close
|
||||
fd, errno := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
|
||||
if fd == -1 {
|
||||
return nil, errno
|
||||
}
|
||||
|
||||
w := &Watcher{
|
||||
fd: fd,
|
||||
inotifyFile: os.NewFile(uintptr(fd), ""),
|
||||
watches: make(map[string]*watch),
|
||||
paths: make(map[int]string),
|
||||
Events: make(chan Event),
|
||||
Errors: make(chan error),
|
||||
done: make(chan struct{}),
|
||||
doneResp: make(chan struct{}),
|
||||
}
|
||||
|
||||
go w.readEvents()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Returns true if the event was sent, or false if watcher is closed.
|
||||
func (w *Watcher) sendEvent(e Event) bool {
|
||||
select {
|
||||
case w.Events <- e:
|
||||
return true
|
||||
case <-w.done:
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Returns true if the error was sent, or false if watcher is closed.
|
||||
func (w *Watcher) sendError(err error) bool {
|
||||
select {
|
||||
case w.Errors <- err:
|
||||
return true
|
||||
case <-w.done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) isClosed() bool {
|
||||
select {
|
||||
case <-w.done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Close removes all watches and closes the events channel.
|
||||
func (w *Watcher) Close() error {
|
||||
w.mu.Lock()
|
||||
if w.isClosed() {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send 'close' signal to goroutine, and set the Watcher to closed.
|
||||
close(w.done)
|
||||
w.mu.Unlock()
|
||||
|
||||
// Causes any blocking reads to return with an error, provided the file
|
||||
// still supports deadline operations.
|
||||
err := w.inotifyFile.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for goroutine to close
|
||||
<-w.doneResp
|
||||
|
||||
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 {
|
||||
name = filepath.Clean(name)
|
||||
if w.isClosed() {
|
||||
return errors.New("inotify instance already closed")
|
||||
}
|
||||
|
||||
var flags uint32 = unix.IN_MOVED_TO | unix.IN_MOVED_FROM |
|
||||
unix.IN_CREATE | unix.IN_ATTRIB | unix.IN_MODIFY |
|
||||
unix.IN_MOVE_SELF | unix.IN_DELETE | unix.IN_DELETE_SELF
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
watchEntry := w.watches[name]
|
||||
if watchEntry != nil {
|
||||
flags |= watchEntry.flags | unix.IN_MASK_ADD
|
||||
}
|
||||
wd, errno := unix.InotifyAddWatch(w.fd, name, flags)
|
||||
if wd == -1 {
|
||||
return errno
|
||||
}
|
||||
|
||||
if watchEntry == nil {
|
||||
w.watches[name] = &watch{wd: uint32(wd), flags: flags}
|
||||
w.paths[wd] = name
|
||||
} else {
|
||||
watchEntry.wd = uint32(wd)
|
||||
watchEntry.flags = flags
|
||||
}
|
||||
|
||||
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 {
|
||||
name = filepath.Clean(name)
|
||||
|
||||
// Fetch the watch.
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
watch, ok := w.watches[name]
|
||||
|
||||
// Remove it from inotify.
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrNonExistentWatch, name)
|
||||
}
|
||||
|
||||
// We successfully removed the watch if InotifyRmWatch doesn't return an
|
||||
// error, we need to clean up our internal state to ensure it matches
|
||||
// inotify's kernel state.
|
||||
delete(w.paths, int(watch.wd))
|
||||
delete(w.watches, name)
|
||||
|
||||
// inotify_rm_watch will return EINVAL if the file has been deleted;
|
||||
// the inotify will already have been removed.
|
||||
// watches and pathes are deleted in ignoreLinux() implicitly and asynchronously
|
||||
// by calling inotify_rm_watch() below. e.g. readEvents() goroutine receives IN_IGNORE
|
||||
// so that EINVAL means that the wd is being rm_watch()ed or its file removed
|
||||
// by another thread and we have not received IN_IGNORE event.
|
||||
success, errno := unix.InotifyRmWatch(w.fd, watch.wd)
|
||||
if success == -1 {
|
||||
// TODO: Perhaps it's not helpful to return an error here in every case;
|
||||
// The only two possible errors are:
|
||||
//
|
||||
// - EBADF, which happens when w.fd is not a valid file descriptor
|
||||
// of any kind.
|
||||
// - EINVAL, which is when fd is not an inotify descriptor or wd
|
||||
// is not a valid watch descriptor. Watch descriptors are
|
||||
// invalidated when they are removed explicitly or implicitly;
|
||||
// explicitly by inotify_rm_watch, implicitly when the file they
|
||||
// are watching is deleted.
|
||||
return errno
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WatchList returns all paths added with [Add] (and are not yet removed).
|
||||
func (w *Watcher) WatchList() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
entries := make([]string, 0, len(w.watches))
|
||||
for pathname := range w.watches {
|
||||
entries = append(entries, pathname)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
type watch struct {
|
||||
wd uint32 // Watch descriptor (as returned by the inotify_add_watch() syscall)
|
||||
flags uint32 // inotify flags of this watch (see inotify(7) for the list of valid flags)
|
||||
}
|
||||
|
||||
// readEvents reads from the inotify file descriptor, converts the
|
||||
// received events into Event objects and sends them via the Events channel
|
||||
func (w *Watcher) readEvents() {
|
||||
defer func() {
|
||||
close(w.doneResp)
|
||||
close(w.Errors)
|
||||
close(w.Events)
|
||||
}()
|
||||
|
||||
var (
|
||||
buf [unix.SizeofInotifyEvent * 4096]byte // Buffer for a maximum of 4096 raw events
|
||||
errno error // Syscall errno
|
||||
)
|
||||
for {
|
||||
// See if we have been closed.
|
||||
if w.isClosed() {
|
||||
return
|
||||
}
|
||||
|
||||
n, err := w.inotifyFile.Read(buf[:])
|
||||
switch {
|
||||
case errors.Unwrap(err) == os.ErrClosed:
|
||||
return
|
||||
case err != nil:
|
||||
if !w.sendError(err) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if n < unix.SizeofInotifyEvent {
|
||||
var err error
|
||||
if n == 0 {
|
||||
// If EOF is received. This should really never happen.
|
||||
err = io.EOF
|
||||
} else if n < 0 {
|
||||
// If an error occurred while reading.
|
||||
err = errno
|
||||
} else {
|
||||
// Read was too short.
|
||||
err = errors.New("notify: short read in readEvents()")
|
||||
}
|
||||
if !w.sendError(err) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var offset uint32
|
||||
// We don't know how many events we just read into the buffer
|
||||
// While the offset points to at least one whole event...
|
||||
for offset <= uint32(n-unix.SizeofInotifyEvent) {
|
||||
var (
|
||||
// Point "raw" to the event in the buffer
|
||||
raw = (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))
|
||||
mask = uint32(raw.Mask)
|
||||
nameLen = uint32(raw.Len)
|
||||
)
|
||||
|
||||
if mask&unix.IN_Q_OVERFLOW != 0 {
|
||||
if !w.sendError(ErrEventOverflow) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If the event happened to the watched directory or the watched file, the kernel
|
||||
// doesn't append the filename to the event, but we would like to always fill the
|
||||
// the "Name" field with a valid filename. We retrieve the path of the watch from
|
||||
// the "paths" map.
|
||||
w.mu.Lock()
|
||||
name, ok := w.paths[int(raw.Wd)]
|
||||
// IN_DELETE_SELF occurs when the file/directory being watched is removed.
|
||||
// This is a sign to clean up the maps, otherwise we are no longer in sync
|
||||
// with the inotify kernel state which has already deleted the watch
|
||||
// automatically.
|
||||
if ok && mask&unix.IN_DELETE_SELF == unix.IN_DELETE_SELF {
|
||||
delete(w.paths, int(raw.Wd))
|
||||
delete(w.watches, name)
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
if nameLen > 0 {
|
||||
// Point "bytes" at the first byte of the filename
|
||||
bytes := (*[unix.PathMax]byte)(unsafe.Pointer(&buf[offset+unix.SizeofInotifyEvent]))[:nameLen:nameLen]
|
||||
// The filename is padded with NULL bytes. TrimRight() gets rid of those.
|
||||
name += "/" + strings.TrimRight(string(bytes[0:nameLen]), "\000")
|
||||
}
|
||||
|
||||
event := w.newEvent(name, mask)
|
||||
|
||||
// Send the events that are not ignored on the events channel
|
||||
if mask&unix.IN_IGNORED == 0 {
|
||||
if !w.sendEvent(event) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Move to the next event in the buffer
|
||||
offset += unix.SizeofInotifyEvent + nameLen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newEvent returns an platform-independent Event based on an inotify mask.
|
||||
func (w *Watcher) newEvent(name string, mask uint32) Event {
|
||||
e := Event{Name: name}
|
||||
if mask&unix.IN_CREATE == unix.IN_CREATE || mask&unix.IN_MOVED_TO == unix.IN_MOVED_TO {
|
||||
e.Op |= Create
|
||||
}
|
||||
if mask&unix.IN_DELETE_SELF == unix.IN_DELETE_SELF || mask&unix.IN_DELETE == unix.IN_DELETE {
|
||||
e.Op |= Remove
|
||||
}
|
||||
if mask&unix.IN_MODIFY == unix.IN_MODIFY {
|
||||
e.Op |= Write
|
||||
}
|
||||
if mask&unix.IN_MOVE_SELF == unix.IN_MOVE_SELF || mask&unix.IN_MOVED_FROM == unix.IN_MOVED_FROM {
|
||||
e.Op |= Rename
|
||||
}
|
||||
if mask&unix.IN_ATTRIB == unix.IN_ATTRIB {
|
||||
e.Op |= Chmod
|
||||
}
|
||||
return e
|
||||
}
|
||||
707
vendor/github.com/fsnotify/fsnotify/backend_kqueue.go
generated
vendored
Normal file
707
vendor/github.com/fsnotify/fsnotify/backend_kqueue.go
generated
vendored
Normal file
@@ -0,0 +1,707 @@
|
||||
//go:build freebsd || openbsd || netbsd || dragonfly || darwin
|
||||
// +build freebsd openbsd netbsd dragonfly darwin
|
||||
|
||||
package fsnotify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
done chan struct{}
|
||||
kq int // File descriptor (as returned by the kqueue() syscall).
|
||||
closepipe [2]int // Pipe used for closing.
|
||||
mu sync.Mutex // Protects access to watcher data
|
||||
watches map[string]int // Watched file descriptors (key: path).
|
||||
watchesByDir map[string]map[int]struct{} // Watched file descriptors indexed by the parent directory (key: dirname(path)).
|
||||
userWatches map[string]struct{} // Watches added with Watcher.Add()
|
||||
dirFlags map[string]uint32 // Watched directories to fflags used in kqueue.
|
||||
paths map[int]pathInfo // File descriptors to path names for processing kqueue events.
|
||||
fileExists map[string]struct{} // Keep track of if we know this file exists (to stop duplicate create events).
|
||||
isClosed bool // Set to true when Close() is first called
|
||||
}
|
||||
|
||||
type pathInfo struct {
|
||||
name string
|
||||
isDir bool
|
||||
}
|
||||
|
||||
// NewWatcher creates a new Watcher.
|
||||
func NewWatcher() (*Watcher, error) {
|
||||
kq, closepipe, err := newKqueue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
w := &Watcher{
|
||||
kq: kq,
|
||||
closepipe: closepipe,
|
||||
watches: make(map[string]int),
|
||||
watchesByDir: make(map[string]map[int]struct{}),
|
||||
dirFlags: make(map[string]uint32),
|
||||
paths: make(map[int]pathInfo),
|
||||
fileExists: make(map[string]struct{}),
|
||||
userWatches: make(map[string]struct{}),
|
||||
Events: make(chan Event),
|
||||
Errors: make(chan error),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go w.readEvents()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// newKqueue creates a new kernel event queue and returns a descriptor.
|
||||
//
|
||||
// This registers a new event on closepipe, which will trigger an event when
|
||||
// it's closed. This way we can use kevent() without timeout/polling; without
|
||||
// the closepipe, it would block forever and we wouldn't be able to stop it at
|
||||
// all.
|
||||
func newKqueue() (kq int, closepipe [2]int, err error) {
|
||||
kq, err = unix.Kqueue()
|
||||
if kq == -1 {
|
||||
return kq, closepipe, err
|
||||
}
|
||||
|
||||
// Register the close pipe.
|
||||
err = unix.Pipe(closepipe[:])
|
||||
if err != nil {
|
||||
unix.Close(kq)
|
||||
return kq, closepipe, err
|
||||
}
|
||||
|
||||
// Register changes to listen on the closepipe.
|
||||
changes := make([]unix.Kevent_t, 1)
|
||||
// SetKevent converts int to the platform-specific types.
|
||||
unix.SetKevent(&changes[0], closepipe[0], unix.EVFILT_READ,
|
||||
unix.EV_ADD|unix.EV_ENABLE|unix.EV_ONESHOT)
|
||||
|
||||
ok, err := unix.Kevent(kq, changes, nil, nil)
|
||||
if ok == -1 {
|
||||
unix.Close(kq)
|
||||
unix.Close(closepipe[0])
|
||||
unix.Close(closepipe[1])
|
||||
return kq, closepipe, err
|
||||
}
|
||||
return kq, closepipe, nil
|
||||
}
|
||||
|
||||
// Returns true if the event was sent, or false if watcher is closed.
|
||||
func (w *Watcher) sendEvent(e Event) bool {
|
||||
select {
|
||||
case w.Events <- e:
|
||||
return true
|
||||
case <-w.done:
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Returns true if the error was sent, or false if watcher is closed.
|
||||
func (w *Watcher) sendError(err error) bool {
|
||||
select {
|
||||
case w.Errors <- err:
|
||||
return true
|
||||
case <-w.done:
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Close removes all watches and closes the events channel.
|
||||
func (w *Watcher) Close() error {
|
||||
w.mu.Lock()
|
||||
if w.isClosed {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
w.isClosed = true
|
||||
|
||||
// copy paths to remove while locked
|
||||
pathsToRemove := make([]string, 0, len(w.watches))
|
||||
for name := range w.watches {
|
||||
pathsToRemove = append(pathsToRemove, name)
|
||||
}
|
||||
w.mu.Unlock() // Unlock before calling Remove, which also locks
|
||||
for _, name := range pathsToRemove {
|
||||
w.Remove(name)
|
||||
}
|
||||
|
||||
// Send "quit" message to the reader goroutine.
|
||||
unix.Close(w.closepipe[1])
|
||||
close(w.done)
|
||||
|
||||
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 {
|
||||
w.mu.Lock()
|
||||
w.userWatches[name] = struct{}{}
|
||||
w.mu.Unlock()
|
||||
_, err := w.addWatch(name, noteAllEvents)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
name = filepath.Clean(name)
|
||||
w.mu.Lock()
|
||||
watchfd, ok := w.watches[name]
|
||||
w.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrNonExistentWatch, name)
|
||||
}
|
||||
|
||||
err := w.register([]int{watchfd}, unix.EV_DELETE, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
unix.Close(watchfd)
|
||||
|
||||
w.mu.Lock()
|
||||
isDir := w.paths[watchfd].isDir
|
||||
delete(w.watches, name)
|
||||
delete(w.userWatches, name)
|
||||
|
||||
parentName := filepath.Dir(name)
|
||||
delete(w.watchesByDir[parentName], watchfd)
|
||||
|
||||
if len(w.watchesByDir[parentName]) == 0 {
|
||||
delete(w.watchesByDir, parentName)
|
||||
}
|
||||
|
||||
delete(w.paths, watchfd)
|
||||
delete(w.dirFlags, name)
|
||||
delete(w.fileExists, name)
|
||||
w.mu.Unlock()
|
||||
|
||||
// Find all watched paths that are in this directory that are not external.
|
||||
if isDir {
|
||||
var pathsToRemove []string
|
||||
w.mu.Lock()
|
||||
for fd := range w.watchesByDir[name] {
|
||||
path := w.paths[fd]
|
||||
if _, ok := w.userWatches[path.name]; !ok {
|
||||
pathsToRemove = append(pathsToRemove, path.name)
|
||||
}
|
||||
}
|
||||
w.mu.Unlock()
|
||||
for _, name := range pathsToRemove {
|
||||
// Since these are internal, not much sense in propagating error
|
||||
// to the user, as that will just confuse them with an error about
|
||||
// a path they did not explicitly watch themselves.
|
||||
w.Remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WatchList returns all paths added with [Add] (and are not yet removed).
|
||||
func (w *Watcher) WatchList() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
entries := make([]string, 0, len(w.userWatches))
|
||||
for pathname := range w.userWatches {
|
||||
entries = append(entries, pathname)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// Watch all events (except NOTE_EXTEND, NOTE_LINK, NOTE_REVOKE)
|
||||
const noteAllEvents = unix.NOTE_DELETE | unix.NOTE_WRITE | unix.NOTE_ATTRIB | unix.NOTE_RENAME
|
||||
|
||||
// addWatch adds name to the watched file set.
|
||||
// The flags are interpreted as described in kevent(2).
|
||||
// Returns the real path to the file which was added, if any, which may be different from the one passed in the case of symlinks.
|
||||
func (w *Watcher) addWatch(name string, flags uint32) (string, error) {
|
||||
var isDir bool
|
||||
// Make ./name and name equivalent
|
||||
name = filepath.Clean(name)
|
||||
|
||||
w.mu.Lock()
|
||||
if w.isClosed {
|
||||
w.mu.Unlock()
|
||||
return "", errors.New("kevent instance already closed")
|
||||
}
|
||||
watchfd, alreadyWatching := w.watches[name]
|
||||
// We already have a watch, but we can still override flags.
|
||||
if alreadyWatching {
|
||||
isDir = w.paths[watchfd].isDir
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
if !alreadyWatching {
|
||||
fi, err := os.Lstat(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Don't watch sockets or named pipes
|
||||
if (fi.Mode()&os.ModeSocket == os.ModeSocket) || (fi.Mode()&os.ModeNamedPipe == os.ModeNamedPipe) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Follow Symlinks
|
||||
//
|
||||
// Linux can add unresolvable symlinks to the watch list without issue,
|
||||
// and Windows can't do symlinks period. To maintain consistency, we
|
||||
// will act like everything is fine if the link can't be resolved.
|
||||
// There will simply be no file events for broken symlinks. Hence the
|
||||
// returns of nil on errors.
|
||||
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
name, err = filepath.EvalSymlinks(name)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
_, alreadyWatching = w.watches[name]
|
||||
w.mu.Unlock()
|
||||
|
||||
if alreadyWatching {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
fi, err = os.Lstat(name)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Retry on EINTR; open() can return EINTR in practice on macOS.
|
||||
// See #354, and go issues 11180 and 39237.
|
||||
for {
|
||||
watchfd, err = unix.Open(name, openMode, 0)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
continue
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
isDir = fi.IsDir()
|
||||
}
|
||||
|
||||
err := w.register([]int{watchfd}, unix.EV_ADD|unix.EV_CLEAR|unix.EV_ENABLE, flags)
|
||||
if err != nil {
|
||||
unix.Close(watchfd)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !alreadyWatching {
|
||||
w.mu.Lock()
|
||||
parentName := filepath.Dir(name)
|
||||
w.watches[name] = watchfd
|
||||
|
||||
watchesByDir, ok := w.watchesByDir[parentName]
|
||||
if !ok {
|
||||
watchesByDir = make(map[int]struct{}, 1)
|
||||
w.watchesByDir[parentName] = watchesByDir
|
||||
}
|
||||
watchesByDir[watchfd] = struct{}{}
|
||||
|
||||
w.paths[watchfd] = pathInfo{name: name, isDir: isDir}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
if isDir {
|
||||
// Watch the directory if it has not been watched before,
|
||||
// or if it was watched before, but perhaps only a NOTE_DELETE (watchDirectoryFiles)
|
||||
w.mu.Lock()
|
||||
|
||||
watchDir := (flags&unix.NOTE_WRITE) == unix.NOTE_WRITE &&
|
||||
(!alreadyWatching || (w.dirFlags[name]&unix.NOTE_WRITE) != unix.NOTE_WRITE)
|
||||
// Store flags so this watch can be updated later
|
||||
w.dirFlags[name] = flags
|
||||
w.mu.Unlock()
|
||||
|
||||
if watchDir {
|
||||
if err := w.watchDirectoryFiles(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// readEvents reads from kqueue and converts the received kevents into
|
||||
// Event values that it sends down the Events channel.
|
||||
func (w *Watcher) readEvents() {
|
||||
defer func() {
|
||||
err := unix.Close(w.kq)
|
||||
if err != nil {
|
||||
w.Errors <- err
|
||||
}
|
||||
unix.Close(w.closepipe[0])
|
||||
close(w.Events)
|
||||
close(w.Errors)
|
||||
}()
|
||||
|
||||
eventBuffer := make([]unix.Kevent_t, 10)
|
||||
for closed := false; !closed; {
|
||||
kevents, err := w.read(eventBuffer)
|
||||
// EINTR is okay, the syscall was interrupted before timeout expired.
|
||||
if err != nil && err != unix.EINTR {
|
||||
if !w.sendError(fmt.Errorf("fsnotify.readEvents: %w", err)) {
|
||||
closed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Flush the events we received to the Events channel
|
||||
for _, kevent := range kevents {
|
||||
var (
|
||||
watchfd = int(kevent.Ident)
|
||||
mask = uint32(kevent.Fflags)
|
||||
)
|
||||
|
||||
// Shut down the loop when the pipe is closed, but only after all
|
||||
// other events have been processed.
|
||||
if watchfd == w.closepipe[0] {
|
||||
closed = true
|
||||
continue
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
path := w.paths[watchfd]
|
||||
w.mu.Unlock()
|
||||
|
||||
event := w.newEvent(path.name, mask)
|
||||
|
||||
if path.isDir && !event.Has(Remove) {
|
||||
// Double check to make sure the directory exists. This can
|
||||
// happen when we do a rm -fr on a recursively watched folders
|
||||
// and we receive a modification event first but the folder has
|
||||
// been deleted and later receive the delete event.
|
||||
if _, err := os.Lstat(event.Name); os.IsNotExist(err) {
|
||||
event.Op |= Remove
|
||||
}
|
||||
}
|
||||
|
||||
if event.Has(Rename) || event.Has(Remove) {
|
||||
w.Remove(event.Name)
|
||||
w.mu.Lock()
|
||||
delete(w.fileExists, event.Name)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
if path.isDir && event.Has(Write) && !event.Has(Remove) {
|
||||
w.sendDirectoryChangeEvents(event.Name)
|
||||
} else {
|
||||
if !w.sendEvent(event) {
|
||||
closed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if event.Has(Remove) {
|
||||
// Look for a file that may have overwritten this.
|
||||
// For example, mv f1 f2 will delete f2, then create f2.
|
||||
if path.isDir {
|
||||
fileDir := filepath.Clean(event.Name)
|
||||
w.mu.Lock()
|
||||
_, found := w.watches[fileDir]
|
||||
w.mu.Unlock()
|
||||
if found {
|
||||
// make sure the directory exists before we watch for changes. When we
|
||||
// do a recursive watch and perform rm -fr, the parent directory might
|
||||
// have gone missing, ignore the missing directory and let the
|
||||
// upcoming delete event remove the watch from the parent directory.
|
||||
if _, err := os.Lstat(fileDir); err == nil {
|
||||
w.sendDirectoryChangeEvents(fileDir)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filePath := filepath.Clean(event.Name)
|
||||
if fileInfo, err := os.Lstat(filePath); err == nil {
|
||||
w.sendFileCreatedEventIfNew(filePath, fileInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newEvent returns an platform-independent Event based on kqueue Fflags.
|
||||
func (w *Watcher) newEvent(name string, mask uint32) Event {
|
||||
e := Event{Name: name}
|
||||
if mask&unix.NOTE_DELETE == unix.NOTE_DELETE {
|
||||
e.Op |= Remove
|
||||
}
|
||||
if mask&unix.NOTE_WRITE == unix.NOTE_WRITE {
|
||||
e.Op |= Write
|
||||
}
|
||||
if mask&unix.NOTE_RENAME == unix.NOTE_RENAME {
|
||||
e.Op |= Rename
|
||||
}
|
||||
if mask&unix.NOTE_ATTRIB == unix.NOTE_ATTRIB {
|
||||
e.Op |= Chmod
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// watchDirectoryFiles to mimic inotify when adding a watch on a directory
|
||||
func (w *Watcher) watchDirectoryFiles(dirPath string) error {
|
||||
// Get all files
|
||||
files, err := ioutil.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, fileInfo := range files {
|
||||
path := filepath.Join(dirPath, fileInfo.Name())
|
||||
|
||||
cleanPath, err := w.internalWatch(path, fileInfo)
|
||||
if err != nil {
|
||||
// No permission to read the file; that's not a problem: just skip.
|
||||
// But do add it to w.fileExists to prevent it from being picked up
|
||||
// as a "new" file later (it still shows up in the directory
|
||||
// listing).
|
||||
switch {
|
||||
case errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM):
|
||||
cleanPath = filepath.Clean(path)
|
||||
default:
|
||||
return fmt.Errorf("%q: %w", filepath.Join(dirPath, fileInfo.Name()), err)
|
||||
}
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.fileExists[cleanPath] = struct{}{}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search the directory for new files and send an event for them.
|
||||
//
|
||||
// This functionality is to have the BSD watcher match the inotify, which sends
|
||||
// a create event for files created in a watched directory.
|
||||
func (w *Watcher) sendDirectoryChangeEvents(dir string) {
|
||||
// Get all files
|
||||
files, err := ioutil.ReadDir(dir)
|
||||
if err != nil {
|
||||
if !w.sendError(fmt.Errorf("fsnotify.sendDirectoryChangeEvents: %w", err)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Search for new files
|
||||
for _, fi := range files {
|
||||
err := w.sendFileCreatedEventIfNew(filepath.Join(dir, fi.Name()), fi)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendFileCreatedEvent sends a create event if the file isn't already being tracked.
|
||||
func (w *Watcher) sendFileCreatedEventIfNew(filePath string, fileInfo os.FileInfo) (err error) {
|
||||
w.mu.Lock()
|
||||
_, doesExist := w.fileExists[filePath]
|
||||
w.mu.Unlock()
|
||||
if !doesExist {
|
||||
if !w.sendEvent(Event{Name: filePath, Op: Create}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// like watchDirectoryFiles (but without doing another ReadDir)
|
||||
filePath, err = w.internalWatch(filePath, fileInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.fileExists[filePath] = struct{}{}
|
||||
w.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Watcher) internalWatch(name string, fileInfo os.FileInfo) (string, error) {
|
||||
if fileInfo.IsDir() {
|
||||
// mimic Linux providing delete events for subdirectories
|
||||
// but preserve the flags used if currently watching subdirectory
|
||||
w.mu.Lock()
|
||||
flags := w.dirFlags[name]
|
||||
w.mu.Unlock()
|
||||
|
||||
flags |= unix.NOTE_DELETE | unix.NOTE_RENAME
|
||||
return w.addWatch(name, flags)
|
||||
}
|
||||
|
||||
// watch file to mimic Linux inotify
|
||||
return w.addWatch(name, noteAllEvents)
|
||||
}
|
||||
|
||||
// Register events with the queue.
|
||||
func (w *Watcher) register(fds []int, flags int, fflags uint32) error {
|
||||
changes := make([]unix.Kevent_t, len(fds))
|
||||
for i, fd := range fds {
|
||||
// SetKevent converts int to the platform-specific types.
|
||||
unix.SetKevent(&changes[i], fd, unix.EVFILT_VNODE, flags)
|
||||
changes[i].Fflags = fflags
|
||||
}
|
||||
|
||||
// Register the events.
|
||||
success, err := unix.Kevent(w.kq, changes, nil, nil)
|
||||
if success == -1 {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// read retrieves pending events, or waits until an event occurs.
|
||||
func (w *Watcher) read(events []unix.Kevent_t) ([]unix.Kevent_t, error) {
|
||||
n, err := unix.Kevent(w.kq, nil, events, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events[0:n], nil
|
||||
}
|
||||
66
vendor/github.com/fsnotify/fsnotify/backend_other.go
generated
vendored
Normal file
66
vendor/github.com/fsnotify/fsnotify/backend_other.go
generated
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
//go:build !darwin && !dragonfly && !freebsd && !openbsd && !linux && !netbsd && !solaris && !windows
|
||||
// +build !darwin,!dragonfly,!freebsd,!openbsd,!linux,!netbsd,!solaris,!windows
|
||||
|
||||
package fsnotify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Watcher watches a set of files, delivering events to a channel.
|
||||
type Watcher struct{}
|
||||
|
||||
// NewWatcher creates a new Watcher.
|
||||
func NewWatcher() (*Watcher, error) {
|
||||
return nil, fmt.Errorf("fsnotify not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
746
vendor/github.com/fsnotify/fsnotify/backend_windows.go
generated
vendored
Normal file
746
vendor/github.com/fsnotify/fsnotify/backend_windows.go
generated
vendored
Normal file
@@ -0,0 +1,746 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package fsnotify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
port windows.Handle // Handle to completion port
|
||||
input chan *input // Inputs to the reader are sent on this channel
|
||||
quit chan chan<- error
|
||||
|
||||
mu sync.Mutex // Protects access to watches, isClosed
|
||||
watches watchMap // Map of watches (key: i-number)
|
||||
isClosed bool // Set to true when Close() is first called
|
||||
}
|
||||
|
||||
// NewWatcher creates a new Watcher.
|
||||
func NewWatcher() (*Watcher, error) {
|
||||
port, err := windows.CreateIoCompletionPort(windows.InvalidHandle, 0, 0, 0)
|
||||
if err != nil {
|
||||
return nil, os.NewSyscallError("CreateIoCompletionPort", err)
|
||||
}
|
||||
w := &Watcher{
|
||||
port: port,
|
||||
watches: make(watchMap),
|
||||
input: make(chan *input, 1),
|
||||
Events: make(chan Event, 50),
|
||||
Errors: make(chan error),
|
||||
quit: make(chan chan<- error, 1),
|
||||
}
|
||||
go w.readEvents()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *Watcher) sendEvent(name string, mask uint64) bool {
|
||||
if mask == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
event := w.newEvent(name, uint32(mask))
|
||||
select {
|
||||
case ch := <-w.quit:
|
||||
w.quit <- ch
|
||||
case w.Events <- event:
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Returns true if the error was sent, or false if watcher is closed.
|
||||
func (w *Watcher) sendError(err error) bool {
|
||||
select {
|
||||
case w.Errors <- err:
|
||||
return true
|
||||
case <-w.quit:
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Close removes all watches and closes the events channel.
|
||||
func (w *Watcher) Close() error {
|
||||
w.mu.Lock()
|
||||
if w.isClosed {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
w.isClosed = true
|
||||
w.mu.Unlock()
|
||||
|
||||
// Send "quit" message to the reader goroutine
|
||||
ch := make(chan error)
|
||||
w.quit <- ch
|
||||
if err := w.wakeupReader(); err != nil {
|
||||
return err
|
||||
}
|
||||
return <-ch
|
||||
}
|
||||
|
||||
// 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 {
|
||||
w.mu.Lock()
|
||||
if w.isClosed {
|
||||
w.mu.Unlock()
|
||||
return errors.New("watcher already closed")
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
in := &input{
|
||||
op: opAddWatch,
|
||||
path: filepath.Clean(name),
|
||||
flags: sysFSALLEVENTS,
|
||||
reply: make(chan error),
|
||||
}
|
||||
w.input <- in
|
||||
if err := w.wakeupReader(); err != nil {
|
||||
return err
|
||||
}
|
||||
return <-in.reply
|
||||
}
|
||||
|
||||
// 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 {
|
||||
in := &input{
|
||||
op: opRemoveWatch,
|
||||
path: filepath.Clean(name),
|
||||
reply: make(chan error),
|
||||
}
|
||||
w.input <- in
|
||||
if err := w.wakeupReader(); err != nil {
|
||||
return err
|
||||
}
|
||||
return <-in.reply
|
||||
}
|
||||
|
||||
// WatchList returns all paths added with [Add] (and are not yet removed).
|
||||
func (w *Watcher) WatchList() []string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
entries := make([]string, 0, len(w.watches))
|
||||
for _, entry := range w.watches {
|
||||
for _, watchEntry := range entry {
|
||||
entries = append(entries, watchEntry.path)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// These options are from the old golang.org/x/exp/winfsnotify, where you could
|
||||
// add various options to the watch. This has long since been removed.
|
||||
//
|
||||
// The "sys" in the name is misleading as they're not part of any "system".
|
||||
//
|
||||
// This should all be removed at some point, and just use windows.FILE_NOTIFY_*
|
||||
const (
|
||||
sysFSALLEVENTS = 0xfff
|
||||
sysFSATTRIB = 0x4
|
||||
sysFSCREATE = 0x100
|
||||
sysFSDELETE = 0x200
|
||||
sysFSDELETESELF = 0x400
|
||||
sysFSMODIFY = 0x2
|
||||
sysFSMOVE = 0xc0
|
||||
sysFSMOVEDFROM = 0x40
|
||||
sysFSMOVEDTO = 0x80
|
||||
sysFSMOVESELF = 0x800
|
||||
sysFSIGNORED = 0x8000
|
||||
)
|
||||
|
||||
func (w *Watcher) newEvent(name string, mask uint32) Event {
|
||||
e := Event{Name: name}
|
||||
if mask&sysFSCREATE == sysFSCREATE || mask&sysFSMOVEDTO == sysFSMOVEDTO {
|
||||
e.Op |= Create
|
||||
}
|
||||
if mask&sysFSDELETE == sysFSDELETE || mask&sysFSDELETESELF == sysFSDELETESELF {
|
||||
e.Op |= Remove
|
||||
}
|
||||
if mask&sysFSMODIFY == sysFSMODIFY {
|
||||
e.Op |= Write
|
||||
}
|
||||
if mask&sysFSMOVE == sysFSMOVE || mask&sysFSMOVESELF == sysFSMOVESELF || mask&sysFSMOVEDFROM == sysFSMOVEDFROM {
|
||||
e.Op |= Rename
|
||||
}
|
||||
if mask&sysFSATTRIB == sysFSATTRIB {
|
||||
e.Op |= Chmod
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
const (
|
||||
opAddWatch = iota
|
||||
opRemoveWatch
|
||||
)
|
||||
|
||||
const (
|
||||
provisional uint64 = 1 << (32 + iota)
|
||||
)
|
||||
|
||||
type input struct {
|
||||
op int
|
||||
path string
|
||||
flags uint32
|
||||
reply chan error
|
||||
}
|
||||
|
||||
type inode struct {
|
||||
handle windows.Handle
|
||||
volume uint32
|
||||
index uint64
|
||||
}
|
||||
|
||||
type watch struct {
|
||||
ov windows.Overlapped
|
||||
ino *inode // i-number
|
||||
path string // Directory path
|
||||
mask uint64 // Directory itself is being watched with these notify flags
|
||||
names map[string]uint64 // Map of names being watched and their notify flags
|
||||
rename string // Remembers the old name while renaming a file
|
||||
buf [65536]byte // 64K buffer
|
||||
}
|
||||
|
||||
type (
|
||||
indexMap map[uint64]*watch
|
||||
watchMap map[uint32]indexMap
|
||||
)
|
||||
|
||||
func (w *Watcher) wakeupReader() error {
|
||||
err := windows.PostQueuedCompletionStatus(w.port, 0, 0, nil)
|
||||
if err != nil {
|
||||
return os.NewSyscallError("PostQueuedCompletionStatus", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Watcher) getDir(pathname string) (dir string, err error) {
|
||||
attr, err := windows.GetFileAttributes(windows.StringToUTF16Ptr(pathname))
|
||||
if err != nil {
|
||||
return "", os.NewSyscallError("GetFileAttributes", err)
|
||||
}
|
||||
if attr&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
|
||||
dir = pathname
|
||||
} else {
|
||||
dir, _ = filepath.Split(pathname)
|
||||
dir = filepath.Clean(dir)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (w *Watcher) getIno(path string) (ino *inode, err error) {
|
||||
h, err := windows.CreateFile(windows.StringToUTF16Ptr(path),
|
||||
windows.FILE_LIST_DIRECTORY,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil, windows.OPEN_EXISTING,
|
||||
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OVERLAPPED, 0)
|
||||
if err != nil {
|
||||
return nil, os.NewSyscallError("CreateFile", err)
|
||||
}
|
||||
|
||||
var fi windows.ByHandleFileInformation
|
||||
err = windows.GetFileInformationByHandle(h, &fi)
|
||||
if err != nil {
|
||||
windows.CloseHandle(h)
|
||||
return nil, os.NewSyscallError("GetFileInformationByHandle", err)
|
||||
}
|
||||
ino = &inode{
|
||||
handle: h,
|
||||
volume: fi.VolumeSerialNumber,
|
||||
index: uint64(fi.FileIndexHigh)<<32 | uint64(fi.FileIndexLow),
|
||||
}
|
||||
return ino, nil
|
||||
}
|
||||
|
||||
// Must run within the I/O thread.
|
||||
func (m watchMap) get(ino *inode) *watch {
|
||||
if i := m[ino.volume]; i != nil {
|
||||
return i[ino.index]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Must run within the I/O thread.
|
||||
func (m watchMap) set(ino *inode, watch *watch) {
|
||||
i := m[ino.volume]
|
||||
if i == nil {
|
||||
i = make(indexMap)
|
||||
m[ino.volume] = i
|
||||
}
|
||||
i[ino.index] = watch
|
||||
}
|
||||
|
||||
// Must run within the I/O thread.
|
||||
func (w *Watcher) addWatch(pathname string, flags uint64) error {
|
||||
dir, err := w.getDir(pathname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ino, err := w.getIno(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.mu.Lock()
|
||||
watchEntry := w.watches.get(ino)
|
||||
w.mu.Unlock()
|
||||
if watchEntry == nil {
|
||||
_, err := windows.CreateIoCompletionPort(ino.handle, w.port, 0, 0)
|
||||
if err != nil {
|
||||
windows.CloseHandle(ino.handle)
|
||||
return os.NewSyscallError("CreateIoCompletionPort", err)
|
||||
}
|
||||
watchEntry = &watch{
|
||||
ino: ino,
|
||||
path: dir,
|
||||
names: make(map[string]uint64),
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.watches.set(ino, watchEntry)
|
||||
w.mu.Unlock()
|
||||
flags |= provisional
|
||||
} else {
|
||||
windows.CloseHandle(ino.handle)
|
||||
}
|
||||
if pathname == dir {
|
||||
watchEntry.mask |= flags
|
||||
} else {
|
||||
watchEntry.names[filepath.Base(pathname)] |= flags
|
||||
}
|
||||
|
||||
err = w.startRead(watchEntry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pathname == dir {
|
||||
watchEntry.mask &= ^provisional
|
||||
} else {
|
||||
watchEntry.names[filepath.Base(pathname)] &= ^provisional
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Must run within the I/O thread.
|
||||
func (w *Watcher) remWatch(pathname string) error {
|
||||
dir, err := w.getDir(pathname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ino, err := w.getIno(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
watch := w.watches.get(ino)
|
||||
w.mu.Unlock()
|
||||
|
||||
err = windows.CloseHandle(ino.handle)
|
||||
if err != nil {
|
||||
w.sendError(os.NewSyscallError("CloseHandle", err))
|
||||
}
|
||||
if watch == nil {
|
||||
return fmt.Errorf("%w: %s", ErrNonExistentWatch, pathname)
|
||||
}
|
||||
if pathname == dir {
|
||||
w.sendEvent(watch.path, watch.mask&sysFSIGNORED)
|
||||
watch.mask = 0
|
||||
} else {
|
||||
name := filepath.Base(pathname)
|
||||
w.sendEvent(filepath.Join(watch.path, name), watch.names[name]&sysFSIGNORED)
|
||||
delete(watch.names, name)
|
||||
}
|
||||
|
||||
return w.startRead(watch)
|
||||
}
|
||||
|
||||
// Must run within the I/O thread.
|
||||
func (w *Watcher) deleteWatch(watch *watch) {
|
||||
for name, mask := range watch.names {
|
||||
if mask&provisional == 0 {
|
||||
w.sendEvent(filepath.Join(watch.path, name), mask&sysFSIGNORED)
|
||||
}
|
||||
delete(watch.names, name)
|
||||
}
|
||||
if watch.mask != 0 {
|
||||
if watch.mask&provisional == 0 {
|
||||
w.sendEvent(watch.path, watch.mask&sysFSIGNORED)
|
||||
}
|
||||
watch.mask = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Must run within the I/O thread.
|
||||
func (w *Watcher) startRead(watch *watch) error {
|
||||
err := windows.CancelIo(watch.ino.handle)
|
||||
if err != nil {
|
||||
w.sendError(os.NewSyscallError("CancelIo", err))
|
||||
w.deleteWatch(watch)
|
||||
}
|
||||
mask := w.toWindowsFlags(watch.mask)
|
||||
for _, m := range watch.names {
|
||||
mask |= w.toWindowsFlags(m)
|
||||
}
|
||||
if mask == 0 {
|
||||
err := windows.CloseHandle(watch.ino.handle)
|
||||
if err != nil {
|
||||
w.sendError(os.NewSyscallError("CloseHandle", err))
|
||||
}
|
||||
w.mu.Lock()
|
||||
delete(w.watches[watch.ino.volume], watch.ino.index)
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
rdErr := windows.ReadDirectoryChanges(watch.ino.handle, &watch.buf[0],
|
||||
uint32(unsafe.Sizeof(watch.buf)), false, mask, nil, &watch.ov, 0)
|
||||
if rdErr != nil {
|
||||
err := os.NewSyscallError("ReadDirectoryChanges", rdErr)
|
||||
if rdErr == windows.ERROR_ACCESS_DENIED && watch.mask&provisional == 0 {
|
||||
// Watched directory was probably removed
|
||||
w.sendEvent(watch.path, watch.mask&sysFSDELETESELF)
|
||||
err = nil
|
||||
}
|
||||
w.deleteWatch(watch)
|
||||
w.startRead(watch)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readEvents reads from the I/O completion port, converts the
|
||||
// received events into Event objects and sends them via the Events channel.
|
||||
// Entry point to the I/O thread.
|
||||
func (w *Watcher) readEvents() {
|
||||
var (
|
||||
n uint32
|
||||
key uintptr
|
||||
ov *windows.Overlapped
|
||||
)
|
||||
runtime.LockOSThread()
|
||||
|
||||
for {
|
||||
qErr := windows.GetQueuedCompletionStatus(w.port, &n, &key, &ov, windows.INFINITE)
|
||||
// This error is handled after the watch == nil check below. NOTE: this
|
||||
// seems odd, note sure if it's correct.
|
||||
|
||||
watch := (*watch)(unsafe.Pointer(ov))
|
||||
if watch == nil {
|
||||
select {
|
||||
case ch := <-w.quit:
|
||||
w.mu.Lock()
|
||||
var indexes []indexMap
|
||||
for _, index := range w.watches {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
w.mu.Unlock()
|
||||
for _, index := range indexes {
|
||||
for _, watch := range index {
|
||||
w.deleteWatch(watch)
|
||||
w.startRead(watch)
|
||||
}
|
||||
}
|
||||
|
||||
err := windows.CloseHandle(w.port)
|
||||
if err != nil {
|
||||
err = os.NewSyscallError("CloseHandle", err)
|
||||
}
|
||||
close(w.Events)
|
||||
close(w.Errors)
|
||||
ch <- err
|
||||
return
|
||||
case in := <-w.input:
|
||||
switch in.op {
|
||||
case opAddWatch:
|
||||
in.reply <- w.addWatch(in.path, uint64(in.flags))
|
||||
case opRemoveWatch:
|
||||
in.reply <- w.remWatch(in.path)
|
||||
}
|
||||
default:
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch qErr {
|
||||
case windows.ERROR_MORE_DATA:
|
||||
if watch == nil {
|
||||
w.sendError(errors.New("ERROR_MORE_DATA has unexpectedly null lpOverlapped buffer"))
|
||||
} else {
|
||||
// The i/o succeeded but the buffer is full.
|
||||
// In theory we should be building up a full packet.
|
||||
// In practice we can get away with just carrying on.
|
||||
n = uint32(unsafe.Sizeof(watch.buf))
|
||||
}
|
||||
case windows.ERROR_ACCESS_DENIED:
|
||||
// Watched directory was probably removed
|
||||
w.sendEvent(watch.path, watch.mask&sysFSDELETESELF)
|
||||
w.deleteWatch(watch)
|
||||
w.startRead(watch)
|
||||
continue
|
||||
case windows.ERROR_OPERATION_ABORTED:
|
||||
// CancelIo was called on this handle
|
||||
continue
|
||||
default:
|
||||
w.sendError(os.NewSyscallError("GetQueuedCompletionPort", qErr))
|
||||
continue
|
||||
case nil:
|
||||
}
|
||||
|
||||
var offset uint32
|
||||
for {
|
||||
if n == 0 {
|
||||
w.sendError(errors.New("short read in readEvents()"))
|
||||
break
|
||||
}
|
||||
|
||||
// Point "raw" to the event in the buffer
|
||||
raw := (*windows.FileNotifyInformation)(unsafe.Pointer(&watch.buf[offset]))
|
||||
|
||||
// Create a buf that is the size of the path name
|
||||
size := int(raw.FileNameLength / 2)
|
||||
var buf []uint16
|
||||
// TODO: Use unsafe.Slice in Go 1.17; https://stackoverflow.com/questions/51187973
|
||||
sh := (*reflect.SliceHeader)(unsafe.Pointer(&buf))
|
||||
sh.Data = uintptr(unsafe.Pointer(&raw.FileName))
|
||||
sh.Len = size
|
||||
sh.Cap = size
|
||||
name := windows.UTF16ToString(buf)
|
||||
fullname := filepath.Join(watch.path, name)
|
||||
|
||||
var mask uint64
|
||||
switch raw.Action {
|
||||
case windows.FILE_ACTION_REMOVED:
|
||||
mask = sysFSDELETESELF
|
||||
case windows.FILE_ACTION_MODIFIED:
|
||||
mask = sysFSMODIFY
|
||||
case windows.FILE_ACTION_RENAMED_OLD_NAME:
|
||||
watch.rename = name
|
||||
case windows.FILE_ACTION_RENAMED_NEW_NAME:
|
||||
// Update saved path of all sub-watches.
|
||||
old := filepath.Join(watch.path, watch.rename)
|
||||
w.mu.Lock()
|
||||
for _, watchMap := range w.watches {
|
||||
for _, ww := range watchMap {
|
||||
if strings.HasPrefix(ww.path, old) {
|
||||
ww.path = filepath.Join(fullname, strings.TrimPrefix(ww.path, old))
|
||||
}
|
||||
}
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
if watch.names[watch.rename] != 0 {
|
||||
watch.names[name] |= watch.names[watch.rename]
|
||||
delete(watch.names, watch.rename)
|
||||
mask = sysFSMOVESELF
|
||||
}
|
||||
}
|
||||
|
||||
sendNameEvent := func() {
|
||||
w.sendEvent(fullname, watch.names[name]&mask)
|
||||
}
|
||||
if raw.Action != windows.FILE_ACTION_RENAMED_NEW_NAME {
|
||||
sendNameEvent()
|
||||
}
|
||||
if raw.Action == windows.FILE_ACTION_REMOVED {
|
||||
w.sendEvent(fullname, watch.names[name]&sysFSIGNORED)
|
||||
delete(watch.names, name)
|
||||
}
|
||||
|
||||
w.sendEvent(fullname, watch.mask&w.toFSnotifyFlags(raw.Action))
|
||||
if raw.Action == windows.FILE_ACTION_RENAMED_NEW_NAME {
|
||||
fullname = filepath.Join(watch.path, watch.rename)
|
||||
sendNameEvent()
|
||||
}
|
||||
|
||||
// Move to the next event in the buffer
|
||||
if raw.NextEntryOffset == 0 {
|
||||
break
|
||||
}
|
||||
offset += raw.NextEntryOffset
|
||||
|
||||
// Error!
|
||||
if offset >= n {
|
||||
w.sendError(errors.New(
|
||||
"Windows system assumed buffer larger than it is, events have likely been missed."))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := w.startRead(watch); err != nil {
|
||||
w.sendError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) toWindowsFlags(mask uint64) uint32 {
|
||||
var m uint32
|
||||
if mask&sysFSMODIFY != 0 {
|
||||
m |= windows.FILE_NOTIFY_CHANGE_LAST_WRITE
|
||||
}
|
||||
if mask&sysFSATTRIB != 0 {
|
||||
m |= windows.FILE_NOTIFY_CHANGE_ATTRIBUTES
|
||||
}
|
||||
if mask&(sysFSMOVE|sysFSCREATE|sysFSDELETE) != 0 {
|
||||
m |= windows.FILE_NOTIFY_CHANGE_FILE_NAME | windows.FILE_NOTIFY_CHANGE_DIR_NAME
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (w *Watcher) toFSnotifyFlags(action uint32) uint64 {
|
||||
switch action {
|
||||
case windows.FILE_ACTION_ADDED:
|
||||
return sysFSCREATE
|
||||
case windows.FILE_ACTION_REMOVED:
|
||||
return sysFSDELETE
|
||||
case windows.FILE_ACTION_MODIFIED:
|
||||
return sysFSMODIFY
|
||||
case windows.FILE_ACTION_RENAMED_OLD_NAME:
|
||||
return sysFSMOVEDFROM
|
||||
case windows.FILE_ACTION_RENAMED_NEW_NAME:
|
||||
return sysFSMOVEDTO
|
||||
}
|
||||
return 0
|
||||
}
|
||||
81
vendor/github.com/fsnotify/fsnotify/fsnotify.go
generated
vendored
Normal file
81
vendor/github.com/fsnotify/fsnotify/fsnotify.go
generated
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
//go:build !plan9
|
||||
// +build !plan9
|
||||
|
||||
// Package fsnotify provides a cross-platform interface for file system
|
||||
// notifications.
|
||||
package fsnotify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Event represents a file system notification.
|
||||
type Event struct {
|
||||
// Path to the file or directory.
|
||||
//
|
||||
// Paths are relative to the input; for example with Add("dir") the Name
|
||||
// will be set to "dir/file" if you create that file, but if you use
|
||||
// Add("/path/to/dir") it will be "/path/to/dir/file".
|
||||
Name string
|
||||
|
||||
// File operation that triggered the event.
|
||||
//
|
||||
// This is a bitmask and some systems may send multiple operations at once.
|
||||
// Use the Event.Has() method instead of comparing with ==.
|
||||
Op Op
|
||||
}
|
||||
|
||||
// Op describes a set of file operations.
|
||||
type Op uint32
|
||||
|
||||
// The operations fsnotify can trigger; see the documentation on [Watcher] for a
|
||||
// full description, and check them with [Event.Has].
|
||||
const (
|
||||
Create Op = 1 << iota
|
||||
Write
|
||||
Remove
|
||||
Rename
|
||||
Chmod
|
||||
)
|
||||
|
||||
// Common errors that can be reported by a watcher
|
||||
var (
|
||||
ErrNonExistentWatch = errors.New("can't remove non-existent watcher")
|
||||
ErrEventOverflow = errors.New("fsnotify queue overflow")
|
||||
)
|
||||
|
||||
func (op Op) String() string {
|
||||
var b strings.Builder
|
||||
if op.Has(Create) {
|
||||
b.WriteString("|CREATE")
|
||||
}
|
||||
if op.Has(Remove) {
|
||||
b.WriteString("|REMOVE")
|
||||
}
|
||||
if op.Has(Write) {
|
||||
b.WriteString("|WRITE")
|
||||
}
|
||||
if op.Has(Rename) {
|
||||
b.WriteString("|RENAME")
|
||||
}
|
||||
if op.Has(Chmod) {
|
||||
b.WriteString("|CHMOD")
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "[no events]"
|
||||
}
|
||||
return b.String()[1:]
|
||||
}
|
||||
|
||||
// Has reports if this operation has the given operation.
|
||||
func (o Op) Has(h Op) bool { return o&h == h }
|
||||
|
||||
// Has reports if this event has the given operation.
|
||||
func (e Event) Has(op Op) bool { return e.Op.Has(op) }
|
||||
|
||||
// String returns a string representation of the event with their path.
|
||||
func (e Event) String() string {
|
||||
return fmt.Sprintf("%-13s %q", e.Op.String(), e.Name)
|
||||
}
|
||||
208
vendor/github.com/fsnotify/fsnotify/mkdoc.zsh
generated
vendored
Normal file
208
vendor/github.com/fsnotify/fsnotify/mkdoc.zsh
generated
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env zsh
|
||||
[ "${ZSH_VERSION:-}" = "" ] && echo >&2 "Only works with zsh" && exit 1
|
||||
setopt err_exit no_unset pipefail extended_glob
|
||||
|
||||
# Simple script to update the godoc comments on all watchers. Probably took me
|
||||
# more time to write this than doing it manually, but ah well 🙃
|
||||
|
||||
watcher=$(<<EOF
|
||||
// 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
|
||||
EOF
|
||||
)
|
||||
|
||||
new=$(<<EOF
|
||||
// NewWatcher creates a new Watcher.
|
||||
EOF
|
||||
)
|
||||
|
||||
add=$(<<EOF
|
||||
// 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].
|
||||
EOF
|
||||
)
|
||||
|
||||
remove=$(<<EOF
|
||||
// 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].
|
||||
EOF
|
||||
)
|
||||
|
||||
close=$(<<EOF
|
||||
// Close removes all watches and closes the events channel.
|
||||
EOF
|
||||
)
|
||||
|
||||
watchlist=$(<<EOF
|
||||
// WatchList returns all paths added with [Add] (and are not yet removed).
|
||||
EOF
|
||||
)
|
||||
|
||||
events=$(<<EOF
|
||||
// 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.
|
||||
EOF
|
||||
)
|
||||
|
||||
errors=$(<<EOF
|
||||
// Errors sends any errors.
|
||||
EOF
|
||||
)
|
||||
|
||||
set-cmt() {
|
||||
local pat=$1
|
||||
local cmt=$2
|
||||
|
||||
IFS=$'\n' local files=($(grep -n $pat backend_*~*_test.go))
|
||||
for f in $files; do
|
||||
IFS=':' local fields=($=f)
|
||||
local file=$fields[1]
|
||||
local end=$(( $fields[2] - 1 ))
|
||||
|
||||
# Find start of comment.
|
||||
local start=0
|
||||
IFS=$'\n' local lines=($(head -n$end $file))
|
||||
for (( i = 1; i <= $#lines; i++ )); do
|
||||
local line=$lines[-$i]
|
||||
if ! grep -q '^[[:space:]]*//' <<<$line; then
|
||||
start=$(( end - (i - 2) ))
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
head -n $(( start - 1 )) $file >/tmp/x
|
||||
print -r -- $cmt >>/tmp/x
|
||||
tail -n+$(( end + 1 )) $file >>/tmp/x
|
||||
mv /tmp/x $file
|
||||
done
|
||||
}
|
||||
|
||||
set-cmt '^type Watcher struct ' $watcher
|
||||
set-cmt '^func NewWatcher(' $new
|
||||
set-cmt '^func (w \*Watcher) Add(' $add
|
||||
set-cmt '^func (w \*Watcher) Remove(' $remove
|
||||
set-cmt '^func (w \*Watcher) Close(' $close
|
||||
set-cmt '^func (w \*Watcher) WatchList(' $watchlist
|
||||
set-cmt '^[[:space:]]*Events *chan Event$' $events
|
||||
set-cmt '^[[:space:]]*Errors *chan error$' $errors
|
||||
8
vendor/github.com/fsnotify/fsnotify/system_bsd.go
generated
vendored
Normal file
8
vendor/github.com/fsnotify/fsnotify/system_bsd.go
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build freebsd || openbsd || netbsd || dragonfly
|
||||
// +build freebsd openbsd netbsd dragonfly
|
||||
|
||||
package fsnotify
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
const openMode = unix.O_NONBLOCK | unix.O_RDONLY | unix.O_CLOEXEC
|
||||
9
vendor/github.com/fsnotify/fsnotify/system_darwin.go
generated
vendored
Normal file
9
vendor/github.com/fsnotify/fsnotify/system_darwin.go
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package fsnotify
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// note: this constant is not defined on BSD
|
||||
const openMode = unix.O_EVTONLY | unix.O_CLOEXEC
|
||||
9
vendor/github.com/google/uuid/.travis.yml
generated
vendored
Normal file
9
vendor/github.com/google/uuid/.travis.yml
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
language: go
|
||||
|
||||
go:
|
||||
- 1.4.3
|
||||
- 1.5.3
|
||||
- tip
|
||||
|
||||
script:
|
||||
- go test -v ./...
|
||||
10
vendor/github.com/google/uuid/CONTRIBUTING.md
generated
vendored
Normal file
10
vendor/github.com/google/uuid/CONTRIBUTING.md
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# How to contribute
|
||||
|
||||
We definitely welcome patches and contribution to this project!
|
||||
|
||||
### Legal requirements
|
||||
|
||||
In order to protect both you and ourselves, you will need to sign the
|
||||
[Contributor License Agreement](https://cla.developers.google.com/clas).
|
||||
|
||||
You may have already signed it for other Google projects.
|
||||
9
vendor/github.com/google/uuid/CONTRIBUTORS
generated
vendored
Normal file
9
vendor/github.com/google/uuid/CONTRIBUTORS
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
Paul Borman <borman@google.com>
|
||||
bmatsuo
|
||||
shawnps
|
||||
theory
|
||||
jboverfelt
|
||||
dsymonds
|
||||
cd1
|
||||
wallclockbuilder
|
||||
dansouza
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user