98 Commits

Author SHA1 Message Date
Aine
0a9701f4c9 fix owner check 2024-02-05 22:21:15 +02:00
Aine
0bd2fc525e fix access checks; fix duplicate metadata message; better email sanitization 2024-02-05 21:49:30 +02:00
Aine
32b80191a3 fix sqlite dependency 2023-12-19 13:29:28 +02:00
Aine
c19c87393c update deps 2023-12-19 12:48:50 +02:00
Aine
fe17195bc1 log when it cannot get room settings 2023-12-19 12:40:13 +02:00
Aine
6d12a61b2a refactor direct smtp client 2023-11-11 19:31:34 +02:00
Aine
f2e032e1e8 emoji signaling 2023-10-19 10:31:14 +03:00
Aine
d3aaa5c060 [skip ci] update readme 2023-10-17 22:09:33 +03:00
Aine
60ec6b7e66 fix multi-arch incompability with registry 2023-10-17 17:14:59 +03:00
Aine
1a5e591f4f ci cache 2023-10-17 16:37:44 +03:00
Aine
ea74eda6fc simplify ci 2023-10-17 15:04:20 +03:00
Aine
b659ab8915 try to use docker directly 2023-10-17 14:19:14 +03:00
Aine
1bf8ba4700 add !pm threadify option, fixes #60 2023-10-13 22:27:46 +03:00
Aine
0d0dcf20b9 lint fixes 2023-10-03 00:03:29 +03:00
Aine
ef221038f7 fix justfile 2023-10-02 23:24:29 +03:00
Aine
7a50caac86 adjust justfile 2023-10-02 23:07:26 +03:00
Aine
3876e6c405 debug ci 2023-10-02 22:50:38 +03:00
Aine
655b5a096f fix ci 2023-10-02 22:36:14 +03:00
Aine
d644cb3757 fix ci 2023-10-02 22:32:31 +03:00
Aine
b7f34d8479 update justfile 2023-10-02 22:27:19 +03:00
Aine
987e126c9d ci debug 2023-10-02 22:04:20 +03:00
Aine
8ce3288ada debug 2023-10-02 21:44:38 +03:00
Aine
0bf8672a88 option set/get visual changes 2023-10-02 21:24:48 +03:00
Aine
53655261fe handle reactions in encrypted rooms 2023-09-29 16:37:46 +03:00
Aine
bebfa6df92 provide proper reply-to fallback by default 2023-09-28 15:17:55 +03:00
Aine
8bdd46fb32 make linter happy 2023-09-28 08:31:42 +03:00
Aine
da41bd31fb add !pm banlist:totals, fix notices on reactions 2023-09-28 08:30:37 +03:00
Aine
7fbb279830 use proper thread IDs on metadata save and error reporting 2023-09-27 15:07:55 +03:00
Aine
816db6f409 always reply to a specific message; moved matrix-related utils to the linkpearl; refactoring 2023-09-27 12:43:55 +03:00
Aine
e2f5f4c731 show banlist by day, in weekly chunks 2023-09-26 15:54:27 +03:00
Aine
6be4891165 subaddressing support, closes #61 2023-09-25 23:20:17 +03:00
Aine
18f1113d33 add pm banlist:auth and pm banlist:auto 2023-09-25 21:59:23 +03:00
Aine
b413e5871a partial complexity refactoring 2023-09-23 17:33:58 +03:00
Aine
8f3a74d46c add !pm autoreply 2023-09-23 17:09:18 +03:00
Aine
480c99cf79 add !pm signature option 2023-09-23 16:35:12 +03:00
Aine
74defa85e4 spam:add using emoji 2023-09-22 22:15:53 +03:00
Aine
3bb1f3ecba add missing go packages 2023-09-22 19:43:44 +03:00
Aine
e3557f5522 new commands: spam:list, spam:add, spam:remove, spam:reset; updated readme with the full list of commands; rearranged sections in help 2023-09-22 19:35:28 +03:00
Aine
c4576869ab fix email queue consideration, fixes #66 2023-09-21 15:29:00 +03:00
Aine
8545ce80e4 Shared secret auth support, contributed by @JeWe37 2023-09-20 10:26:20 +03:00
Aine
60b4386dd8 automatically ignore known forwarded addresses, fixes #64 2023-09-18 12:35:37 +03:00
Aine
e90925eceb handle missing threads 2023-09-16 15:42:28 +03:00
Aine
f2432270e5 Merge branch 'main-patch-09ef' into 'main'
Fixes a copy and paste mistake in the docstring of in RoomNoInlines

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

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

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

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

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

See merge request etke.cc/postmoogle!40
2022-11-19 16:06:29 +00:00
Aine
1bcf9bb050 set correct Message-Id, From, To, Cc, based on previous emails (and used domain) in the thread 2022-11-19 18:05:26 +02:00
Aine
128d2b595a use the same sender's domain on thread reply as in parent email 2022-11-19 17:41:38 +02:00
Aine
8aac16aca8 make thread replies CC-aware and multi-domain aware 2022-11-19 17:38:13 +02:00
Aine
5fe8603506 add nocc option 2022-11-19 17:09:24 +02:00
Aine
052fd5bb25 refactoring, created email package 2022-11-19 17:00:57 +02:00
Aine
9e532a6007 initial cc support 2022-11-19 16:41:53 +02:00
Aine
ad83eab930 force <style></style> removal in html part of incoming emails 2022-11-19 00:48:48 +02:00
842 changed files with 74810 additions and 51176 deletions

View File

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

View File

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

View File

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

View File

@@ -1,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
View File

@@ -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** &lt;mailbox&gt; - Delete specific mailbox
#### mailbox anti-spam
> The following section is visible to the mailbox owners only
* **`!pm spam:list`** - Show comma-separated spamlist of the room, eg: `spammer@example.com,*@spammer.org,spam@*`
* **`!pm spam:add`** - Mark an email address (or pattern) as spam (or you can react to the email with emoji: ⛔️,🛑, or 🚫)
* **`!pm spam:remove`** - Unmark an email address (or pattern) as spam
* **`!pm spam:reset`** - Reset spamlist
---
* **!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>

View File

@@ -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
View File

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

View File

@@ -6,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()
}

View File

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

View File

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

View File

@@ -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
View File

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

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

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

119
bot/config/manager.go Normal file
View 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
View 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",
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

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

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

49
bot/reaction.go Normal file
View 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)})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,23 @@
package config
import "time"
// Config of Postmoogle
type Config struct {
// Homeserver url
Homeserver string
// Login is a MXID localpart (scheduler - OK, @scheduler:example.com - wrong)
// Login is a localpart if logging in with password (postmoogle) OR full MXID if logging in with shared secret (@postmoogle:example.com)
Login string
// Password for login/password auth only
Password string
// 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
View File

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

42
docs/tricks.md Normal file
View File

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

View File

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

View File

@@ -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
View 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
View 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
View File

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

47
go.mod
View File

@@ -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
View File

@@ -1,7 +1,13 @@
blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE=
blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/archdx/zerolog-sentry v1.2.0 h1:FDFqlo5XvL/jpDAPoAWI15EjJQVFvixn70v3IH//eTM=
github.com/archdx/zerolog-sentry v1.2.0/go.mod h1:3H8gClGFafB90fKMsvfP017bdmkG5MD6UiA+6iPEwGw=
github.com/buger/jsonparser v1.0.0 h1:etJTGF5ESxjI0Ic2UaLQs2LQQpa8G9ykQScukbh4L8A=
github.com/buger/jsonparser v1.0.0/go.mod h1:tgcrVJ81GPSF0mz+0nu1Xaz0fazGPrmmJfJtxjbHhUQ=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -16,6 +22,8 @@ github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDm
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
@@ -27,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
View 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
View 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
}

View File

@@ -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
View File

@@ -0,0 +1,44 @@
package smtp
import (
"strings"
"github.com/rs/zerolog"
)
// validatorLoggerWrapper is a wrapper around zerolog.Logger to implement validator.Logger interface
type validatorLoggerWrapper struct {
log *zerolog.Logger
}
func (l validatorLoggerWrapper) Info(msg string, args ...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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

76
utils/mail.go Normal file
View 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
View File

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

View File

@@ -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
View File

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

View File

@@ -1,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
View File

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

12
vendor/github.com/fsnotify/fsnotify/.editorconfig generated vendored Normal file
View 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
View File

@@ -0,0 +1 @@
go.sum linguist-generated

6
vendor/github.com/fsnotify/fsnotify/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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