diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 00000000..45a5ae2a
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,39 @@
+name: build
+on: [push, pull_request]
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      -
+        name: Install Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: '1.18.x'
+      -
+        name: Install node
+        uses: actions/setup-node@v2
+        with:
+          node-version: '16'
+      -
+        name: Checkout code
+        uses: actions/checkout@v2
+      -
+        name: Cache Go and npm modules
+        uses: actions/cache@v3
+        with:
+          path: |
+            ~/go/pkg/mod
+            ~/go/bin
+            ~/.npm
+            web/node_modules
+          key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
+          restore-keys: ${{ runner.os }}-ntfy-
+      -
+        name: Install dependencies
+        run: make build-deps-ubuntu
+      -
+        name: Build all the things
+        run: make build
+      -
+        name: Print build results and checksums
+        run: make cli-build-results
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
deleted file mode 100644
index 31fdfe20..00000000
--- a/.github/workflows/codeql-analysis.yml
+++ /dev/null
@@ -1,72 +0,0 @@
-# For most projects, this workflow file will not need changing; you simply need
-# to commit it to your repository.
-#
-# You may wish to alter this file to override the set of languages analyzed,
-# or to provide custom queries or build logic.
-#
-# ******** NOTE ********
-# We have attempted to detect the languages in your repository. Please check
-# the `language` matrix defined below to confirm you have the correct set of
-# supported CodeQL languages.
-#
-name: "CodeQL"
-
-on:
-  push:
-    branches: [ main ]
-  pull_request:
-    # The branches below must be a subset of the branches above
-    branches: [ main ]
-  schedule:
-    - cron: '21 10 * * 5'
-
-jobs:
-  analyze:
-    name: Analyze
-    runs-on: ubuntu-latest
-    permissions:
-      actions: read
-      contents: read
-      security-events: write
-
-    strategy:
-      fail-fast: false
-      matrix:
-        language: [ 'go', 'javascript' ]
-        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
-        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
-
-    steps:
-    - name: Checkout repository
-      uses: actions/checkout@v3
-
-    # Initializes the CodeQL tools for scanning.
-    - name: Initialize CodeQL
-      uses: github/codeql-action/init@v2
-      with:
-        languages: ${{ matrix.language }}
-        # If you wish to specify custom queries, you can do so here or in a config file.
-        # By default, queries listed here will override any specified in a config file.
-        # Prefix the list here with "+" to use these queries and those in the config file.
-        
-        # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
-        # queries: security-extended,security-and-quality
-
-        
-    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
-    # If this step fails, then you should remove it and run the build manually (see below)
-    - name: Autobuild
-      uses: github/codeql-action/autobuild@v2
-
-    # ℹ️ Command-line programs to run using the OS shell.
-    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
-
-    #   If the Autobuild fails above, remove it and uncomment the following three lines. 
-    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
-
-    # - run: |
-    #   echo "Run, Build Application using script"
-    #   ./location_of_script_within_repo/buildscript.sh
-
-    - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 00000000..a2a6e335
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,50 @@
+name: release
+on:
+  push:
+    tags:
+      - 'v[0-9]+.[0-9]+.[0-9]+'
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    steps:
+      -
+        name: Install Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: '1.18.x'
+      -
+        name: Install node
+        uses: actions/setup-node@v2
+        with:
+          node-version: '16'
+      -
+        name: Checkout code
+        uses: actions/checkout@v2
+      -
+        name: Cache Go and npm modules
+        uses: actions/cache@v3
+        with:
+          path: |
+            ~/go/pkg/mod
+            ~/go/bin
+            ~/.npm
+            web/node_modules
+          key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
+          restore-keys: ${{ runner.os }}-ntfy-
+      -
+        name: Docker login
+        uses: docker/login-action@v2
+        with:
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.DOCKER_HUB_TOKEN }}
+      -
+        name: Install dependencies
+        run: make build-deps-ubuntu
+      -
+        name: Build and publish
+        run: make release
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      -
+        name: Print build results and checksums
+        run: make cli-build-results
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 0b63387f..96c43d85 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -3,26 +3,46 @@ on: [push, pull_request]
 jobs:
   test:
     runs-on: ubuntu-latest
-    steps:
-      - name: Install Go
+    steps: 
+      -
+        name: Install Go
         uses: actions/setup-go@v2
         with:
-          go-version: '1.17.x'
-      - name: Install node
+          go-version: '1.18.x'
+      -
+        name: Install node
         uses: actions/setup-node@v2
         with:
           node-version: '16'
-      - name: Checkout code
+      -
+        name: Checkout code
         uses: actions/checkout@v2
-      - name: Install dependencies
-        run: sudo apt update && sudo apt install -y python3-pip curl
-      - name: Build docs (required for tests)
+      -
+        name: Cache Go and npm modules
+        uses: actions/cache@v3
+        with:
+          path: |
+            ~/go/pkg/mod
+            ~/go/bin
+            ~/.npm
+            web/node_modules
+          key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
+          restore-keys: ${{ runner.os }}-ntfy-
+      -
+        name: Install dependencies
+        run: make build-deps-ubuntu
+      -
+        name: Build docs (required for tests)
         run: make docs
-      - name: Build web app (required for tests)
+      -
+        name: Build web app (required for tests)
         run: make web
-      - name: Run tests, formatting, vetting and linting
+      -
+        name: Run tests, formatting, vetting and linting
         run: make check
-      - name: Run coverage
+      -
+        name: Run coverage
         run: make coverage
-      - name: Upload coverage to codecov.io
+      -
+        name: Upload coverage to codecov.io
         run: make coverage-upload
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 3be24147..ea728840 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -157,6 +157,7 @@ universal_binaries:
   -
     id: ntfy_darwin_all
     replace: true
+    name_template: ntfy
 checksum:
   name_template: 'checksums.txt'
 snapshot:
diff --git a/Makefile b/Makefile
index b58e62e3..f2e741d5 100644
--- a/Makefile
+++ b/Makefile
@@ -79,6 +79,18 @@ build: web docs cli
 update: web-deps-update cli-deps-update docs-deps-update
 	docker pull alpine
 
+# Ubuntu-specific
+
+build-deps-ubuntu:
+	sudo apt update
+	sudo apt install -y \
+		curl \
+		gcc-aarch64-linux-gnu \
+		gcc-arm-linux-gnueabi \
+		upx \
+		jq
+	which pip3 || sudo apt install -y python3-pip
+
 # Documentation
 
 docs: docs-deps docs-build
@@ -114,28 +126,29 @@ web-deps:
 web-deps-update:
 	cd web && npm update
 
+
 # Main server/client build
 
 cli: cli-deps
-	goreleaser build --snapshot --rm-dist --debug
+	goreleaser build --snapshot --rm-dist
 
 cli-linux-amd64: cli-deps-static-sites
-	goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_amd64
+	goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64
 
 cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
-	goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_armv6
+	goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6
 
 cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
-	goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_armv7
+	goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7
 
 cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
-	goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_arm64
+	goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64
 
 cli-windows-amd64: cli-deps-static-sites
-	goreleaser build --snapshot --rm-dist --debug --id ntfy_windows_amd64
+	goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64
 
 cli-darwin-all: cli-deps-static-sites
-	goreleaser build --snapshot --rm-dist --debug --id ntfy_darwin_all
+	goreleaser build --snapshot --rm-dist --id ntfy_darwin_all
 
 cli-linux-server: cli-deps-static-sites
 	# This is a target to build the CLI (including the server) manually.
@@ -177,6 +190,7 @@ cli-deps-static-sites:
 
 cli-deps-all:
 	which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
+	go install github.com/goreleaser/goreleaser@latest
 
 cli-deps-gcc-armv6-armv7:
 	which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
@@ -187,6 +201,18 @@ cli-deps-gcc-arm64:
 cli-deps-update:
 	go get -u
 	go install honnef.co/go/tools/cmd/staticcheck@latest
+	go install golang.org/x/lint/golint@latest
+	go install github.com/goreleaser/goreleaser@latest
+
+cli-build-results:
+	cat dist/config.yaml
+	[ -f dist/artifacts.json ] && cat dist/artifacts.json | jq . || true
+	[ -f dist/metadata.json ] && cat dist/metadata.json | jq . || true
+	[ -f dist/checksums.txt ] && cat dist/checksums.txt || true
+	find dist -maxdepth 2 -type f \
+		\( -name '*.deb' -or -name '*.rpm' -or -name '*.zip' -or -name '*.tar.gz' -or -name 'ntfy' \) \
+		-and -not -path 'dist/goreleaserdocker*' \
+		-exec sha256sum {} \;
 
 # Test/check targets
 
@@ -238,13 +264,13 @@ staticcheck: .PHONY
 
 # Releasing targets
 
-release: clean update cli-deps release-check-tags docs web check
-	goreleaser release --rm-dist --debug
+release: clean update cli-deps release-checks docs web check
+	goreleaser release --rm-dist
 
 release-snapshot: clean update cli-deps docs web check
-	goreleaser release --snapshot --skip-publish --rm-dist --debug
+	goreleaser release --snapshot --skip-publish --rm-dist
 
-release-check-tags:
+release-checks:
 	$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
 	if ! grep -q $(LATEST_TAG) docs/install.md; then\
 	 	echo "ERROR: Must update docs/install.md with latest tag first.";\
@@ -254,6 +280,10 @@ release-check-tags:
 		echo "ERROR: Must update docs/releases.md with latest tag first.";\
 		exit 1;\
 	fi
+	if [ -n "$(shell git status -s)" ]; then\
+	  echo "ERROR: Git repository is in an unclean state.";\
+	  exit 1;\
+	fi
 
 
 # Installing targets
diff --git a/README.md b/README.md
index 94ac1d4f..f6969459 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,16 @@ too.
 [Install / Self-hosting](https://ntfy.sh/docs/install/) |
 [Building](https://ntfy.sh/docs/develop/)
 
+## Chat
+You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org) 
+(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
+[on my website](https://heckel.io/about).
+
+## Announcements / beta testers
+For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements) 
+topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,
+join Discord/Matrix (I'll eventually make a testing channel in Google Play).
+
 ## Contributing
 I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out 
 the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app.
@@ -43,11 +53,6 @@ Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start im
 <img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
 </a>
 
-## Contact me
-You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org) 
-(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
-[on my website](https://heckel.io/about).
-
 ## License
 Made with ❤️ by [Philipp C. Heckel](https://heckel.io).   
 The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).
diff --git a/client/client.go b/client/client.go
index eaff5673..8b05a393 100644
--- a/client/client.go
+++ b/client/client.go
@@ -7,9 +7,9 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"heckel.io/ntfy/log"
 	"heckel.io/ntfy/util"
 	"io"
-	"log"
 	"net/http"
 	"strings"
 	"sync"
@@ -102,6 +102,7 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
 			return nil, err
 		}
 	}
+	log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header)
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
 		return nil, err
@@ -136,6 +137,7 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
 	msgChan := make(chan *Message)
 	errChan := make(chan error)
 	topicURL := c.expandTopicURL(topic)
+	log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
 	options = append(options, WithPoll())
 	go func() {
 		err := performSubscribeRequest(ctx, msgChan, topicURL, "", options...)
@@ -171,6 +173,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
 	defer c.mu.Unlock()
 	subscriptionID := util.RandomString(10)
 	topicURL := c.expandTopicURL(topic)
+	log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
 	ctx, cancel := context.WithCancel(context.Background())
 	c.subscriptions[subscriptionID] = &subscription{
 		ID:       subscriptionID,
@@ -226,11 +229,11 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR
 		// TODO The retry logic is crude and may lose messages. It should record the last message like the
 		//      Android client, use since=, and do incremental backoff too
 		if err := performSubscribeRequest(ctx, msgChan, topicURL, subcriptionID, options...); err != nil {
-			log.Printf("Connection to %s failed: %s", topicURL, err.Error())
+			log.Warn("%s Connection failed: %s", util.ShortTopicURL(topicURL), err.Error())
 		}
 		select {
 		case <-ctx.Done():
-			log.Printf("Connection to %s exited", topicURL)
+			log.Info("%s Connection exited", util.ShortTopicURL(topicURL))
 			return
 		case <-time.After(10 * time.Second): // TODO Add incremental backoff
 		}
@@ -238,7 +241,9 @@ func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicUR
 }
 
 func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicURL string, subscriptionID string, options ...SubscribeOption) error {
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/json", topicURL), nil)
+	streamURL := fmt.Sprintf("%s/json", topicURL)
+	log.Debug("%s Listening to %s", util.ShortTopicURL(topicURL), streamURL)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, nil)
 	if err != nil {
 		return err
 	}
@@ -261,10 +266,12 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR
 	}
 	scanner := bufio.NewScanner(resp.Body)
 	for scanner.Scan() {
-		m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
+		messageJSON := scanner.Text()
+		m, err := toMessage(messageJSON, topicURL, subscriptionID)
 		if err != nil {
 			return err
 		}
+		log.Trace("%s Message received: %s", util.ShortTopicURL(topicURL), messageJSON)
 		if m.Event == MessageEvent {
 			msgChan <- m
 		}
diff --git a/cmd/access.go b/cmd/access.go
index e70a68b7..e1f61bc1 100644
--- a/cmd/access.go
+++ b/cmd/access.go
@@ -19,7 +19,7 @@ const (
 )
 
 var flagsAccess = append(
-	userCommandFlags(),
+	flagsUser,
 	&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
 )
 
@@ -28,7 +28,7 @@ var cmdAccess = &cli.Command{
 	Usage:     "Grant/revoke access to a topic, or show access",
 	UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
 	Flags:     flagsAccess,
-	Before:    initConfigFileInputSourceFunc("config", flagsAccess),
+	Before:    initConfigFileInputSourceFunc("config", flagsAccess, initLogFunc),
 	Action:    execUserAccess,
 	Category:  categoryServer,
 	Description: `Manage the access control list for the ntfy server.
diff --git a/cmd/app.go b/cmd/app.go
index 5a0b426f..975fd2fa 100644
--- a/cmd/app.go
+++ b/cmd/app.go
@@ -3,6 +3,8 @@ package cmd
 
 import (
 	"github.com/urfave/cli/v2"
+	"github.com/urfave/cli/v2/altsrc"
+	"heckel.io/ntfy/log"
 	"os"
 )
 
@@ -13,6 +15,13 @@ const (
 
 var commands = make([]*cli.Command, 0)
 
+var flagsDefault = []cli.Flag{
+	&cli.BoolFlag{Name: "debug", Aliases: []string{"d"}, EnvVars: []string{"NTFY_DEBUG"}, Usage: "enable debug logging"},
+	&cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"},
+	&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"},
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
+}
+
 // New creates a new CLI application
 func New() *cli.App {
 	return &cli.App{
@@ -25,5 +34,21 @@ func New() *cli.App {
 		Writer:                 os.Stdout,
 		ErrWriter:              os.Stderr,
 		Commands:               commands,
+		Flags:                  flagsDefault,
+		Before:                 initLogFunc,
 	}
 }
+
+func initLogFunc(c *cli.Context) error {
+	if c.Bool("trace") {
+		log.SetLevel(log.TraceLevel)
+	} else if c.Bool("debug") {
+		log.SetLevel(log.DebugLevel)
+	} else {
+		log.SetLevel(log.ToLevel(c.String("log-level")))
+	}
+	if c.Bool("no-log-dates") {
+		log.DisableDates()
+	}
+	return nil
+}
diff --git a/cmd/config_loader.go b/cmd/config_loader.go
index 7840c6e7..6d984840 100644
--- a/cmd/config_loader.go
+++ b/cmd/config_loader.go
@@ -11,7 +11,7 @@ import (
 
 // initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
 // if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
-func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag) cli.BeforeFunc {
+func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli.BeforeFunc) cli.BeforeFunc {
 	return func(context *cli.Context) error {
 		configFile := context.String(configFlag)
 		if context.IsSet(configFlag) && !util.FileExists(configFile) {
@@ -23,7 +23,15 @@ func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag) cli.Befo
 		if err != nil {
 			return err
 		}
-		return altsrc.ApplyInputSourceValues(context, inputSource, flags)
+		if err := altsrc.ApplyInputSourceValues(context, inputSource, flags); err != nil {
+			return err
+		}
+		if next != nil {
+			if err := next(context); err != nil {
+				return err
+			}
+		}
+		return nil
 	}
 }
 
diff --git a/cmd/publish.go b/cmd/publish.go
index 06f8d962..c56aecad 100644
--- a/cmd/publish.go
+++ b/cmd/publish.go
@@ -16,31 +16,35 @@ func init() {
 	commands = append(commands, cmdPublish)
 }
 
+var flagsPublish = append(
+	flagsDefault,
+	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
+	&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
+	&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
+	&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
+	&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
+	&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
+	&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
+	&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
+	&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
+	&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
+	&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
+	&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
+	&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
+	&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
+	&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
+	&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
+)
+
 var cmdPublish = &cli.Command{
 	Name:      "publish",
 	Aliases:   []string{"pub", "send", "trigger"},
 	Usage:     "Send message via a ntfy server",
-	UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n   NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]",
+	UsageText: "ntfy publish [OPTIONS..] TOPIC [MESSAGE]\nNTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE]",
 	Action:    execPublish,
 	Category:  categoryClient,
-	Flags: []cli.Flag{
-		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
-		&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
-		&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
-		&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
-		&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
-		&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
-		&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
-		&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
-		&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
-		&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
-		&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
-		&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
-		&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
-		&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
-		&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
-		&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"},
-	},
+	Flags:     flagsPublish,
+	Before:    initLogFunc,
 	Description: `Publish a message to a ntfy server.
 
 Examples:
diff --git a/cmd/serve.go b/cmd/serve.go
index 630889d0..50969e03 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -5,10 +5,13 @@ package cmd
 import (
 	"errors"
 	"fmt"
-	"log"
+	"heckel.io/ntfy/log"
 	"math"
 	"net"
+	"os"
+	"os/signal"
 	"strings"
+	"syscall"
 	"time"
 
 	"github.com/urfave/cli/v2"
@@ -21,8 +24,13 @@ func init() {
 	commands = append(commands, cmdServe)
 }
 
-var flagsServe = []cli.Flag{
-	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
+const (
+	defaultServerConfigFile = "/etc/ntfy/server.yml"
+)
+
+var flagsServe = append(
+	flagsDefault,
+	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
@@ -59,7 +67,7 @@ var flagsServe = []cli.Flag{
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
-}
+)
 
 var cmdServe = &cli.Command{
 	Name:      "serve",
@@ -68,7 +76,7 @@ var cmdServe = &cli.Command{
 	Action:    execServe,
 	Category:  categoryServer,
 	Flags:     flagsServe,
-	Before:    initConfigFileInputSourceFunc("config", flagsServe),
+	Before:    initConfigFileInputSourceFunc("config", flagsServe, initLogFunc),
 	Description: `Run the ntfy server and listen for incoming requests
 
 The command will load the configuration from /etc/ntfy/server.yml. Config options can 
@@ -85,6 +93,7 @@ func execServe(c *cli.Context) error {
 	}
 
 	// Read all the options
+	config := c.String("config")
 	baseURL := c.String("base-url")
 	listenHTTP := c.String("listen-http")
 	listenHTTPS := c.String("listen-https")
@@ -192,7 +201,7 @@ func execServe(c *cli.Context) error {
 	for _, host := range visitorRequestLimitExemptHosts {
 		ips, err := net.LookupIP(host)
 		if err != nil {
-			log.Printf("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
+			log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
 			continue
 		}
 		for _, ip := range ips {
@@ -240,14 +249,18 @@ func execServe(c *cli.Context) error {
 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
 	conf.BehindProxy = behindProxy
 	conf.EnableWeb = enableWeb
+
+	// Set up hot-reloading of config
+	go sigHandlerConfigReload(config)
+
+	// Run server
 	s, err := server.New(conf)
 	if err != nil {
-		log.Fatalln(err)
+		log.Fatal(err)
+	} else if err := s.Run(); err != nil {
+		log.Fatal(err)
 	}
-	if err := s.Run(); err != nil {
-		log.Fatalln(err)
-	}
-	log.Printf("Exiting.")
+	log.Info("Exiting.")
 	return nil
 }
 
@@ -261,3 +274,28 @@ func parseSize(s string, defaultValue int64) (v int64, err error) {
 	}
 	return v, nil
 }
+
+func sigHandlerConfigReload(config string) {
+	sigs := make(chan os.Signal, 1)
+	signal.Notify(sigs, syscall.SIGHUP)
+	for range sigs {
+		log.Info("Partially hot reloading configuration ...")
+		inputSource, err := newYamlSourceFromFile(config, flagsServe)
+		if err != nil {
+			log.Warn("Hot reload failed: %s", err.Error())
+			continue
+		}
+		reloadLogLevel(inputSource)
+	}
+}
+
+func reloadLogLevel(inputSource altsrc.InputSourceContext) {
+	newLevelStr, err := inputSource.String("log-level")
+	if err != nil {
+		log.Warn("Cannot load log level: %s", err.Error())
+		return
+	}
+	newLevel := log.ToLevel(newLevelStr)
+	log.SetLevel(newLevel)
+	log.Info("Log level is %s", newLevel.String())
+}
diff --git a/cmd/subscribe.go b/cmd/subscribe.go
index 97f7e410..3cab077c 100644
--- a/cmd/subscribe.go
+++ b/cmd/subscribe.go
@@ -5,12 +5,13 @@ import (
 	"fmt"
 	"github.com/urfave/cli/v2"
 	"heckel.io/ntfy/client"
+	"heckel.io/ntfy/log"
 	"heckel.io/ntfy/util"
-	"log"
 	"os"
 	"os/exec"
 	"os/user"
 	"path/filepath"
+	"sort"
 	"strings"
 )
 
@@ -24,6 +25,16 @@ const (
 	clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
 )
 
+var flagsSubscribe = append(
+	flagsDefault,
+	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
+	&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
+	&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
+	&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
+	&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
+	&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
+)
+
 var cmdSubscribe = &cli.Command{
 	Name:      "subscribe",
 	Aliases:   []string{"sub"},
@@ -31,15 +42,8 @@ var cmdSubscribe = &cli.Command{
 	UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
 	Action:    execSubscribe,
 	Category:  categoryClient,
-	Flags: []cli.Flag{
-		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
-		&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
-		&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
-		&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
-		&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
-		&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
-		&cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"},
-	},
+	Flags:     flagsSubscribe,
+	Before:    initLogFunc,
 	Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for 
 every arriving message. There are 3 modes in which the command can be run:
 
@@ -186,6 +190,7 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
 		if !ok {
 			continue
 		}
+		log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw)
 		printMessageOrRunCommand(c, m, cmd)
 	}
 	return nil
@@ -195,26 +200,26 @@ func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string)
 	if command != "" {
 		runCommand(c, command, m)
 	} else {
+		log.Debug("%s Printing raw message", logMessagePrefix(m))
 		fmt.Fprintln(c.App.Writer, m.Raw)
 	}
 }
 
 func runCommand(c *cli.Context, command string, m *client.Message) {
 	if err := runCommandInternal(c, command, m); err != nil {
-		fmt.Fprintf(c.App.ErrWriter, "Command failed: %s\n", err.Error())
+		log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error())
 	}
 }
 
 func runCommandInternal(c *cli.Context, script string, m *client.Message) error {
 	scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt)
-	if err := os.WriteFile(scriptFile, []byte(scriptHeader+script), 0700); err != nil {
+	log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile)
+	script = scriptHeader + script
+	if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
 		return err
 	}
 	defer os.Remove(scriptFile)
-	verbose := c.Bool("verbose")
-	if verbose {
-		log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), script, m.Raw)
-	}
+	log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile)
 	cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)
 	cmd.Stdin = c.App.Reader
 	cmd.Stdout = c.App.Writer
@@ -224,7 +229,7 @@ func runCommandInternal(c *cli.Context, script string, m *client.Message) error
 }
 
 func envVars(m *client.Message) []string {
-	env := os.Environ()
+	env := make([]string, 0)
 	env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
 	env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
 	env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
@@ -233,7 +238,11 @@ func envVars(m *client.Message) []string {
 	env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
 	env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
 	env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
-	return env
+	sort.Strings(env)
+	if log.IsTrace() {
+		log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n"))
+	}
+	return append(os.Environ(), env...)
 }
 
 func envVar(value string, vars ...string) []string {
@@ -249,7 +258,7 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
 	if filename != "" {
 		return client.LoadConfig(filename)
 	}
-	configFile := defaultConfigFile()
+	configFile := defaultClientConfigFile()
 	if s, _ := os.Stat(configFile); s != nil {
 		return client.LoadConfig(configFile)
 	}
@@ -257,7 +266,7 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
 }
 
 //lint:ignore U1000 Conditionally used in different builds
-func defaultConfigFileUnix() string {
+func defaultClientConfigFileUnix() string {
 	u, _ := user.Current()
 	configFile := clientRootConfigFileUnixAbsolute
 	if u.Uid != "0" {
@@ -268,7 +277,11 @@ func defaultConfigFileUnix() string {
 }
 
 //lint:ignore U1000 Conditionally used in different builds
-func defaultConfigFileWindows() string {
+func defaultClientConfigFileWindows() string {
 	homeDir, _ := os.UserConfigDir()
 	return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
 }
+
+func logMessagePrefix(m *client.Message) string {
+	return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
+}
diff --git a/cmd/subscribe_darwin.go b/cmd/subscribe_darwin.go
index e4f44ed6..0372a79f 100644
--- a/cmd/subscribe_darwin.go
+++ b/cmd/subscribe_darwin.go
@@ -11,6 +11,6 @@ var (
 	scriptLauncher = []string{"sh", "-c"}
 )
 
-func defaultConfigFile() string {
-	return defaultConfigFileUnix()
+func defaultClientConfigFile() string {
+	return defaultClientConfigFileUnix()
 }
diff --git a/cmd/subscribe_linux.go b/cmd/subscribe_linux.go
index c57660e8..346606bd 100644
--- a/cmd/subscribe_linux.go
+++ b/cmd/subscribe_linux.go
@@ -11,6 +11,6 @@ var (
 	scriptLauncher = []string{"sh", "-c"}
 )
 
-func defaultConfigFile() string {
-	return defaultConfigFileUnix()
+func defaultClientConfigFile() string {
+	return defaultClientConfigFileUnix()
 }
diff --git a/cmd/subscribe_windows.go b/cmd/subscribe_windows.go
index 1d5c6655..e8f1a271 100644
--- a/cmd/subscribe_windows.go
+++ b/cmd/subscribe_windows.go
@@ -10,6 +10,6 @@ var (
 	scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
 )
 
-func defaultConfigFile() string {
-	return defaultConfigFileWindows()
+func defaultClientConfigFile() string {
+	return defaultClientConfigFileWindows()
 }
diff --git a/cmd/user.go b/cmd/user.go
index 5ccc5b15..acc06d4c 100644
--- a/cmd/user.go
+++ b/cmd/user.go
@@ -17,14 +17,19 @@ func init() {
 	commands = append(commands, cmdUser)
 }
 
-var flagsUser = userCommandFlags()
+var flagsUser = append(
+	flagsDefault,
+	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
+)
 
 var cmdUser = &cli.Command{
 	Name:      "user",
 	Usage:     "Manage/show users",
 	UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
 	Flags:     flagsUser,
-	Before:    initConfigFileInputSourceFunc("config", flagsUser),
+	Before:    initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
 	Category:  categoryServer,
 	Subcommands: []*cli.Command{
 		{
@@ -269,11 +274,3 @@ func readPasswordAndConfirm(c *cli.Context) (string, error) {
 	}
 	return string(password), nil
 }
-
-func userCommandFlags() []cli.Flag {
-	return []cli.Flag{
-		&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
-		altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
-		altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
-	}
-}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..d39492e8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,17 @@
+version: "2.1"
+services:
+  ntfy:
+    image: binwiederhier/ntfy
+    container_name: ntfy
+    command:
+      - serve
+    environment:
+      - TZ=UTC    # optional: Change to your desired timezone
+    user: UID:GID # optional: Set custom user/group or uid/gid
+    volumes:
+      - /var/cache/ntfy:/var/cache/ntfy
+      - /etc/ntfy:/etc/ntfy
+    ports:
+      - 80:80
+    restart: unless-stopped
+
diff --git a/docs/config.md b/docs/config.md
index 15b547bb..db664c7c 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -643,10 +643,18 @@ In case you're curious, here's an example of the entire flow:
 - In the iOS app, you subscribe to `https://ntfy.example.com/mytopic`
 - The app subscribes to the Firebase topic `6de73be8dfb7d69e...` (the SHA256 of the topic URL)
 - When you publish a message to `https://ntfy.example.com/mytopic`, your ntfy server will publish a 
-  poll request to `https://ntfy.sh/6de73be8dfb7d69e...` (passing the message ID in the `X-Poll-ID` header)
-- The ntfy.sh server publishes the message to Firebase, which forwards it to APNS, which forwards it to your iOS device
+  poll request to `https://ntfy.sh/6de73be8dfb7d69e...`. The request from your server to the upstream server 
+  contains only the message ID (in the `X-Poll-ID` header), and the SHA256 checksum of the topic URL (as upstream topic).
+- The ntfy.sh server publishes the poll request message to Firebase, which forwards it to APNS, which forwards it to your iOS device
 - Your iOS device receives the poll request, and fetches the actual message from your server, and then displays it
 
+Here's an example of what the self-hosted server forwards to the upstream server. The request is equivalent to this curl:
+
+```
+curl -X POST -H "X-Poll-ID: s4PdJozxM8na" https://ntfy.sh/6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b
+{"id":"4HsClFEuCIcs","time":1654087955,"event":"poll_request","topic":"6de73be8dfb7d69e32fb2c00c23fe7adbd8b5504406e3068c273aa24cef4055b","message":"New message","poll_id":"s4PdJozxM8na"}
+```
+
 ## Rate limiting
 !!! info
     Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. 
@@ -700,6 +708,23 @@ are enabled):
 * `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
 * `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
 
+### Firebase limits
+If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no` 
+is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling)
+on how many messages can be published. Unfortunately these limits are a little vague and can change depending on the time 
+of day. In practice, I have only ever observed `429 Quota exceeded` responses from Firebase if **too many messages are published to 
+the same topic**. 
+
+In ntfy, if Firebase responds with a 429 after publishing to a topic, the visitor (= IP address) who published the message
+is **banned from publishing to Firebase for 10 minutes** (not configurable). Because publishing to Firebase happens asynchronously,
+there is no indication of the user that this has happened. Non-Firebase subscribers (WebSocket or HTTP stream) are not affected.
+After the 10 minutes are up, messages forwarding to Firebase is resumed for this visitor.
+
+If this ever happens, there will be a log message that looks something like this:
+```
+WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
+```
+
 ## Tuning for scale
 If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
 if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
@@ -799,6 +824,26 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
     maxretry = 10
     ```
 
+## Debugging/tracing
+If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
+to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message 
+contents. The `TRACE` setting will also print the message contents. 
+
+!!! warning
+    Both options are very verbose and should only be enabled in production for short periods of time. Otherwise, 
+    you're going to run out of disk space pretty quickly.
+
+You can also hot-reload the `log-level` by sending the `SIGHUP` signal to the process after editing the `server.yml` file.
+You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`. 
+If successful, you'll see something like this:
+
+```
+$ ntfy serve
+2022/06/02 10:29:28 INFO Listening on :2586[http] :1025[smtp], log level is INFO
+2022/06/02 10:29:34 INFO Partially hot reloading configuration ...
+2022/06/02 10:29:34 INFO Log level is TRACE
+```
+
 ## Config options
 Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a
 CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
@@ -809,43 +854,44 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
     `cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do 
     not support dashes.
 
-| Config option                              | Env variable                                    | Format                                              | Default      | Description                                                                                                                                                                                                                     |
-|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `base-url`                                 | `NTFY_BASE_URL`                                 | *URL*                                               | -            | Public facing base URL of the service (e.g. `https://ntfy.sh`)                                                                                                                                                                  |
-| `listen-http`                              | `NTFY_LISTEN_HTTP`                              | `[host]:port`                                       | `:80`        | Listen address for the HTTP web server                                                                                                                                                                                          |
-| `listen-https`                             | `NTFY_LISTEN_HTTPS`                             | `[host]:port`                                       | -            | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`.                                                                                                                               |
-| `listen-unix`                              | `NTFY_LISTEN_UNIX`                              | *filename*                                          | -            | Path to a Unix socket to listen on                                                                                                                                                                                              |
-| `key-file`                                 | `NTFY_KEY_FILE`                                 | *filename*                                          | -            | HTTPS/TLS private key file, only used if `listen-https` is set.                                                                                                                                                                 |
-| `cert-file`                                | `NTFY_CERT_FILE`                                | *filename*                                          | -            | HTTPS/TLS certificate file, only used if `listen-https` is set.                                                                                                                                                                 |
-| `firebase-key-file`                        | `NTFY_FIREBASE_KEY_FILE`                        | *filename*                                          | -            | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm).                        |
-| `cache-file`                               | `NTFY_CACHE_FILE`                               | *filename*                                          | -            | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache).             |
-| `cache-duration`                           | `NTFY_CACHE_DURATION`                           | *duration*                                          | 12h          | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely.                                        |
-| `auth-file`                                | `NTFY_AUTH_FILE`                                | *filename*                                          | -            | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control).                                                                                           |
-| `auth-default-access`                      | `NTFY_AUTH_DEFAULT_ACCESS`                      | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`.                                                                                                                             |
-| `behind-proxy`                             | `NTFY_BEHIND_PROXY`                             | *bool*                                              | false        | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection.                                                                                                 |
-| `attachment-cache-dir`                     | `NTFY_ATTACHMENT_CACHE_DIR`                     | *directory*                                         | -            | Cache directory for attached files. To enable attachments, this has to be set.                                                                                                                                                  |
-| `attachment-total-size-limit`              | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT`              | *size*                                              | 5G           | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected.                                                                                                                   |
-| `attachment-file-size-limit`               | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT`               | *size*                                              | 15M          | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected.                                                                                                                                       |
-| `attachment-expiry-duration`               | `NTFY_ATTACHMENT_EXPIRY_DURATION`               | *duration*                                          | 3h           | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`.                                                                                               |
-| `smtp-sender-addr`                         | `NTFY_SMTP_SENDER_ADDR`                         | `host:port`                                         | -            | SMTP server address to allow email sending                                                                                                                                                                                      |
-| `smtp-sender-user`                         | `NTFY_SMTP_SENDER_USER`                         | *string*                                            | -            | SMTP user; only used if e-mail sending is enabled                                                                                                                                                                               |
-| `smtp-sender-pass`                         | `NTFY_SMTP_SENDER_PASS`                         | *string*                                            | -            | SMTP password; only used if e-mail sending is enabled                                                                                                                                                                           |
-| `smtp-sender-from`                         | `NTFY_SMTP_SENDER_FROM`                         | *e-mail address*                                    | -            | SMTP sender e-mail address; only used if e-mail sending is enabled                                                                                                                                                              |
-| `smtp-server-listen`                       | `NTFY_SMTP_SERVER_LISTEN`                       | `[ip]:port`                                         | -            | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`                                                                                                                                      |
-| `smtp-server-domain`                       | `NTFY_SMTP_SERVER_DOMAIN`                       | *domain name*                                       | -            | SMTP server e-mail domain, e.g. `ntfy.sh`                                                                                                                                                                                       |
-| `smtp-server-addr-prefix`                  | `NTFY_SMTP_SERVER_ADDR_PREFIX`                  | `[ip]:port`                                         | -            | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-`                                                                                                                                                          |
-| `keepalive-interval`                       | `NTFY_KEEPALIVE_INTERVAL`                       | *duration*                                          | 45s          | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
-| `manager-interval`                         | `$NTFY_MANAGER_INTERVAL`                        | *duration*                                          | 1m           | Interval in which the manager prunes old messages, deletes topics and prints the stats.                                                                                                                                         |
-| `web-root`                                 | `NTFY_WEB_ROOT`                                 | `app`, `home` or `disable`                          | `app`        | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable)                                                                                                                                  |
-| `global-topic-limit`                       | `NTFY_GLOBAL_TOPIC_LIMIT`                       | *number*                                            | 15,000       | Rate limiting: Total number of topics before the server rejects new topics.                                                                                                                                                     |
-| `visitor-subscription-limit`               | `NTFY_VISITOR_SUBSCRIPTION_LIMIT`               | *number*                                            | 30           | Rate limiting: Number of subscriptions per visitor (IP address)                                                                                                                                                                 |
-| `visitor-attachment-total-size-limit`      | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT`      | *size*                                              | 100M         | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`.                                                 |
-| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size*                                              | 500M         | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding.                                                                                        |
-| `visitor-request-limit-burst`              | `NTFY_VISITOR_REQUEST_LIMIT_BURST`              | *number*                                            | 60           | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has                                                                                           |
-| `visitor-request-limit-replenish`          | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH`          | *duration*                                          | 5s           | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled                                                                                                                      |
-| `visitor-request-limit-exempt-hosts`       | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS`       | *comma-separated host/IP list*                      | -            | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting                                                                                                                                                |
-| `visitor-email-limit-burst`                | `NTFY_VISITOR_EMAIL_LIMIT_BURST`                | *number*                                            | 16           | Rate limiting:Initial limit of e-mails per visitor                                                                                                                                                                              |
-| `visitor-email-limit-replenish`            | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH`            | *duration*                                          | 1h           | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled                                                                                                                        |
+| Config option                              | Env variable                                    | Format                                              | Default           | Description                                                                                                                                                                                                                     |
+|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `base-url`                                 | `NTFY_BASE_URL`                                 | *URL*                                               | -                 | Public facing base URL of the service (e.g. `https://ntfy.sh`)                                                                                                                                                                  |
+| `listen-http`                              | `NTFY_LISTEN_HTTP`                              | `[host]:port`                                       | `:80`             | Listen address for the HTTP web server                                                                                                                                                                                          |
+| `listen-https`                             | `NTFY_LISTEN_HTTPS`                             | `[host]:port`                                       | -                 | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`.                                                                                                                               |
+| `listen-unix`                              | `NTFY_LISTEN_UNIX`                              | *filename*                                          | -                 | Path to a Unix socket to listen on                                                                                                                                                                                              |
+| `key-file`                                 | `NTFY_KEY_FILE`                                 | *filename*                                          | -                 | HTTPS/TLS private key file, only used if `listen-https` is set.                                                                                                                                                                 |
+| `cert-file`                                | `NTFY_CERT_FILE`                                | *filename*                                          | -                 | HTTPS/TLS certificate file, only used if `listen-https` is set.                                                                                                                                                                 |
+| `firebase-key-file`                        | `NTFY_FIREBASE_KEY_FILE`                        | *filename*                                          | -                 | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm).                        |
+| `cache-file`                               | `NTFY_CACHE_FILE`                               | *filename*                                          | -                 | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache).             |
+| `cache-duration`                           | `NTFY_CACHE_DURATION`                           | *duration*                                          | 12h               | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely.                                        |
+| `auth-file`                                | `NTFY_AUTH_FILE`                                | *filename*                                          | -                 | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control).                                                                                           |
+| `auth-default-access`                      | `NTFY_AUTH_DEFAULT_ACCESS`                      | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write`      | Default permissions if no matching entries in the auth database are found. Default is `read-write`.                                                                                                                             |
+| `behind-proxy`                             | `NTFY_BEHIND_PROXY`                             | *bool*                                              | false             | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection.                                                                                                 |
+| `attachment-cache-dir`                     | `NTFY_ATTACHMENT_CACHE_DIR`                     | *directory*                                         | -                 | Cache directory for attached files. To enable attachments, this has to be set.                                                                                                                                                  |
+| `attachment-total-size-limit`              | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT`              | *size*                                              | 5G                | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected.                                                                                                                   |
+| `attachment-file-size-limit`               | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT`               | *size*                                              | 15M               | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected.                                                                                                                                       |
+| `attachment-expiry-duration`               | `NTFY_ATTACHMENT_EXPIRY_DURATION`               | *duration*                                          | 3h                | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`.                                                                                               |
+| `smtp-sender-addr`                         | `NTFY_SMTP_SENDER_ADDR`                         | `host:port`                                         | -                 | SMTP server address to allow email sending                                                                                                                                                                                      |
+| `smtp-sender-user`                         | `NTFY_SMTP_SENDER_USER`                         | *string*                                            | -                 | SMTP user; only used if e-mail sending is enabled                                                                                                                                                                               |
+| `smtp-sender-pass`                         | `NTFY_SMTP_SENDER_PASS`                         | *string*                                            | -                 | SMTP password; only used if e-mail sending is enabled                                                                                                                                                                           |
+| `smtp-sender-from`                         | `NTFY_SMTP_SENDER_FROM`                         | *e-mail address*                                    | -                 | SMTP sender e-mail address; only used if e-mail sending is enabled                                                                                                                                                              |
+| `smtp-server-listen`                       | `NTFY_SMTP_SERVER_LISTEN`                       | `[ip]:port`                                         | -                 | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`                                                                                                                                      |
+| `smtp-server-domain`                       | `NTFY_SMTP_SERVER_DOMAIN`                       | *domain name*                                       | -                 | SMTP server e-mail domain, e.g. `ntfy.sh`                                                                                                                                                                                       |
+| `smtp-server-addr-prefix`                  | `NTFY_SMTP_SERVER_ADDR_PREFIX`                  | `[ip]:port`                                         | -                 | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-`                                                                                                                                                          |
+| `keepalive-interval`                       | `NTFY_KEEPALIVE_INTERVAL`                       | *duration*                                          | 45s               | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
+| `manager-interval`                         | `$NTFY_MANAGER_INTERVAL`                        | *duration*                                          | 1m                | Interval in which the manager prunes old messages, deletes topics and prints the stats.                                                                                                                                         |
+| `global-topic-limit`                       | `NTFY_GLOBAL_TOPIC_LIMIT`                       | *number*                                            | 15,000            | Rate limiting: Total number of topics before the server rejects new topics.                                                                                                                                                     |
+| `upstream-base-url`                        | `NTFY_UPSTREAM_BASE_URL`                        | *URL*                                               | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers                                                                                                                   |
+| `visitor-attachment-total-size-limit`      | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT`      | *size*                                              | 100M              | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`.                                                 |
+| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size*                                              | 500M              | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding.                                                                                        |
+| `visitor-email-limit-burst`                | `NTFY_VISITOR_EMAIL_LIMIT_BURST`                | *number*                                            | 16                | Rate limiting:Initial limit of e-mails per visitor                                                                                                                                                                              |
+| `visitor-email-limit-replenish`            | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH`            | *duration*                                          | 1h                | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled                                                                                                                        |
+| `visitor-request-limit-burst`              | `NTFY_VISITOR_REQUEST_LIMIT_BURST`              | *number*                                            | 60                | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has                                                                                           |
+| `visitor-request-limit-replenish`          | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH`          | *duration*                                          | 5s                | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled                                                                                                                      |
+| `visitor-request-limit-exempt-hosts`       | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS`       | *comma-separated host/IP list*                      | -                 | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting                                                                                                                                                |
+| `visitor-subscription-limit`               | `NTFY_VISITOR_SUBSCRIPTION_LIMIT`               | *number*                                            | 30                | Rate limiting: Number of subscriptions per visitor (IP address)                                                                                                                                                                 |
+| `web-root`                                 | `NTFY_WEB_ROOT`                                 | `app`, `home` or `disable`                          | `app`             | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable)                                                                                                                                  |
 
 The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.   
 The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
@@ -873,42 +919,46 @@ DESCRIPTION:
      ntfy serve --listen-http :8080  # Starts server with alternate port
 
 OPTIONS:
-   --config value, -c value                                                                            config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
+   --attachment-cache-dir value, --attachment_cache_dir value                                          cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
+   --attachment-expiry-duration value, --attachment_expiry_duration value, -X value                    duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
+   --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value                    per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
+   --attachment-total-size-limit value, --attachment_total_size_limit value, -A value                  limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
+   --auth-default-access value, --auth_default_access value, -p value                                  default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
+   --auth-file value, --auth_file value, -H value                                                      auth database file used for access control [$NTFY_AUTH_FILE]
    --base-url value, --base_url value, -B value                                                        externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
+   --behind-proxy, --behind_proxy, -P                                                                  if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
+   --cache-duration since, --cache_duration since, -b since                                            buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
+   --cache-file value, --cache_file value, -C value                                                    cache file used for message caching [$NTFY_CACHE_FILE]
+   --cert-file value, --cert_file value, -E value                                                      certificate file, if listen-https is set [$NTFY_CERT_FILE]
+   --config value, -c value                                                                            config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
+   --debug, -d                                                                                         enable debug logging (default: false) [$NTFY_DEBUG]
+   --firebase-key-file value, --firebase_key_file value, -F value                                      Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
+   --global-topic-limit value, --global_topic_limit value, -T value                                    total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
+   --keepalive-interval value, --keepalive_interval value, -k value                                    interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
+   --key-file value, --key_file value, -K value                                                        private key file, if listen-https is set [$NTFY_KEY_FILE]
    --listen-http value, --listen_http value, -l value                                                  ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
    --listen-https value, --listen_https value, -L value                                                ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
    --listen-unix value, --listen_unix value, -U value                                                  listen on unix socket path [$NTFY_LISTEN_UNIX]
-   --key-file value, --key_file value, -K value                                                        private key file, if listen-https is set [$NTFY_KEY_FILE]
-   --cert-file value, --cert_file value, -E value                                                      certificate file, if listen-https is set [$NTFY_CERT_FILE]
-   --firebase-key-file value, --firebase_key_file value, -F value                                      Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
-   --cache-file value, --cache_file value, -C value                                                    cache file used for message caching [$NTFY_CACHE_FILE]
-   --cache-duration since, --cache_duration since, -b since                                            buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
-   --auth-file value, --auth_file value, -H value                                                      auth database file used for access control [$NTFY_AUTH_FILE]
-   --auth-default-access value, --auth_default_access value, -p value                                  default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
-   --attachment-cache-dir value, --attachment_cache_dir value                                          cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
-   --attachment-total-size-limit value, --attachment_total_size_limit value, -A value                  limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
-   --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value                    per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
-   --attachment-expiry-duration value, --attachment_expiry_duration value, -X value                    duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
-   --keepalive-interval value, --keepalive_interval value, -k value                                    interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
+   --log-level value, --log_level value                                                                set log level (default: "INFO") [$NTFY_LOG_LEVEL]
    --manager-interval value, --manager_interval value, -m value                                        interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
-   --web-root value, --web_root value                                                                  sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
+   --no-log-dates, --no_log_dates                                                                      disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
    --smtp-sender-addr value, --smtp_sender_addr value                                                  SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
-   --smtp-sender-user value, --smtp_sender_user value                                                  SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
-   --smtp-sender-pass value, --smtp_sender_pass value                                                  SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
    --smtp-sender-from value, --smtp_sender_from value                                                  SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
-   --smtp-server-listen value, --smtp_server_listen value                                              SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
-   --smtp-server-domain value, --smtp_server_domain value                                              SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
+   --smtp-sender-pass value, --smtp_sender_pass value                                                  SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
+   --smtp-sender-user value, --smtp_sender_user value                                                  SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
    --smtp-server-addr-prefix value, --smtp_server_addr_prefix value                                    SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
-   --global-topic-limit value, --global_topic_limit value, -T value                                    total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
-   --visitor-subscription-limit value, --visitor_subscription_limit value                              number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
-   --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value            total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
+   --smtp-server-domain value, --smtp_server_domain value                                              SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
+   --smtp-server-listen value, --smtp_server_listen value                                              SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
+   --trace                                                                                             enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
+   --upstream-base-url value, --upstream_base_url value                                                forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
    --visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value  total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
-   --visitor-request-limit-burst value, --visitor_request_limit_burst value                            initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
-   --visitor-request-limit-replenish value, --visitor_request_limit_replenish value                    interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
-   --visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value              hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
+   --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value            total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
    --visitor-email-limit-burst value, --visitor_email_limit_burst value                                initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
    --visitor-email-limit-replenish value, --visitor_email_limit_replenish value                        interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
-   --behind-proxy, --behind_proxy, -P                                                                  if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
-   --help, -h                                                                                          show help (default: false)
+   --visitor-request-limit-burst value, --visitor_request_limit_burst value                            initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
+   --visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value              hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
+   --visitor-request-limit-replenish value, --visitor_request_limit_replenish value                    interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
+   --visitor-subscription-limit value, --visitor_subscription_limit value                              number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
+   --web-root value, --web_root value                                                                  sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
 ```
 
diff --git a/docs/examples.md b/docs/examples.md
index 8c94daf6..26be6b30 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -44,7 +44,7 @@ fi
 
 ## Server-sent messages in your web app
 Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own
-web application. Check out the <a href="/example.html">live example</a> or just look the source of this page.
+web application. Check out the <a href="/example.html">live example</a>.
 
 ## Notify on SSH login
 Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I
diff --git a/docs/install.md b/docs/install.md
index 0d7f1ed4..740a2699 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -26,37 +26,37 @@ deb/rpm packages.
 
 === "x86_64/amd64"
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_x86_64.tar.gz
-    tar zxvf ntfy_1.24.0_linux_x86_64.tar.gz
-    sudo cp -a ntfy_1.24.0_linux_x86_64/ntfy /usr/bin/ntfy
-    sudo mkdir /etc/ntfy && sudo cp ntfy_1.24.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
+    wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_x86_64.tar.gz
+    tar zxvf ntfy_1.25.2_linux_x86_64.tar.gz
+    sudo cp -a ntfy_1.25.2_linux_x86_64/ntfy /usr/bin/ntfy
+    sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.2_linux_x86_64/{client,server}/*.yml /etc/ntfy
     sudo ntfy serve
     ```
 
 === "armv6"
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv6.tar.gz
-    tar zxvf ntfy_1.24.0_linux_armv6.tar.gz
-    sudo cp -a ntfy_1.24.0_linux_armv6/ntfy /usr/bin/ntfy
-    sudo mkdir /etc/ntfy && sudo cp ntfy_1.24.0_linux_armv6/{client,server}/*.yml /etc/ntfy
+    wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv6.tar.gz
+    tar zxvf ntfy_1.25.2_linux_armv6.tar.gz
+    sudo cp -a ntfy_1.25.2_linux_armv6/ntfy /usr/bin/ntfy
+    sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.2_linux_armv6/{client,server}/*.yml /etc/ntfy
     sudo ntfy serve
     ```
 
 === "armv7/armhf"
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv7.tar.gz
-    tar zxvf ntfy_1.24.0_linux_armv7.tar.gz
-    sudo cp -a ntfy_1.24.0_linux_armv7/ntfy /usr/bin/ntfy
-    sudo mkdir /etc/ntfy && sudo cp ntfy_1.24.0_linux_armv7/{client,server}/*.yml /etc/ntfy
+    wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv7.tar.gz
+    tar zxvf ntfy_1.25.2_linux_armv7.tar.gz
+    sudo cp -a ntfy_1.25.2_linux_armv7/ntfy /usr/bin/ntfy
+    sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.2_linux_armv7/{client,server}/*.yml /etc/ntfy
     sudo ntfy serve
     ```
 
 === "arm64"
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_arm64.tar.gz
-    tar zxvf ntfy_1.24.0_linux_arm64.tar.gz
-    sudo cp -a ntfy_1.24.0_linux_arm64/ntfy /usr/bin/ntfy
-    sudo mkdir /etc/ntfy && sudo cp ntfy_1.24.0_linux_arm64/{client,server}/*.yml /etc/ntfy
+    wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_arm64.tar.gz
+    tar zxvf ntfy_1.25.2_linux_arm64.tar.gz
+    sudo cp -a ntfy_1.25.2_linux_arm64/ntfy /usr/bin/ntfy
+    sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.2_linux_arm64/{client,server}/*.yml /etc/ntfy
     sudo ntfy serve
     ```
 
@@ -103,7 +103,7 @@ Manually installing the .deb file:
 
 === "x86_64/amd64"
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_amd64.deb
+    wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_amd64.deb
     sudo dpkg -i ntfy_*.deb
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
@@ -111,7 +111,7 @@ Manually installing the .deb file:
 
 === "armv6"
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv6.deb
+    wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv6.deb
     sudo dpkg -i ntfy_*.deb
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
@@ -119,7 +119,7 @@ Manually installing the .deb file:
 
 === "armv7/armhf"
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv7.deb
+    wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv7.deb
     sudo dpkg -i ntfy_*.deb
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
@@ -127,7 +127,7 @@ Manually installing the .deb file:
 
 === "arm64"
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_arm64.deb
+    wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_arm64.deb
     sudo dpkg -i ntfy_*.deb
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
@@ -137,28 +137,28 @@ Manually installing the .deb file:
 
 === "x86_64/amd64"
     ```bash
-    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_amd64.rpm
+    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_amd64.rpm
     sudo systemctl enable ntfy 
     sudo systemctl start ntfy
     ```
 
 === "armv6"
     ```bash
-    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv6.rpm
+    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv6.rpm
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
     ```
 
 === "armv7/armhf"
     ```bash
-    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_armv7.rpm
+    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_armv7.rpm
     sudo systemctl enable ntfy 
     sudo systemctl start ntfy
     ```
 
 === "arm64"
     ```bash
-    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_linux_arm64.rpm
+    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_linux_arm64.rpm
     sudo systemctl enable ntfy 
     sudo systemctl start ntfy
     ```
@@ -176,6 +176,12 @@ cd ntfysh-bin
 makepkg -si
 ```
 
+## NixOS / Nix
+ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:
+```
+nix-env -iA ntfy-sh
+```
+
 ## macOS
 The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. 
 To install, please download the tarball, extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). 
@@ -184,11 +190,11 @@ If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For a
 `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
 
 ```bash
-curl https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_1.24.0_macOS_all.tar.gz > ntfy_1.24.0_macOS_all.tar.gz
-tar zxvf ntfy_1.24.0_macOS_all.tar.gz
-sudo cp -a ntfy_1.24.0_macOS_all/ntfy /usr/local/bin/ntfy
+curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_1.25.2_macOS_all.tar.gz > ntfy_1.25.2_macOS_all.tar.gz
+tar zxvf ntfy_1.25.2_macOS_all.tar.gz
+sudo cp -a ntfy_1.25.2_macOS_all/ntfy /usr/local/bin/ntfy
 mkdir ~/Library/Application\ Support/ntfy 
-cp ntfy_1.24.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
+cp ntfy_1.25.2_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
 ntfy --help
 ```
 
@@ -200,11 +206,15 @@ ntfy --help
 
 ## Windows
 The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
-To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.24.0/ntfy_v1.24.0_windows_x86_64.zip),
+To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.25.2/ntfy_v1.25.2_windows_x86_64.zip),
 extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. 
 
 The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
 
+Also available in [Scoop's](https://scoop.sh) Main repository:
+
+`scoop install ntfy`
+
 !!! info
     There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
     [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
@@ -233,17 +243,19 @@ docker run \
     serve
 ```
 
-With other config options (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
+With other config options, timezone, and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
 ```bash
 docker run \
   -v /etc/ntfy:/etc/ntfy \
+  -e TZ=UTC \
   -p 80:80 \
+  -u UID:GID \
   -it \
   binwiederhier/ntfy \
   serve
 ```
 
-Using docker-compose:
+Using docker-compose with non-root user:
 ```yaml
 version: "2.1"
 
@@ -253,6 +265,9 @@ services:
     container_name: ntfy
     command:
       - serve
+    environment:
+      - TZ=UTC    # optional: set desired timezone
+    user: UID:GID # optional: replace with your own user/group or uid/gid
     volumes:
       - /var/cache/ntfy:/var/cache/ntfy
       - /etc/ntfy:/etc/ntfy
@@ -261,6 +276,8 @@ services:
     restart: unless-stopped
 ```
 
+If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files to the same uid/gid.
+
 Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
 ```
 FROM binwiederhier/ntfy
diff --git a/docs/privacy.md b/docs/privacy.md
index 5a36a1c8..f89f9aaa 100644
--- a/docs/privacy.md
+++ b/docs/privacy.md
@@ -8,5 +8,5 @@ any outside service. All data is exclusively used to make the service function p
 I use is Firebase Cloud Messaging (FCM) service, which is required to provide instant Android notifications (see
 [FAQ](faq.md) for details). To avoid FCM altogether, download the F-Droid version.
 
-The web server does not log or otherwise store request paths, remote IP addresses or even topics or messages,
-aside from a short on-disk cache to support service restarts.
+For debugging purposes, the ntfy server may temporarily log request paths, remote IP addresses or even topics 
+or messages, though typically this is turned off.
diff --git a/docs/releases.md b/docs/releases.md
index 1fdf36bc..a6ff64fe 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -4,7 +4,67 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 <!--
 
-## ntfy iOS app v1.1 (UNRELEASED)
+## ntfy Android app v1.14.0 (UNRELEASED)
+
+**Additional translations:**
+
+* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
+
+## ntfy server v1.26.0 (UNRELEASED)
+
+**Features:**
+
+* Windows CLI is now available via [Scoop](https://scoop.sh) ([ScoopInstaller#3594](https://github.com/ScoopInstaller/Main/pull/3594), [#311](https://github.com/binwiederhier/ntfy/pull/311), [#269](https://github.com/binwiederhier/ntfy/issues/269), thanks to [@kzshantonu](https://github.com/kzshantonu))
+
+-->
+
+## ntfy server v1.25.2
+Released June 2, 2022
+
+This release adds the ability to set a log level to facilitate easier debugging of live systems. It also solves a 
+production problem with a few over-users that resulted in Firebase quota problems (only applying to the over-users). 
+We now block visitors from using Firebase if they trigger a quota exceeded response.
+
+On top of that, we updated the Firebase SDK and are now building the release in GitHub Actions. We've also got two
+more translations: Chinese/Simplified and Dutch.
+
+**Features:**
+
+* Advanced logging, with different log levels and hot reloading of the log level ([#284](https://github.com/binwiederhier/ntfy/pull/284))
+
+**Bugs**:
+
+* Respect Firebase "quota exceeded" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289))
+* Fix documentation header blue header due to mkdocs-material theme update (no ticket) 
+
+**Maintenance:**
+
+* Upgrade Firebase Admin SDK to 4.x ([#274](https://github.com/binwiederhier/ntfy/issues/274))
+* CI: Build from pipeline instead of locally ([#36](https://github.com/binwiederhier/ntfy/issues/36))
+
+**Documentation**:
+
+* ⚠️ [Privacy policy](privacy.md) updated to reflect additional debug/tracing feature (no ticket)
+* [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs))
+* Install instructions for [NixOS/Nix](https://ntfy.sh/docs/install/#nixos-nix) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@arjan-s](https://github.com/arjan-s))
+* Clarify `poll_request` wording for [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) ([#300](https://github.com/binwiederhier/ntfy/issues/300), thanks to [@prabirshrestha](https://github.com/prabirshrestha) for reporting)
+* Example for using ntfy with docker-compose.yml without root privileges ([#304](https://github.com/binwiederhier/ntfy/pull/304), thanks to [@ksurl](https://github.com/ksurl))
+
+**Additional translations:**
+
+* Chinese/Simplified (thanks to [@yufei.im](https://hosted.weblate.org/user/yufei.im/))
+* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/))
+
+## ntfy iOS app v1.1
+Released May 31, 2022
+
+In this release of the iOS app, we add message priorities (mapped to iOS interruption levels), tags and emojis,
+action buttons to open websites or perform HTTP requests (in the notification and the detail view), a custom click
+action when the notification is tapped, and various other fixes.
+
+It also adds support for self-hosted servers (albeit not supporting auth yet). The self-hosted server needs to be
+configured to forward poll requests to upstream ntfy.sh for push notifications to work (see [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications)
+for details).
 
 **Features:**
 
@@ -21,22 +81,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 * iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267))
 
-
-## ntfy Android app v1.14.0 (UNRELEASED)
-
-**Additional translations:**
-
-* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
-
-
-## ntfy server v1.25.0 (UNRELEASED)
-
-**Documentation**:
-
-* [Examples](examples.md) for [Home Assistant](https://www.home-assistant.io/) ([#282](https://github.com/binwiederhier/ntfy/pull/282), thanks to [@poblabs](https://github.com/poblabs))
-
--->
-
 ## ntfy server v1.24.0
 Released May 28, 2022
 
diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css
index cb71a018..a7370399 100644
--- a/docs/static/css/extra.css
+++ b/docs/static/css/extra.css
@@ -1,4 +1,4 @@
-:root {
+:root > * {
     --md-primary-fg-color:        #338574;
     --md-primary-fg-color--light: #338574;
     --md-primary-fg-color--dark:  #338574;
diff --git a/docs/static/js/extra.js b/docs/static/js/extra.js
index 0aa380a7..6ddf07a9 100644
--- a/docs/static/js/extra.js
+++ b/docs/static/js/extra.js
@@ -1,8 +1,8 @@
 // Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
 
-const savedTab = localStorage.getItem('savedTab')
-const tabs = document.querySelectorAll(".tabbed-set > input")
-for (const tab of tabs) {
+const savedCodeTab = localStorage.getItem('savedTab')
+const codeTabs = document.querySelectorAll(".tabbed-set > input")
+for (const tab of codeTabs) {
     tab.addEventListener("click", () => {
         const current = document.querySelector(`label[for=${tab.id}]`)
         const pos = current.getBoundingClientRect().top
@@ -25,7 +25,7 @@ for (const tab of tabs) {
     // Select saved tab
     const current = document.querySelector(`label[for=${tab.id}]`)
     const labelContent = current.innerHTML
-    if (savedTab === labelContent) {
+    if (savedCodeTab === labelContent) {
         tab.checked = true
     }
 }
diff --git a/go.mod b/go.mod
index f00c1c33..6c36e8fc 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.17
 require (
 	cloud.google.com/go/firestore v1.6.1 // indirect
 	cloud.google.com/go/storage v1.22.1 // indirect
-	firebase.google.com/go v3.13.0+incompatible
 	github.com/BurntSushi/toml v1.1.0 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
 	github.com/emersion/go-smtp v0.15.0
@@ -17,15 +16,17 @@ require (
 	github.com/urfave/cli/v2 v2.8.1
 	golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
 	golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 // indirect
-	golang.org/x/sync v0.0.0-20220513210516-0976fa681c29
+	golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467
 	golang.org/x/time v0.0.0-20220411224347-583f2d630306
-	google.golang.org/api v0.81.0
+	google.golang.org/api v0.82.0
 	gopkg.in/yaml.v2 v2.4.0
 )
 
 require github.com/pkg/errors v0.9.1 // indirect
 
+require firebase.google.com/go/v4 v4.8.0
+
 require (
 	cloud.google.com/go v0.102.0 // indirect
 	cloud.google.com/go/compute v1.6.1 // indirect
@@ -43,13 +44,14 @@ require (
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
 	go.opencensus.io v0.23.0 // indirect
-	golang.org/x/net v0.0.0-20220526153639-5463443f8c37 // indirect
+	golang.org/x/net v0.0.0-20220531201128-c960675eff93 // indirect
 	golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58 // indirect
-	google.golang.org/grpc v1.46.2 // indirect
+	google.golang.org/appengine/v2 v2.0.1 // indirect
+	google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect
+	google.golang.org/grpc v1.47.0 // indirect
 	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
 )
diff --git a/go.sum b/go.sum
index 4a4b581a..e0f0204e 100644
--- a/go.sum
+++ b/go.sum
@@ -26,6 +26,7 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y
 cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
 cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
 cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
+cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
 cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
 cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8=
 cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
@@ -36,6 +37,7 @@ cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUM
 cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
 cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
+cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
 cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
 cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
 cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
@@ -45,6 +47,7 @@ cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
 cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
 cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
+cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
 cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
 cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
@@ -56,11 +59,12 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
 cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg=
 cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
-firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
+firebase.google.com/go/v4 v4.8.0 h1:ooJqjFEh1G6DQ5+wyb/RAXAgku0E2RzJeH6WauSpWSo=
+firebase.google.com/go/v4 v4.8.0/go.mod h1:y+j6xX7BgBco/XaN+YExIBVm6pzvYutheDV3nprvbWc=
 github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
 github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
 github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
@@ -337,9 +341,9 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su
 golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220526153639-5463443f8c37 h1:lUkvobShwKsOesNfWWlCS5q7fnbG1MEliIzwu886fn8=
 golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA=
+golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -373,8 +377,9 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 h1:w8s32wxx3sY+OjLlv9qltkLU5yvJzxjjgiHWLjdIcw4=
 golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8=
+golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -425,9 +430,11 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -543,15 +550,19 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr
 google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
 google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
 google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
+google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
+google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
 google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
+google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80=
 google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
 google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
+google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI=
 google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
 google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
 google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
 google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
-google.golang.org/api v0.81.0 h1:o8WF5AvfidafWbFjsRyupxyEQJNUWxLZJCK5NXrxZZ8=
-google.golang.org/api v0.81.0/go.mod h1:FA6Mb/bZxj706H2j+j2d6mHEEaHBmbbWnkfvmorOCko=
+google.golang.org/api v0.82.0 h1:h6EGeZuzhoKSS7BUznzkW+2wHZ+4Ubd6rsVvvh3dRkw=
+google.golang.org/api v0.82.0/go.mod h1:Ld58BeTlL9DIYr2M2ajvoSqmGLei0BMn+kVBmkam1os=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -560,6 +571,8 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
 google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
 google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine/v2 v2.0.1 h1:jTGfiRmR5qoInpT3CXJ72GJEB4owDGEKN+xRDA6ekBY=
+google.golang.org/appengine/v2 v2.0.1/go.mod h1:XgltgQxPOF3ShivrVrZyfvYCx8Dunh73bKjUuXUZb8Q=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
 google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -623,8 +636,14 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6
 google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
 google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
@@ -637,10 +656,10 @@ google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX
 google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
 google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
-google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
 google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
-google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58 h1:a221mAAEAzq4Lz6ZWRkcS8ptb2mxoxYSt4N68aRyQHM=
 google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
+google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM=
+google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -670,8 +689,9 @@ google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9K
 google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
 google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
-google.golang.org/grpc v1.46.2 h1:u+MLGgVf7vRdjEYZ8wDFhAVNmhkbJ5hmrA1LMWK1CAQ=
 google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8=
+google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
diff --git a/log/log.go b/log/log.go
new file mode 100644
index 00000000..5dac2327
--- /dev/null
+++ b/log/log.go
@@ -0,0 +1,129 @@
+package log
+
+import (
+	"log"
+	"strings"
+	"sync"
+)
+
+// Level is a well-known log level, as defined below
+type Level int
+
+// Well known log levels
+const (
+	TraceLevel Level = iota
+	DebugLevel
+	InfoLevel
+	WarnLevel
+	ErrorLevel
+)
+
+func (l Level) String() string {
+	switch l {
+	case TraceLevel:
+		return "TRACE"
+	case DebugLevel:
+		return "DEBUG"
+	case InfoLevel:
+		return "INFO"
+	case WarnLevel:
+		return "WARN"
+	case ErrorLevel:
+		return "ERROR"
+	}
+	return "unknown"
+}
+
+var (
+	level = InfoLevel
+	mu    = &sync.Mutex{}
+)
+
+// Trace prints the given message, if the current log level is TRACE
+func Trace(message string, v ...interface{}) {
+	logIf(TraceLevel, message, v...)
+}
+
+// Debug prints the given message, if the current log level is DEBUG or lower
+func Debug(message string, v ...interface{}) {
+	logIf(DebugLevel, message, v...)
+}
+
+// Info prints the given message, if the current log level is INFO or lower
+func Info(message string, v ...interface{}) {
+	logIf(InfoLevel, message, v...)
+}
+
+// Warn prints the given message, if the current log level is WARN or lower
+func Warn(message string, v ...interface{}) {
+	logIf(WarnLevel, message, v...)
+}
+
+// Error prints the given message, if the current log level is ERROR or lower
+func Error(message string, v ...interface{}) {
+	logIf(ErrorLevel, message, v...)
+}
+
+// Fatal prints the given message, and exits the program
+func Fatal(v ...interface{}) {
+	log.Fatalln(v...)
+}
+
+// CurrentLevel returns the current log level
+func CurrentLevel() Level {
+	mu.Lock()
+	defer mu.Unlock()
+	return level
+}
+
+// SetLevel sets a new log level
+func SetLevel(newLevel Level) {
+	mu.Lock()
+	defer mu.Unlock()
+	level = newLevel
+}
+
+// DisableDates disables the date/time prefix
+func DisableDates() {
+	log.SetFlags(0)
+}
+
+// ToLevel converts a string to a Level. It returns InfoLevel if the string
+// does not match any known log levels.
+func ToLevel(s string) Level {
+	switch strings.ToUpper(s) {
+	case "TRACE":
+		return TraceLevel
+	case "DEBUG":
+		return DebugLevel
+	case "INFO":
+		return InfoLevel
+	case "WARN", "WARNING":
+		return WarnLevel
+	case "ERROR":
+		return ErrorLevel
+	default:
+		return InfoLevel
+	}
+}
+
+// Loggable returns true if the given log level is lower or equal to the current log level
+func Loggable(l Level) bool {
+	return CurrentLevel() <= l
+}
+
+// IsTrace returns true if the current log level is TraceLevel
+func IsTrace() bool {
+	return Loggable(TraceLevel)
+}
+
+// IsDebug returns true if the current log level is DebugLevel or below
+func IsDebug() bool {
+	return Loggable(DebugLevel)
+}
+
+func logIf(l Level, message string, v ...interface{}) {
+	if CurrentLevel() <= l {
+		log.Printf(l.String()+" "+message, v...)
+	}
+}
diff --git a/server/config.go b/server/config.go
index 4db52a33..60d3cdf9 100644
--- a/server/config.go
+++ b/server/config.go
@@ -6,15 +6,16 @@ import (
 
 // Defines default config settings (excluding limits, see below)
 const (
-	DefaultListenHTTP                = ":80"
-	DefaultCacheDuration             = 12 * time.Hour
-	DefaultKeepaliveInterval         = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
-	DefaultManagerInterval           = time.Minute
-	DefaultAtSenderInterval          = 10 * time.Second
-	DefaultMinDelay                  = 10 * time.Second
-	DefaultMaxDelay                  = 3 * 24 * time.Hour
-	DefaultFirebaseKeepaliveInterval = 3 * time.Hour    // ~control topic (Android), not too frequently to save battery
-	DefaultFirebasePollInterval      = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
+	DefaultListenHTTP                           = ":80"
+	DefaultCacheDuration                        = 12 * time.Hour
+	DefaultKeepaliveInterval                    = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
+	DefaultManagerInterval                      = time.Minute
+	DefaultDelayedSenderInterval                = 10 * time.Second
+	DefaultMinDelay                             = 10 * time.Second
+	DefaultMaxDelay                             = 3 * 24 * time.Hour
+	DefaultFirebaseKeepaliveInterval            = 3 * time.Hour    // ~control topic (Android), not too frequently to save battery
+	DefaultFirebasePollInterval                 = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
+	DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
 )
 
 // Defines all global and per-visitor limits
@@ -66,9 +67,10 @@ type Config struct {
 	KeepaliveInterval                    time.Duration
 	ManagerInterval                      time.Duration
 	WebRootIsApp                         bool
-	AtSenderInterval                     time.Duration
+	DelayedSenderInterval                time.Duration
 	FirebaseKeepaliveInterval            time.Duration
 	FirebasePollInterval                 time.Duration
+	FirebaseQuotaExceededPenaltyDuration time.Duration
 	UpstreamBaseURL                      string
 	SMTPSenderAddr                       string
 	SMTPSenderUser                       string
@@ -118,9 +120,10 @@ func NewConfig() *Config {
 		MessageLimit:                         DefaultMessageLengthLimit,
 		MinDelay:                             DefaultMinDelay,
 		MaxDelay:                             DefaultMaxDelay,
-		AtSenderInterval:                     DefaultAtSenderInterval,
+		DelayedSenderInterval:                DefaultDelayedSenderInterval,
 		FirebaseKeepaliveInterval:            DefaultFirebaseKeepaliveInterval,
 		FirebasePollInterval:                 DefaultFirebasePollInterval,
+		FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
 		TotalTopicLimit:                      DefaultTotalTopicLimit,
 		VisitorSubscriptionLimit:             DefaultVisitorSubscriptionLimit,
 		VisitorAttachmentTotalSizeLimit:      DefaultVisitorAttachmentTotalSizeLimit,
diff --git a/server/message_cache.go b/server/message_cache.go
index b55c34ba..77aa4f78 100644
--- a/server/message_cache.go
+++ b/server/message_cache.go
@@ -6,8 +6,8 @@ import (
 	"errors"
 	"fmt"
 	_ "github.com/mattn/go-sqlite3" // SQLite driver
+	"heckel.io/ntfy/log"
 	"heckel.io/ntfy/util"
-	"log"
 	"strings"
 	"time"
 )
@@ -36,7 +36,7 @@ const (
 			attachment_size INT NOT NULL,
 			attachment_expires INT NOT NULL,
 			attachment_url TEXT NOT NULL,
-			attachment_owner TEXT NOT NULL,
+			sender TEXT NOT NULL,
 			encoding TEXT NOT NULL,
 			published INT NOT NULL
 		);
@@ -45,37 +45,37 @@ const (
 		COMMIT;
 	`
 	insertMessageQuery = `
-		INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published) 
+		INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published) 
 		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 	`
 	pruneMessagesQuery           = `DELETE FROM messages WHERE time < ? AND published = 1`
 	selectRowIDFromMessageID     = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
 	selectMessagesSinceTimeQuery = `
-		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
+		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 		FROM messages 
 		WHERE topic = ? AND time >= ? AND published = 1
 		ORDER BY time, id
 	`
 	selectMessagesSinceTimeIncludeScheduledQuery = `
-		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
+		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 		FROM messages 
 		WHERE topic = ? AND time >= ?
 		ORDER BY time, id
 	`
 	selectMessagesSinceIDQuery = `
-		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
+		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 		FROM messages 
 		WHERE topic = ? AND id > ? AND published = 1 
 		ORDER BY time, id
 	`
 	selectMessagesSinceIDIncludeScheduledQuery = `
-		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
+		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 		FROM messages 
 		WHERE topic = ? AND (id > ? OR published = 0)
 		ORDER BY time, id
 	`
 	selectMessagesDueQuery = `
-		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
+		SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
 		FROM messages 
 		WHERE time <= ? AND published = 0
 		ORDER BY time, id
@@ -84,13 +84,13 @@ const (
 	selectMessagesCountQuery        = `SELECT COUNT(*) FROM messages`
 	selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
 	selectTopicsQuery               = `SELECT topic FROM messages GROUP BY topic`
-	selectAttachmentsSizeQuery      = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?`
+	selectAttachmentsSizeQuery      = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?`
 	selectAttachmentsExpiredQuery   = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
 )
 
 // Schema management queries
 const (
-	currentSchemaVersion          = 6
+	currentSchemaVersion          = 7
 	createSchemaVersionTableQuery = `
 		CREATE TABLE IF NOT EXISTS schemaVersion (
 			id INT PRIMARY KEY,
@@ -173,6 +173,11 @@ const (
 	migrate5To6AlterMessagesTableQuery = `
 		ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
 	`
+
+	// 6 -> 7
+	migrate6To7AlterMessagesTableQuery = `
+		ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
+	`
 )
 
 type messageCache struct {
@@ -225,7 +230,7 @@ func (c *messageCache) AddMessage(m *message) error {
 	}
 	published := m.Time <= time.Now().Unix()
 	tags := strings.Join(m.Tags, ",")
-	var attachmentName, attachmentType, attachmentURL, attachmentOwner string
+	var attachmentName, attachmentType, attachmentURL string
 	var attachmentSize, attachmentExpires int64
 	if m.Attachment != nil {
 		attachmentName = m.Attachment.Name
@@ -233,7 +238,6 @@ func (c *messageCache) AddMessage(m *message) error {
 		attachmentSize = m.Attachment.Size
 		attachmentExpires = m.Attachment.Expires
 		attachmentURL = m.Attachment.URL
-		attachmentOwner = m.Attachment.Owner
 	}
 	var actionsStr string
 	if len(m.Actions) > 0 {
@@ -259,7 +263,7 @@ func (c *messageCache) AddMessage(m *message) error {
 		attachmentSize,
 		attachmentExpires,
 		attachmentURL,
-		attachmentOwner,
+		m.Sender,
 		m.Encoding,
 		published,
 	)
@@ -371,8 +375,8 @@ func (c *messageCache) Prune(olderThan time.Time) error {
 	return err
 }
 
-func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
-	rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
+func (c *messageCache) AttachmentBytesUsed(sender string) (int64, error) {
+	rows, err := c.db.Query(selectAttachmentsSizeQuery, sender, time.Now().Unix())
 	if err != nil {
 		return 0, err
 	}
@@ -415,7 +419,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
 	for rows.Next() {
 		var timestamp, attachmentSize, attachmentExpires int64
 		var priority int
-		var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
+		var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string
 		err := rows.Scan(
 			&id,
 			&timestamp,
@@ -431,7 +435,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
 			&attachmentSize,
 			&attachmentExpires,
 			&attachmentURL,
-			&attachmentOwner,
+			&sender,
 			&encoding,
 		)
 		if err != nil {
@@ -455,7 +459,6 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
 				Size:    attachmentSize,
 				Expires: attachmentExpires,
 				URL:     attachmentURL,
-				Owner:   attachmentOwner,
 			}
 		}
 		messages = append(messages, &message{
@@ -470,6 +473,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
 			Click:      click,
 			Actions:    actions,
 			Attachment: att,
+			Sender:     sender,
 			Encoding:   encoding,
 		})
 	}
@@ -516,6 +520,8 @@ func setupCacheDB(db *sql.DB) error {
 		return migrateFrom4(db)
 	} else if schemaVersion == 5 {
 		return migrateFrom5(db)
+	} else if schemaVersion == 6 {
+		return migrateFrom6(db)
 	}
 	return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
 }
@@ -534,7 +540,7 @@ func setupNewCacheDB(db *sql.DB) error {
 }
 
 func migrateFrom0(db *sql.DB) error {
-	log.Print("Migrating cache database schema: from 0 to 1")
+	log.Info("Migrating cache database schema: from 0 to 1")
 	if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
 		return err
 	}
@@ -548,7 +554,7 @@ func migrateFrom0(db *sql.DB) error {
 }
 
 func migrateFrom1(db *sql.DB) error {
-	log.Print("Migrating cache database schema: from 1 to 2")
+	log.Info("Migrating cache database schema: from 1 to 2")
 	if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
 		return err
 	}
@@ -559,7 +565,7 @@ func migrateFrom1(db *sql.DB) error {
 }
 
 func migrateFrom2(db *sql.DB) error {
-	log.Print("Migrating cache database schema: from 2 to 3")
+	log.Info("Migrating cache database schema: from 2 to 3")
 	if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
 		return err
 	}
@@ -570,7 +576,7 @@ func migrateFrom2(db *sql.DB) error {
 }
 
 func migrateFrom3(db *sql.DB) error {
-	log.Print("Migrating cache database schema: from 3 to 4")
+	log.Info("Migrating cache database schema: from 3 to 4")
 	if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
 		return err
 	}
@@ -581,7 +587,7 @@ func migrateFrom3(db *sql.DB) error {
 }
 
 func migrateFrom4(db *sql.DB) error {
-	log.Print("Migrating cache database schema: from 4 to 5")
+	log.Info("Migrating cache database schema: from 4 to 5")
 	if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
 		return err
 	}
@@ -592,12 +598,23 @@ func migrateFrom4(db *sql.DB) error {
 }
 
 func migrateFrom5(db *sql.DB) error {
-	log.Print("Migrating cache database schema: from 5 to 6")
+	log.Info("Migrating cache database schema: from 5 to 6")
 	if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
 		return err
 	}
 	if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
 		return err
 	}
+	return migrateFrom6(db)
+}
+
+func migrateFrom6(db *sql.DB) error {
+	log.Info("Migrating cache database schema: from 6 to 7")
+	if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
+		return err
+	}
+	if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
+		return err
+	}
 	return nil // Update this when a new version is added
 }
diff --git a/server/message_cache_test.go b/server/message_cache_test.go
index cb888b42..398f21e4 100644
--- a/server/message_cache_test.go
+++ b/server/message_cache_test.go
@@ -281,39 +281,39 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
 	expires1 := time.Now().Add(-4 * time.Hour).Unix()
 	m := newDefaultMessage("mytopic", "flower for you")
 	m.ID = "m1"
+	m.Sender = "1.2.3.4"
 	m.Attachment = &attachment{
 		Name:    "flower.jpg",
 		Type:    "image/jpeg",
 		Size:    5000,
 		Expires: expires1,
 		URL:     "https://ntfy.sh/file/AbDeFgJhal.jpg",
-		Owner:   "1.2.3.4",
 	}
 	require.Nil(t, c.AddMessage(m))
 
 	expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
 	m = newDefaultMessage("mytopic", "sending you a car")
 	m.ID = "m2"
+	m.Sender = "1.2.3.4"
 	m.Attachment = &attachment{
 		Name:    "car.jpg",
 		Type:    "image/jpeg",
 		Size:    10000,
 		Expires: expires2,
 		URL:     "https://ntfy.sh/file/aCaRURL.jpg",
-		Owner:   "1.2.3.4",
 	}
 	require.Nil(t, c.AddMessage(m))
 
 	expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
 	m = newDefaultMessage("another-topic", "sending you another car")
 	m.ID = "m3"
+	m.Sender = "1.2.3.4"
 	m.Attachment = &attachment{
 		Name:    "another-car.jpg",
 		Type:    "image/jpeg",
 		Size:    20000,
 		Expires: expires3,
 		URL:     "https://ntfy.sh/file/zakaDHFW.jpg",
-		Owner:   "1.2.3.4",
 	}
 	require.Nil(t, c.AddMessage(m))
 
@@ -327,7 +327,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
 	require.Equal(t, int64(5000), messages[0].Attachment.Size)
 	require.Equal(t, expires1, messages[0].Attachment.Expires)
 	require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
-	require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner)
+	require.Equal(t, "1.2.3.4", messages[0].Sender)
 
 	require.Equal(t, "sending you a car", messages[1].Message)
 	require.Equal(t, "car.jpg", messages[1].Attachment.Name)
@@ -335,7 +335,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
 	require.Equal(t, int64(10000), messages[1].Attachment.Size)
 	require.Equal(t, expires2, messages[1].Attachment.Expires)
 	require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
-	require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
+	require.Equal(t, "1.2.3.4", messages[1].Sender)
 
 	size, err := c.AttachmentBytesUsed("1.2.3.4")
 	require.Nil(t, err)
diff --git a/server/ntfy.service b/server/ntfy.service
index 6645b21f..8bf250a5 100644
--- a/server/ntfy.service
+++ b/server/ntfy.service
@@ -5,7 +5,8 @@ After=network.target
 [Service]
 User=ntfy
 Group=ntfy
-ExecStart=/usr/bin/ntfy serve
+ExecStart=/usr/bin/ntfy serve --no-log-dates
+ExecReload=/bin/kill --signal HUP $MAINPID
 Restart=on-failure
 AmbientCapabilities=CAP_NET_BIND_SERVICE
 LimitNOFILE=10000
diff --git a/server/server.go b/server/server.go
index 86ed7539..0b909b80 100644
--- a/server/server.go
+++ b/server/server.go
@@ -7,13 +7,11 @@ import (
 	"embed"
 	"encoding/base64"
 	"encoding/json"
-	"errors"
 	"fmt"
+	"heckel.io/ntfy/log"
 	"io"
-	"log"
 	"net"
 	"net/http"
-	"net/http/httptest"
 	"net/url"
 	"os"
 	"path"
@@ -34,22 +32,22 @@ import (
 
 // Server is the main server, providing the UI and API for ntfy
 type Server struct {
-	config       *Config
-	httpServer   *http.Server
-	httpsServer  *http.Server
-	unixListener net.Listener
-	smtpServer   *smtp.Server
-	smtpBackend  *smtpBackend
-	topics       map[string]*topic
-	visitors     map[string]*visitor
-	firebase     subscriber
-	mailer       mailer
-	messages     int64
-	auth         auth.Auther
-	messageCache *messageCache
-	fileCache    *fileCache
-	closeChan    chan bool
-	mu           sync.Mutex
+	config            *Config
+	httpServer        *http.Server
+	httpsServer       *http.Server
+	unixListener      net.Listener
+	smtpServer        *smtp.Server
+	smtpServerBackend *smtpBackend
+	smtpSender        mailer
+	topics            map[string]*topic
+	visitors          map[string]*visitor
+	firebaseClient    *firebaseClient
+	messages          int64
+	auth              auth.Auther
+	messageCache      *messageCache
+	fileCache         *fileCache
+	closeChan         chan bool
+	mu                sync.Mutex
 }
 
 // handleFunc extends the normal http.HandlerFunc to be able to easily return errors
@@ -136,23 +134,23 @@ func New(conf *Config) (*Server, error) {
 			return nil, err
 		}
 	}
-	var firebaseSubscriber subscriber
+	var firebaseClient *firebaseClient
 	if conf.FirebaseKeyFile != "" {
-		var err error
-		firebaseSubscriber, err = createFirebaseSubscriber(conf.FirebaseKeyFile, auther)
+		sender, err := newFirebaseSender(conf.FirebaseKeyFile)
 		if err != nil {
 			return nil, err
 		}
+		firebaseClient = newFirebaseClient(sender, auther)
 	}
 	return &Server{
-		config:       conf,
-		messageCache: messageCache,
-		fileCache:    fileCache,
-		firebase:     firebaseSubscriber,
-		mailer:       mailer,
-		topics:       topics,
-		auth:         auther,
-		visitors:     make(map[string]*visitor),
+		config:         conf,
+		messageCache:   messageCache,
+		fileCache:      fileCache,
+		firebaseClient: firebaseClient,
+		smtpSender:     mailer,
+		topics:         topics,
+		auth:           auther,
+		visitors:       make(map[string]*visitor),
 	}, nil
 }
 
@@ -181,7 +179,7 @@ func (s *Server) Run() error {
 	if s.config.SMTPServerListen != "" {
 		listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen)
 	}
-	log.Printf("Listening on%s", listenStr)
+	log.Info("Listening on%s, log level is %s", listenStr, log.CurrentLevel().String())
 	mux := http.NewServeMux()
 	mux.HandleFunc("/", s.handle)
 	errChan := make(chan error)
@@ -221,7 +219,7 @@ func (s *Server) Run() error {
 	}
 	s.mu.Unlock()
 	go s.runManager()
-	go s.runAtSender()
+	go s.runDelayedSender()
 	go s.runFirebaseKeepaliver()
 
 	return <-errChan
@@ -248,16 +246,27 @@ func (s *Server) Stop() {
 
 func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
 	v := s.visitor(r)
+	log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
 	if err := s.handleInternal(w, r, v); err != nil {
 		if websocket.IsWebSocketUpgrade(r) {
-			log.Printf("[%s] WS %s %s - %s", v.ip, r.Method, r.URL.Path, err.Error())
+			isNormalError := strings.Contains(err.Error(), "i/o timeout")
+			if isNormalError {
+				log.Debug("%s WebSocket error (this error is okay, it happens a lot): %s", logHTTPPrefix(v, r), err.Error())
+			} else {
+				log.Info("%s WebSocket error: %s", logHTTPPrefix(v, r), err.Error())
+			}
 			return // Do not attempt to write to upgraded connection
 		}
 		httpErr, ok := err.(*errHTTP)
 		if !ok {
 			httpErr = errHTTPInternalError
 		}
-		log.Printf("[%s] HTTP %s %s - %d - %d - %s", v.ip, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error())
+		isNormalError := httpErr.HTTPCode == http.StatusNotFound || httpErr.HTTPCode == http.StatusBadRequest
+		if isNormalError {
+			log.Debug("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error())
+		} else {
+			log.Info("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error())
+		}
 		w.Header().Set("Content-Type", "application/json")
 		w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
 		w.WriteHeader(httpErr.HTTPCode)
@@ -434,19 +443,26 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 		m.Message = emptyMessageBody
 	}
 	delayed := m.Time > time.Now().Unix()
+	log.Debug("%s Received message: event=%s, body=%d byte(s), delayed=%t, firebase=%t, cache=%t, up=%t, email=%s",
+		logMessagePrefix(v, m), m.Event, len(m.Message), delayed, firebase, cache, unifiedpush, email)
+	if log.IsTrace() {
+		log.Trace("%s Message body: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(m))
+	}
 	if !delayed {
-		if err := t.Publish(m); err != nil {
+		if err := t.Publish(v, m); err != nil {
 			return err
 		}
-	}
-	if s.firebase != nil && firebase && !delayed {
-		go s.sendToFirebase(v, m)
-	}
-	if s.mailer != nil && email != "" && !delayed {
-		go s.sendEmail(v, m, email)
-	}
-	if s.config.UpstreamBaseURL != "" {
-		go s.forwardPollRequest(v, m)
+		if s.firebaseClient != nil && firebase {
+			go s.sendToFirebase(v, m)
+		}
+		if s.smtpSender != nil && email != "" {
+			go s.sendEmail(v, m, email)
+		}
+		if s.config.UpstreamBaseURL != "" {
+			go s.forwardPollRequest(v, m)
+		}
+	} else {
+		log.Debug("%s Message delayed, will process later", logMessagePrefix(v, m))
 	}
 	if cache {
 		if err := s.messageCache.AddMessage(m); err != nil {
@@ -465,14 +481,20 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
 }
 
 func (s *Server) sendToFirebase(v *visitor, m *message) {
-	if err := s.firebase(m); err != nil {
-		log.Printf("[%s] FB - Unable to publish to Firebase: %v", v.ip, err.Error())
+	log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m))
+	if err := s.firebaseClient.Send(v, m); err != nil {
+		if err == errFirebaseTemporarilyBanned {
+			log.Debug("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error())
+		} else {
+			log.Warn("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error())
+		}
 	}
 }
 
 func (s *Server) sendEmail(v *visitor, m *message, email string) {
-	if err := s.mailer.Send(v.ip, email, m); err != nil {
-		log.Printf("[%s] MAIL - Unable to send email: %v", v.ip, err.Error())
+	log.Debug("%s Sending email to %s", logMessagePrefix(v, m), email)
+	if err := s.smtpSender.Send(v, m, email); err != nil {
+		log.Warn("%s Unable to send email to %s: %v", logMessagePrefix(v, m), email, err.Error())
 	}
 }
 
@@ -480,18 +502,22 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
 	topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
 	topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
 	forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
+	log.Debug("%s Publishing poll request to %s", logMessagePrefix(v, m), forwardURL)
 	req, err := http.NewRequest("POST", forwardURL, strings.NewReader(""))
 	if err != nil {
-		log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
+		log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
 		return
 	}
 	req.Header.Set("X-Poll-ID", m.ID)
-	response, err := http.DefaultClient.Do(req)
+	var httpClient = &http.Client{
+		Timeout: time.Second * 10,
+	}
+	response, err := httpClient.Do(req)
 	if err != nil {
-		log.Printf("[%s] FWD - Unable to forward poll request: %v", v.ip, err.Error())
+		log.Warn("%s Unable to publish poll request: %v", logMessagePrefix(v, m), err.Error())
 		return
 	} else if response.StatusCode != http.StatusOK {
-		log.Printf("[%s] FWD - Unable to forward poll request, unexpected status: %d", v.ip, response.StatusCode)
+		log.Warn("%s Unable to publish poll request, unexpected HTTP status: %d", logMessagePrefix(v, m), response.StatusCode)
 		return
 	}
 }
@@ -533,7 +559,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
 			return false, false, "", false, errHTTPTooManyRequestsLimitEmails
 		}
 	}
-	if s.mailer == nil && email != "" {
+	if s.smtpSender == nil && email != "" {
 		return false, false, "", false, errHTTPBadRequestEmailDisabled
 	}
 	messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
@@ -568,6 +594,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
 			return false, false, "", false, errHTTPBadRequestDelayTooLarge
 		}
 		m.Time = delay.Unix()
+		m.Sender = v.ip // Important for rate limiting
 	}
 	actionsStr := readParam(r, "x-actions", "actions", "action")
 	if actionsStr != "" {
@@ -606,7 +633,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
 //    If file.txt is > message limit, treat it as an attachment
 func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
 	if m.Event == pollRequestEvent { // Case 1
-		return nil
+		return s.handleBodyDiscard(body)
 	} else if unifiedpush {
 		return s.handleBodyAsMessageAutoDetect(m, body) // Case 2
 	} else if m.Attachment != nil && m.Attachment.URL != "" {
@@ -619,6 +646,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
 	return s.handleBodyAsAttachment(r, v, m, body) // Case 6
 }
 
+func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
+	_, err := io.Copy(io.Discard, body)
+	_ = body.Close()
+	return err
+}
+
 func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
 	if utf8.Valid(body.PeekedBytes) {
 		m.Message = string(body.PeekedBytes) // Do not trim
@@ -663,7 +696,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
 		m.Attachment = &attachment{}
 	}
 	var ext string
-	m.Attachment.Owner = v.ip // Important for attachment rate limiting
+	m.Sender = v.ip // Important for attachment rate limiting
 	m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix()
 	m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name)
 	m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
@@ -718,6 +751,8 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
 }
 
 func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error {
+	log.Debug("%s HTTP stream connection opened", logHTTPPrefix(v, r))
+	defer log.Debug("%s HTTP stream connection closed", logHTTPPrefix(v, r))
 	if err := v.SubscriptionAllowed(); err != nil {
 		return errHTTPTooManyRequestsLimitSubscriptions
 	}
@@ -731,7 +766,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
 		return err
 	}
 	var wlock sync.Mutex
-	sub := func(msg *message) error {
+	sub := func(v *visitor, msg *message) error {
 		if !filters.Pass(msg) {
 			return nil
 		}
@@ -752,7 +787,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
 	w.Header().Set("Access-Control-Allow-Origin", "*")            // CORS, allow cross-origin requests
 	w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
 	if poll {
-		return s.sendOldMessages(topics, since, scheduled, sub)
+		return s.sendOldMessages(topics, since, scheduled, v, sub)
 	}
 	subscriberIDs := make([]int, 0)
 	for _, t := range topics {
@@ -763,10 +798,10 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
 			topics[i].Unsubscribe(subscriberID) // Order!
 		}
 	}()
-	if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message
+	if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message
 		return err
 	}
-	if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil {
+	if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {
 		return err
 	}
 	for {
@@ -774,8 +809,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
 		case <-r.Context().Done():
 			return nil
 		case <-time.After(s.config.KeepaliveInterval):
+			log.Trace("%s Sending keepalive message", logHTTPPrefix(v, r))
 			v.Keepalive()
-			if err := sub(newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
+			if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
 				return err
 			}
 		}
@@ -790,6 +826,8 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 		return errHTTPTooManyRequestsLimitSubscriptions
 	}
 	defer v.RemoveSubscription()
+	log.Debug("%s WebSocket connection opened", logHTTPPrefix(v, r))
+	defer log.Debug("%s WebSocket connection closed", logHTTPPrefix(v, r))
 	topics, topicsStr, err := s.topicsFromPath(r.URL.Path)
 	if err != nil {
 		return err
@@ -819,6 +857,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 			return err
 		}
 		conn.SetPongHandler(func(appData string) error {
+			log.Trace("%s Received WebSocket pong", logHTTPPrefix(v, r))
 			return conn.SetReadDeadline(time.Now().Add(pongWait))
 		})
 		for {
@@ -835,6 +874,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 			if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil {
 				return err
 			}
+			log.Trace("%s Sending WebSocket ping", logHTTPPrefix(v, r))
 			return conn.WriteMessage(websocket.PingMessage, nil)
 		}
 		for {
@@ -849,7 +889,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 			}
 		}
 	})
-	sub := func(msg *message) error {
+	sub := func(v *visitor, msg *message) error {
 		if !filters.Pass(msg) {
 			return nil
 		}
@@ -862,7 +902,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 	}
 	w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
 	if poll {
-		return s.sendOldMessages(topics, since, scheduled, sub)
+		return s.sendOldMessages(topics, since, scheduled, v, sub)
 	}
 	subscriberIDs := make([]int, 0)
 	for _, t := range topics {
@@ -873,15 +913,16 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 			topics[i].Unsubscribe(subscriberID) // Order!
 		}
 	}()
-	if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message
+	if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message
 		return err
 	}
-	if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil {
+	if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {
 		return err
 	}
 	err = g.Wait()
-	if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
-		return nil // Normal closures are not errors
+	if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
+		log.Trace("%s WebSocket connection closed: %s", logHTTPPrefix(v, r), err.Error())
+		return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot
 	}
 	return err
 }
@@ -900,7 +941,7 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
 	return
 }
 
-func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, sub subscriber) error {
+func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
 	if since.IsNone() {
 		return nil
 	}
@@ -910,7 +951,7 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled b
 			return err
 		}
 		for _, m := range messages {
-			if err := sub(m); err != nil {
+			if err := sub(v, m); err != nil {
 				return err
 			}
 		}
@@ -1004,28 +1045,36 @@ func (s *Server) updateStatsAndPrune() {
 	defer s.mu.Unlock()
 
 	// Expire visitors from rate visitors map
+	staleVisitors := 0
 	for ip, v := range s.visitors {
 		if v.Stale() {
+			log.Debug("Deleting stale visitor %s", v.ip)
 			delete(s.visitors, ip)
+			staleVisitors++
 		}
 	}
+	log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors)
 
 	// Delete expired attachments
 	if s.fileCache != nil {
 		ids, err := s.messageCache.AttachmentsExpired()
-		if err == nil {
+		if err != nil {
+			log.Warn("Error retrieving expired attachments: %s", err.Error())
+		} else if len(ids) > 0 {
+			log.Debug("Manager: Deleting expired attachments: %v", ids)
 			if err := s.fileCache.Remove(ids...); err != nil {
-				log.Printf("error while deleting attachments: %s", err.Error())
+				log.Warn("Error deleting attachments: %s", err.Error())
 			}
 		} else {
-			log.Printf("error retrieving expired attachments: %s", err.Error())
+			log.Debug("Manager: No expired attachments to delete")
 		}
 	}
 
 	// Prune message cache
 	olderThan := time.Now().Add(-1 * s.config.CacheDuration)
+	log.Debug("Manager: Pruning messages older than %s", olderThan.Format("2006-01-02 15:04:05"))
 	if err := s.messageCache.Prune(olderThan); err != nil {
-		log.Printf("error pruning cache: %s", err.Error())
+		log.Warn("Manager: Error pruning cache: %s", err.Error())
 	}
 
 	// Prune old topics, remove subscriptions without subscribers
@@ -1034,7 +1083,7 @@ func (s *Server) updateStatsAndPrune() {
 		subs := t.Subscribers()
 		msgs, err := s.messageCache.MessageCount(t.ID)
 		if err != nil {
-			log.Printf("cannot get stats for topic %s: %s", t.ID, err.Error())
+			log.Warn("Manager: Cannot get stats for topic %s: %s", t.ID, err.Error())
 			continue
 		}
 		if msgs == 0 && subs == 0 {
@@ -1046,35 +1095,25 @@ func (s *Server) updateStatsAndPrune() {
 	}
 
 	// Mail stats
-	var mailSuccess, mailFailure int64
-	if s.smtpBackend != nil {
-		mailSuccess, mailFailure = s.smtpBackend.Counts()
+	var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
+	if s.smtpServerBackend != nil {
+		receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
+	}
+	var sentMailTotal, sentMailSuccess, sentMailFailure int64
+	if s.smtpSender != nil {
+		sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
 	}
 
 	// Print stats
-	log.Printf("Stats: %d message(s) published, %d in cache, %d successful mails, %d failed, %d topic(s) active, %d subscriber(s), %d visitor(s)",
-		s.messages, messages, mailSuccess, mailFailure, len(s.topics), subscribers, len(s.visitors))
+	log.Info("Stats: %d messages published, %d in cache, %d topic(s) active, %d subscriber(s), %d visitor(s), %d mails received (%d successful, %d failed), %d mails sent (%d successful, %d failed)",
+		s.messages, messages, len(s.topics), subscribers, len(s.visitors),
+		receivedMailTotal, receivedMailSuccess, receivedMailFailure,
+		sentMailTotal, sentMailSuccess, sentMailFailure)
 }
 
 func (s *Server) runSMTPServer() error {
-	sub := func(m *message) error {
-		url := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
-		req, err := http.NewRequest("PUT", url, strings.NewReader(m.Message))
-		if err != nil {
-			return err
-		}
-		if m.Title != "" {
-			req.Header.Set("Title", m.Title)
-		}
-		rr := httptest.NewRecorder()
-		s.handle(rr, req)
-		if rr.Code != http.StatusOK {
-			return errors.New("error: " + rr.Body.String())
-		}
-		return nil
-	}
-	s.smtpBackend = newMailBackend(s.config, sub)
-	s.smtpServer = smtp.NewServer(s.smtpBackend)
+	s.smtpServerBackend = newMailBackend(s.config, s.handle)
+	s.smtpServer = smtp.NewServer(s.smtpServerBackend)
 	s.smtpServer.Addr = s.config.SMTPServerListen
 	s.smtpServer.Domain = s.config.SMTPServerDomain
 	s.smtpServer.ReadTimeout = 10 * time.Second
@@ -1096,32 +1135,29 @@ func (s *Server) runManager() {
 	}
 }
 
-func (s *Server) runAtSender() {
+func (s *Server) runFirebaseKeepaliver() {
+	if s.firebaseClient == nil {
+		return
+	}
+	v := newVisitor(s.config, s.messageCache, "0.0.0.0") // Background process, not a real visitor
 	for {
 		select {
-		case <-time.After(s.config.AtSenderInterval):
-			if err := s.sendDelayedMessages(); err != nil {
-				log.Printf("error sending scheduled messages: %s", err.Error())
-			}
+		case <-time.After(s.config.FirebaseKeepaliveInterval):
+			s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic))
+		case <-time.After(s.config.FirebasePollInterval):
+			s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic))
 		case <-s.closeChan:
 			return
 		}
 	}
 }
 
-func (s *Server) runFirebaseKeepaliver() {
-	if s.firebase == nil {
-		return
-	}
+func (s *Server) runDelayedSender() {
 	for {
 		select {
-		case <-time.After(s.config.FirebaseKeepaliveInterval):
-			if err := s.firebase(newKeepaliveMessage(firebaseControlTopic)); err != nil {
-				log.Printf("error sending Firebase keepalive message to %s: %s", firebaseControlTopic, err.Error())
-			}
-		case <-time.After(s.config.FirebasePollInterval):
-			if err := s.firebase(newKeepaliveMessage(firebasePollTopic)); err != nil {
-				log.Printf("error sending Firebase keepalive message to %s: %s", firebasePollTopic, err.Error())
+		case <-time.After(s.config.DelayedSenderInterval):
+			if err := s.sendDelayedMessages(); err != nil {
+				log.Warn("Error sending delayed messages: %s", err.Error())
 			}
 		case <-s.closeChan:
 			return
@@ -1130,27 +1166,40 @@ func (s *Server) runFirebaseKeepaliver() {
 }
 
 func (s *Server) sendDelayedMessages() error {
-	s.mu.Lock()
-	defer s.mu.Unlock()
 	messages, err := s.messageCache.MessagesDue()
 	if err != nil {
 		return err
 	}
 	for _, m := range messages {
-		t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
-		if ok {
-			if err := t.Publish(m); err != nil {
-				log.Printf("unable to publish message %s to topic %s: %v", m.ID, m.Topic, err.Error())
+		v := s.visitorFromIP(m.Sender)
+		if err := s.sendDelayedMessage(v, m); err != nil {
+			log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
+		}
+	}
+	return nil
+}
+
+func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	log.Debug("%s Sending delayed message", logMessagePrefix(v, m))
+	t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
+	if ok {
+		go func() {
+			// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
+			if err := t.Publish(v, m); err != nil {
+				log.Warn("%s Unable to publish message: %v", logMessagePrefix(v, m), err.Error())
 			}
-		}
-		if s.firebase != nil { // Firebase subscribers may not show up in topics map
-			if err := s.firebase(m); err != nil {
-				log.Printf("unable to publish to Firebase: %v", err.Error())
-			}
-		}
-		if err := s.messageCache.MarkPublished(m); err != nil {
-			return err
-		}
+		}()
+	}
+	if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map
+		go s.sendToFirebase(v, m)
+	}
+	if s.config.UpstreamBaseURL != "" {
+		go s.forwardPollRequest(v, m)
+	}
+	if err := s.messageCache.MarkPublished(m); err != nil {
+		return err
 	}
 	return nil
 }
@@ -1252,13 +1301,13 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
 		username, password, ok := extractUserPass(r)
 		if ok {
 			if user, err = s.auth.Authenticate(username, password); err != nil {
-				log.Printf("authentication failed: %s", err.Error())
+				log.Info("authentication failed: %s", err.Error())
 				return errHTTPUnauthorized
 			}
 		}
 		for _, t := range topics {
 			if err := s.auth.Authorize(user, t.ID, perm); err != nil {
-				log.Printf("unauthorized: %s", err.Error())
+				log.Info("unauthorized: %s", err.Error())
 				return errHTTPForbidden
 			}
 		}
@@ -1290,8 +1339,6 @@ func extractUserPass(r *http.Request) (username string, password string, ok bool
 // visitor creates or retrieves a rate.Limiter for the given visitor.
 // This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
 func (s *Server) visitor(r *http.Request) *visitor {
-	s.mu.Lock()
-	defer s.mu.Unlock()
 	remoteAddr := r.RemoteAddr
 	ip, _, err := net.SplitHostPort(remoteAddr)
 	if err != nil {
@@ -1300,6 +1347,12 @@ func (s *Server) visitor(r *http.Request) *visitor {
 	if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" {
 		ip = r.Header.Get("X-Forwarded-For")
 	}
+	return s.visitorFromIP(ip)
+}
+
+func (s *Server) visitorFromIP(ip string) *visitor {
+	s.mu.Lock()
+	defer s.mu.Unlock()
 	v, exists := s.visitors[ip]
 	if !exists {
 		s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
diff --git a/server/server.yml b/server/server.yml
index ce7b1c75..63c32209 100644
--- a/server/server.yml
+++ b/server/server.yml
@@ -178,3 +178,11 @@
 #
 # visitor-attachment-total-size-limit: "100M"
 # visitor-attachment-daily-bandwidth-limit: "500M"
+
+# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
+# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
+#
+# Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for
+# debugging purposes, or your disk will fill up quickly.
+#
+# log-level: INFO
diff --git a/server/server_firebase.go b/server/server_firebase.go
index 40ca806a..99f0ba13 100644
--- a/server/server_firebase.go
+++ b/server/server_firebase.go
@@ -3,13 +3,15 @@ package server
 import (
 	"context"
 	"encoding/json"
+	"errors"
+	firebase "firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/messaging"
 	"fmt"
-	"strings"
-
-	firebase "firebase.google.com/go"
-	"firebase.google.com/go/messaging"
 	"google.golang.org/api/option"
 	"heckel.io/ntfy/auth"
+	"heckel.io/ntfy/log"
+	"heckel.io/ntfy/util"
+	"strings"
 )
 
 const (
@@ -17,25 +19,79 @@ const (
 	fcmApnsBodyMessageLimit = 100
 )
 
-func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) {
+var (
+	errFirebaseQuotaExceeded     = errors.New("quota exceeded for Firebase messages to topic")
+	errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase")
+)
+
+// firebaseClient is a generic client that formats and sends messages to Firebase.
+// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.
+type firebaseClient struct {
+	sender firebaseSender
+	auther auth.Auther
+}
+
+func newFirebaseClient(sender firebaseSender, auther auth.Auther) *firebaseClient {
+	return &firebaseClient{
+		sender: sender,
+		auther: auther,
+	}
+}
+
+func (c *firebaseClient) Send(v *visitor, m *message) error {
+	if err := v.FirebaseAllowed(); err != nil {
+		return errFirebaseTemporarilyBanned
+	}
+	fbm, err := toFirebaseMessage(m, c.auther)
+	if err != nil {
+		return err
+	}
+	if log.IsTrace() {
+		log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(fbm))
+	}
+	err = c.sender.Send(fbm)
+	if err == errFirebaseQuotaExceeded {
+		log.Warn("%s Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor", logMessagePrefix(v, m))
+		v.FirebaseTemporarilyDeny()
+	}
+	return err
+}
+
+// firebaseSender is an interface that represents a client that can send to Firebase Cloud Messaging.
+// In tests, this can be implemented with a mock.
+type firebaseSender interface {
+	// Send sends a message to Firebase, or returns an error. It returns errFirebaseQuotaExceeded
+	// if a rate limit has reached.
+	Send(m *messaging.Message) error
+}
+
+// firebaseSenderImpl is a firebaseSender that actually talks to Firebase
+type firebaseSenderImpl struct {
+	client *messaging.Client
+}
+
+func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) {
 	fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
 	if err != nil {
 		return nil, err
 	}
-	msg, err := fb.Messaging(context.Background())
+	client, err := fb.Messaging(context.Background())
 	if err != nil {
 		return nil, err
 	}
-	return func(m *message) error {
-		fbm, err := toFirebaseMessage(m, auther)
-		if err != nil {
-			return err
-		}
-		_, err = msg.Send(context.Background(), fbm)
-		return err
+	return &firebaseSenderImpl{
+		client: client,
 	}, nil
 }
 
+func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
+	_, err := c.client.Send(context.Background(), m)
+	if err != nil && messaging.IsQuotaExceeded(err) {
+		return errFirebaseQuotaExceeded
+	}
+	return err
+}
+
 // toFirebaseMessage converts a message to a Firebase message.
 //
 // Normal messages ("message"):
diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go
index c990b930..b004b0fa 100644
--- a/server/server_firebase_test.go
+++ b/server/server_firebase_test.go
@@ -3,11 +3,12 @@ package server
 import (
 	"encoding/json"
 	"errors"
-	"firebase.google.com/go/messaging"
+	"firebase.google.com/go/v4/messaging"
 	"fmt"
 	"github.com/stretchr/testify/require"
 	"heckel.io/ntfy/auth"
 	"strings"
+	"sync"
 	"testing"
 )
 
@@ -26,6 +27,35 @@ func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error {
 	return errors.New("unauthorized")
 }
 
+type testFirebaseSender struct {
+	allowed  int
+	messages []*messaging.Message
+	mu       sync.Mutex
+}
+
+func newTestFirebaseSender(allowed int) *testFirebaseSender {
+	return &testFirebaseSender{
+		allowed:  allowed,
+		messages: make([]*messaging.Message, 0),
+	}
+}
+
+func (s *testFirebaseSender) Send(m *messaging.Message) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if len(s.messages)+1 > s.allowed {
+		return errFirebaseQuotaExceeded
+	}
+	s.messages = append(s.messages, m)
+	return nil
+}
+
+func (s *testFirebaseSender) Messages() []*messaging.Message {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return append(make([]*messaging.Message, 0), s.messages...)
+}
+
 func TestToFirebaseMessage_Keepalive(t *testing.T) {
 	m := newKeepaliveMessage("mytopic")
 	fbm, err := toFirebaseMessage(m, nil)
@@ -119,7 +149,6 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
 		Size:    12345,
 		Expires: 98765543,
 		URL:     "https://example.com/file.jpg",
-		Owner:   "some-owner",
 	}
 	fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
 	require.Nil(t, err)
@@ -286,3 +315,22 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
 	require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
 	require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
 }
+
+func TestToFirebaseSender_Abuse(t *testing.T) {
+	sender := &testFirebaseSender{allowed: 2}
+	client := newFirebaseClient(sender, &testAuther{})
+	visitor := newVisitor(newTestConfig(t), newMemTestCache(t), "1.2.3.4")
+
+	require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
+	require.Equal(t, 1, len(sender.Messages()))
+
+	require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
+	require.Equal(t, 2, len(sender.Messages()))
+
+	require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
+	require.Equal(t, 2, len(sender.Messages()))
+
+	sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working
+	require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &message{Topic: "mytopic"}))
+	require.Equal(t, 0, len(sender.Messages()))
+}
diff --git a/server/server_test.go b/server/server_test.go
index 06f3cd2d..8010643f 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -9,7 +9,6 @@ import (
 	"math/rand"
 	"net/http"
 	"net/http/httptest"
-	"os"
 	"path/filepath"
 	"strings"
 	"sync"
@@ -55,6 +54,21 @@ func TestServer_PublishAndPoll(t *testing.T) {
 	require.Equal(t, "my second  message", lines[1]) // \n -> " "
 }
 
+func TestServer_PublishWithFirebase(t *testing.T) {
+	sender := newTestFirebaseSender(10)
+	s := newTestServer(t, newTestConfig(t))
+	s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
+
+	response := request(t, s, "PUT", "/mytopic", "my first message", nil)
+	msg1 := toMessage(t, response.Body.String())
+	require.NotEmpty(t, msg1.ID)
+	require.Equal(t, "my first message", msg1.Message)
+	require.Equal(t, 1, len(sender.Messages()))
+	require.Equal(t, "my first message", sender.Messages()[0].Data["message"])
+	require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body)
+	require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"])
+}
+
 func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
 	c := newTestConfig(t)
 	c.KeepaliveInterval = time.Second
@@ -264,7 +278,7 @@ func TestServer_PublishNoCache(t *testing.T) {
 func TestServer_PublishAt(t *testing.T) {
 	c := newTestConfig(t)
 	c.MinDelay = time.Second
-	c.AtSenderInterval = 100 * time.Millisecond
+	c.DelayedSenderInterval = 100 * time.Millisecond
 	s := newTestServer(t, c)
 
 	response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
@@ -283,6 +297,13 @@ func TestServer_PublishAt(t *testing.T) {
 	messages = toMessages(t, response.Body.String())
 	require.Equal(t, 1, len(messages))
 	require.Equal(t, "a message", messages[0].Message)
+	require.Equal(t, "", messages[0].Sender) // Never return the sender!
+
+	messages, err := s.messageCache.Messages("mytopic", sinceAllMessages, true)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(messages))
+	require.Equal(t, "a message", messages[0].Message)
+	require.Equal(t, "9.9.9.9", messages[0].Sender) // It's stored in the DB though!
 }
 
 func TestServer_PublishAtWithCacheError(t *testing.T) {
@@ -454,29 +475,9 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
 	require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n !
 }
 
-func TestServer_PublishFirebase(t *testing.T) {
-	// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
-	// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
-	// I tried everything. I already had written the test, and it increases the code coverage, so I'll leave it ... :shrug: ...
-
-	c := newTestConfig(t)
-	c.FirebaseKeyFile = firebaseServiceAccountFile(t) // May skip the test!
-	s := newTestServer(t, c)
-
-	// Normal message
-	response := request(t, s, "PUT", "/mytopic", "This is a message for firebase", nil)
-	msg := toMessage(t, response.Body.String())
-	require.NotEmpty(t, msg.ID)
-
-	// Keepalive message
-	require.Nil(t, s.firebase(newKeepaliveMessage(firebaseControlTopic)))
-
-	time.Sleep(500 * time.Millisecond) // Time for sends
-}
-
 func TestServer_PublishInvalidTopic(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
-	s.mailer = &testMailer{}
+	s.smtpSender = &testMailer{}
 	response := request(t, s, "PUT", "/docs", "fail", nil)
 	require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
 }
@@ -742,13 +743,17 @@ type testMailer struct {
 	mu    sync.Mutex
 }
 
-func (t *testMailer) Send(from, to string, m *message) error {
+func (t *testMailer) Send(v *visitor, m *message, to string) error {
 	t.mu.Lock()
 	defer t.mu.Unlock()
 	t.count++
 	return nil
 }
 
+func (t *testMailer) Counts() (total int64, success int64, failure int64) {
+	return 0, 0, 0
+}
+
 func (t *testMailer) Count() int {
 	t.mu.Lock()
 	defer t.mu.Unlock()
@@ -794,7 +799,7 @@ func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
 
 func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
-	s.mailer = &testMailer{}
+	s.smtpSender = &testMailer{}
 	for i := 0; i < 16; i++ {
 		response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
 			"E-Mail": "test@example.com",
@@ -811,7 +816,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
 	c := newTestConfig(t)
 	c.VisitorEmailLimitReplenish = 500 * time.Millisecond
 	s := newTestServer(t, c)
-	s.mailer = &testMailer{}
+	s.smtpSender = &testMailer{}
 	for i := 0; i < 16; i++ {
 		response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
 			"E-Mail": "test@example.com",
@@ -837,7 +842,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
 
 func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
-	s.mailer = &testMailer{}
+	s.smtpSender = &testMailer{}
 	response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
 		"E-Mail": "test@example.com",
 		"Delay":  "20 min",
@@ -955,7 +960,7 @@ func TestServer_PublishAsJSON(t *testing.T) {
 func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
 	mailer := &testMailer{}
 	s := newTestServer(t, newTestConfig(t))
-	s.mailer = mailer
+	s.smtpSender = mailer
 	body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
 	response := request(t, s, "PUT", "/", body, nil)
 	require.Equal(t, 200, response.Code)
@@ -1018,7 +1023,7 @@ func TestServer_PublishAttachment(t *testing.T) {
 	require.Equal(t, int64(5000), msg.Attachment.Size)
 	require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(179*time.Minute).Unix()) // Almost 3 hours
 	require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
-	require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
+	require.Equal(t, "", msg.Sender) // Should never be returned
 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
 
 	path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
@@ -1047,7 +1052,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
 	require.Equal(t, int64(21), msg.Attachment.Size)
 	require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
 	require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
-	require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
+	require.Equal(t, "", msg.Sender) // Should never be returned
 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
 
 	path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
@@ -1074,7 +1079,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
 	require.Equal(t, "", msg.Attachment.Type)
 	require.Equal(t, int64(0), msg.Attachment.Size)
 	require.Equal(t, int64(0), msg.Attachment.Expires)
-	require.Equal(t, "", msg.Attachment.Owner)
+	require.Equal(t, "", msg.Sender)
 
 	// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
 	size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
@@ -1095,7 +1100,7 @@ func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
 	require.Equal(t, "", msg.Attachment.Type)
 	require.Equal(t, int64(0), msg.Attachment.Size)
 	require.Equal(t, int64(0), msg.Attachment.Expires)
-	require.Equal(t, "", msg.Attachment.Owner)
+	require.Equal(t, "", msg.Sender)
 }
 
 func TestServer_PublishAttachmentBadURL(t *testing.T) {
@@ -1333,18 +1338,6 @@ func toHTTPError(t *testing.T, s string) *errHTTP {
 	return &e
 }
 
-func firebaseServiceAccountFile(t *testing.T) string {
-	if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
-		return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
-	} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
-		filename := filepath.Join(t.TempDir(), "firebase.json")
-		require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0o600))
-		return filename
-	}
-	t.SkipNow()
-	return ""
-}
-
 func basicAuth(s string) string {
 	return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
 }
diff --git a/server/smtp_sender.go b/server/smtp_sender.go
index 15f004c1..1ccaf084 100644
--- a/server/smtp_sender.go
+++ b/server/smtp_sender.go
@@ -4,33 +4,62 @@ import (
 	_ "embed" // required by go:embed
 	"encoding/json"
 	"fmt"
+	"heckel.io/ntfy/log"
 	"heckel.io/ntfy/util"
 	"mime"
 	"net"
 	"net/smtp"
 	"strings"
+	"sync"
 	"time"
 )
 
 type mailer interface {
-	Send(from, to string, m *message) error
+	Send(v *visitor, m *message, to string) error
+	Counts() (total int64, success int64, failure int64)
 }
 
 type smtpSender struct {
-	config *Config
+	config  *Config
+	success int64
+	failure int64
+	mu      sync.Mutex
 }
 
-func (s *smtpSender) Send(senderIP, to string, m *message) error {
-	host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
+func (s *smtpSender) Send(v *visitor, m *message, to string) error {
+	return s.withCount(v, m, func() error {
+		host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
+		if err != nil {
+			return err
+		}
+		message, err := formatMail(s.config.BaseURL, v.ip, s.config.SMTPSenderFrom, to, m)
+		if err != nil {
+			return err
+		}
+		auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
+		log.Debug("%s Sending mail: via=%s, user=%s, pass=***, to=%s", logMessagePrefix(v, m), s.config.SMTPSenderAddr, s.config.SMTPSenderUser, to)
+		log.Trace("%s Mail body: %s", logMessagePrefix(v, m), message)
+		return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
+	})
+}
+
+func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.success + s.failure, s.success, s.failure
+}
+
+func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
+	err := fn()
+	s.mu.Lock()
+	defer s.mu.Unlock()
 	if err != nil {
-		return err
+		log.Debug("%s Sending mail failed: %s", logMessagePrefix(v, m), err.Error())
+		s.failure++
+	} else {
+		s.success++
 	}
-	message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
-	if err != nil {
-		return err
-	}
-	auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
-	return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
+	return err
 }
 
 func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
diff --git a/server/smtp_server.go b/server/smtp_server.go
index c437d235..3f4b9b68 100644
--- a/server/smtp_server.go
+++ b/server/smtp_server.go
@@ -3,10 +3,15 @@ package server
 import (
 	"bytes"
 	"errors"
+	"fmt"
 	"github.com/emersion/go-smtp"
+	"heckel.io/ntfy/log"
 	"io"
 	"mime"
 	"mime/multipart"
+	"net"
+	"net/http"
+	"net/http/httptest"
 	"net/mail"
 	"strings"
 	"sync"
@@ -23,49 +28,55 @@ var (
 // smtpBackend implements SMTP server methods.
 type smtpBackend struct {
 	config  *Config
-	sub     subscriber
+	handler func(http.ResponseWriter, *http.Request)
 	success int64
 	failure int64
 	mu      sync.Mutex
 }
 
-func newMailBackend(conf *Config, sub subscriber) *smtpBackend {
+func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
 	return &smtpBackend{
-		config: conf,
-		sub:    sub,
+		config:  conf,
+		handler: handler,
 	}
 }
 
 func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
-	return &smtpSession{backend: b}, nil
+	log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username)
+	return &smtpSession{backend: b, state: state}, nil
 }
 
 func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
-	return &smtpSession{backend: b}, nil
+	log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state))
+	return &smtpSession{backend: b, state: state}, nil
 }
 
-func (b *smtpBackend) Counts() (success int64, failure int64) {
+func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
 	b.mu.Lock()
 	defer b.mu.Unlock()
-	return b.success, b.failure
+	return b.success + b.failure, b.success, b.failure
 }
 
 // smtpSession is returned after EHLO.
 type smtpSession struct {
 	backend *smtpBackend
+	state   *smtp.ConnectionState
 	topic   string
 	mu      sync.Mutex
 }
 
 func (s *smtpSession) AuthPlain(username, password string) error {
+	log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username)
 	return nil
 }
 
 func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
+	log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts)
 	return nil
 }
 
 func (s *smtpSession) Rcpt(to string) error {
+	log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to)
 	return s.withFailCount(func() error {
 		conf := s.backend.config
 		addressList, err := mail.ParseAddressList(to)
@@ -102,6 +113,11 @@ func (s *smtpSession) Data(r io.Reader) error {
 		if err != nil {
 			return err
 		}
+		if log.IsTrace() {
+			log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b))
+		} else if log.IsDebug() {
+			log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b))
+		}
 		msg, err := mail.ReadMessage(bytes.NewReader(b))
 		if err != nil {
 			return err
@@ -128,7 +144,7 @@ func (s *smtpSession) Data(r io.Reader) error {
 			m.Message = m.Title // Flip them, this makes more sense
 			m.Title = ""
 		}
-		if err := s.backend.sub(m); err != nil {
+		if err := s.publishMessage(m); err != nil {
 			return err
 		}
 		s.backend.mu.Lock()
@@ -138,6 +154,33 @@ func (s *smtpSession) Data(r io.Reader) error {
 	})
 }
 
+func (s *smtpSession) publishMessage(m *message) error {
+	// Extract remote address (for rate limiting)
+	remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String())
+	if err != nil {
+		remoteAddr = s.state.RemoteAddr.String()
+	}
+
+	// Call HTTP handler with fake HTTP request
+	url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
+	req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
+	req.RequestURI = "/" + m.Topic // just for the logs
+	req.RemoteAddr = remoteAddr    // rate limiting!!
+	req.Header.Set("X-Forwarded-For", remoteAddr)
+	if err != nil {
+		return err
+	}
+	if m.Title != "" {
+		req.Header.Set("Title", m.Title)
+	}
+	rr := httptest.NewRecorder()
+	s.backend.handler(rr, req)
+	if rr.Code != http.StatusOK {
+		return errors.New("error: " + rr.Body.String())
+	}
+	return nil
+}
+
 func (s *smtpSession) Reset() {
 	s.mu.Lock()
 	s.topic = ""
@@ -153,6 +196,9 @@ func (s *smtpSession) withFailCount(fn func() error) error {
 	s.backend.mu.Lock()
 	defer s.backend.mu.Unlock()
 	if err != nil {
+		// Almost all of these errors are parse errors, and user input errors.
+		// We do not want to spam the log with WARN messages.
+		log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error())
 		s.backend.failure++
 	}
 	return err
diff --git a/server/smtp_server_test.go b/server/smtp_server_test.go
index d0e8bfd2..8e9d5892 100644
--- a/server/smtp_server_test.go
+++ b/server/smtp_server_test.go
@@ -3,6 +3,9 @@ package server
 import (
 	"github.com/emersion/go-smtp"
 	"github.com/stretchr/testify/require"
+	"io"
+	"net"
+	"net/http"
 	"strings"
 	"testing"
 )
@@ -27,13 +30,12 @@ Content-Type: text/html; charset="UTF-8"
 <div dir="ltr">what&#39;s up<br clear="all"><div><br></div></div>
 
 --000000000000f3320b05d42915c9--`
-	_, backend := newTestBackend(t, func(m *message) error {
-		require.Equal(t, "mytopic", m.Topic)
-		require.Equal(t, "and one more", m.Title)
-		require.Equal(t, "what's up", m.Message)
-		return nil
+	_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "/mytopic", r.URL.Path)
+		require.Equal(t, "and one more", r.Header.Get("Title"))
+		require.Equal(t, "what's up", readAll(t, r.Body))
 	})
-	session, _ := backend.AnonymousLogin(nil)
+	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
 	require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
 	require.Nil(t, session.Data(strings.NewReader(email)))
@@ -59,13 +61,12 @@ Content-Type: text/html; charset="UTF-8"
 <div dir="ltr"><br></div>
 
 --000000000000bcf4a405d429f8d4--`
-	_, backend := newTestBackend(t, func(m *message) error {
-		require.Equal(t, "emailtest", m.Topic)
-		require.Equal(t, "", m.Title) // We flipped message and body
-		require.Equal(t, "This email has a subject but no body", m.Message)
-		return nil
+	_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "/emailtest", r.URL.Path)
+		require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body
+		require.Equal(t, "This email has a subject but no body", readAll(t, r.Body))
 	})
-	session, _ := backend.AnonymousLogin(nil)
+	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
 	require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
 	require.Nil(t, session.Data(strings.NewReader(email)))
@@ -81,14 +82,13 @@ Content-Type: text/plain; charset="UTF-8"
 
 what's up
 `
-	conf, backend := newTestBackend(t, func(m *message) error {
-		require.Equal(t, "mytopic", m.Topic)
-		require.Equal(t, "and one more", m.Title)
-		require.Equal(t, "what's up", m.Message)
-		return nil
+	conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "/mytopic", r.URL.Path)
+		require.Equal(t, "and one more", r.Header.Get("Title"))
+		require.Equal(t, "what's up", readAll(t, r.Body))
 	})
 	conf.SMTPServerAddrPrefix = ""
-	session, _ := backend.AnonymousLogin(nil)
+	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
 	require.Nil(t, session.Data(strings.NewReader(email)))
@@ -99,14 +99,13 @@ func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
 
 what's up
 `
-	conf, backend := newTestBackend(t, func(m *message) error {
-		require.Equal(t, "mytopic", m.Topic)
-		require.Equal(t, "Very short mail", m.Title)
-		require.Equal(t, "what's up", m.Message)
-		return nil
+	conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "/mytopic", r.URL.Path)
+		require.Equal(t, "Very short mail", r.Header.Get("Title"))
+		require.Equal(t, "what's up", readAll(t, r.Body))
 	})
 	conf.SMTPServerAddrPrefix = ""
-	session, _ := backend.AnonymousLogin(nil)
+	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
 	require.Nil(t, session.Data(strings.NewReader(email)))
@@ -121,11 +120,10 @@ Content-Type: text/plain; charset="UTF-8"
 
 what's up
 `
-	_, backend := newTestBackend(t, func(m *message) error {
-		require.Equal(t, "Three santas 🎅🎅🎅", m.Title)
-		return nil
+	_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title"))
 	})
-	session, _ := backend.AnonymousLogin(nil)
+	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
 	require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
 	require.Nil(t, session.Data(strings.NewReader(email)))
@@ -140,7 +138,7 @@ To: mytopic@ntfy.sh
 Content-Type: text/plain; charset="UTF-8"
 
 you know this is a string.
-it's a long string. 
+it's a long string.
 it's supposed to be longer than the max message length
 which is 4096 bytes,
 it used to be 512 bytes, but I increased that for the UnifiedPush support
@@ -204,9 +202,9 @@ BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
 that should do it
 `
-	conf, backend := newTestBackend(t, func(m *message) error {
+	conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
 		expected := `you know this is a string.
-it's a long string. 
+it's a long string.
 it's supposed to be longer than the max message length
 which is 4096 bytes,
 it used to be 512 bytes, but I increased that for the UnifiedPush support
@@ -266,13 +264,12 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
 ......................................................................
 ......................................................................
 and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
-BBBBBBBBBBBBBBBBBBBBBBBB`
+BBBBBBBBBBBBBBBBBBBBBBBBB`
 		require.Equal(t, 4096, len(expected)) // Sanity check
-		require.Equal(t, expected, m.Message)
-		return nil
+		require.Equal(t, expected, readAll(t, r.Body))
 	})
 	conf.SMTPServerAddrPrefix = ""
-	session, _ := backend.AnonymousLogin(nil)
+	session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
 	require.Nil(t, session.Data(strings.NewReader(email)))
@@ -288,21 +285,41 @@ Content-Type: text/SOMETHINGELSE
 
 what's up
 `
-	conf, backend := newTestBackend(t, func(m *message) error {
-		return nil
+	conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) {
+		// Nothing.
 	})
 	conf.SMTPServerAddrPrefix = ""
-	session, _ := backend.Login(nil, "user", "pass")
+	session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass")
 	require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
 	require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
 	require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
 }
 
-func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) {
+func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) {
 	conf := newTestConfig(t)
 	conf.SMTPServerListen = ":25"
 	conf.SMTPServerDomain = "ntfy.sh"
 	conf.SMTPServerAddrPrefix = "ntfy-"
-	backend := newMailBackend(conf, sub)
+	backend := newMailBackend(conf, handler)
 	return conf, backend
 }
+
+func readAll(t *testing.T, rc io.ReadCloser) string {
+	b, err := io.ReadAll(rc)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return string(b)
+}
+
+func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
+	ip, err := net.ResolveIPAddr("ip", remoteAddr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return &smtp.ConnectionState{
+		Hostname:   "myhostname",
+		LocalAddr:  ip,
+		RemoteAddr: ip,
+	}
+}
diff --git a/server/topic.go b/server/topic.go
index 9badd7bd..889f1eb7 100644
--- a/server/topic.go
+++ b/server/topic.go
@@ -1,7 +1,7 @@
 package server
 
 import (
-	"log"
+	"heckel.io/ntfy/log"
 	"math/rand"
 	"sync"
 )
@@ -15,7 +15,7 @@ type topic struct {
 }
 
 // subscriber is a function that is called for every new message on a topic
-type subscriber func(msg *message) error
+type subscriber func(v *visitor, msg *message) error
 
 // newTopic creates a new topic
 func newTopic(id string) *topic {
@@ -42,14 +42,19 @@ func (t *topic) Unsubscribe(id int) {
 }
 
 // Publish asynchronously publishes to all subscribers
-func (t *topic) Publish(m *message) error {
+func (t *topic) Publish(v *visitor, m *message) error {
 	go func() {
 		t.mu.Lock()
 		defer t.mu.Unlock()
-		for _, s := range t.subscribers {
-			if err := s(m); err != nil {
-				log.Printf("error publishing message to subscriber")
+		if len(t.subscribers) > 0 {
+			log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(t.subscribers))
+			for _, s := range t.subscribers {
+				if err := s(v, m); err != nil {
+					log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
+				}
 			}
+		} else {
+			log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m))
 		}
 	}()
 	return nil
diff --git a/server/types.go b/server/types.go
index 6a69338c..bb8e32a3 100644
--- a/server/types.go
+++ b/server/types.go
@@ -32,6 +32,7 @@ type message struct {
 	Actions    []*action   `json:"actions,omitempty"`
 	Attachment *attachment `json:"attachment,omitempty"`
 	PollID     string      `json:"poll_id,omitempty"`
+	Sender     string      `json:"-"`                  // IP address of uploader, used for rate limiting
 	Encoding   string      `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
 }
 
@@ -41,7 +42,6 @@ type attachment struct {
 	Size    int64  `json:"size,omitempty"`
 	Expires int64  `json:"expires,omitempty"`
 	URL     string `json:"url"`
-	Owner   string `json:"-"` // IP address of uploader, used for rate limiting
 }
 
 type action struct {
diff --git a/server/util.go b/server/util.go
index 7c596344..d6b95a73 100644
--- a/server/util.go
+++ b/server/util.go
@@ -1,6 +1,8 @@
 package server
 
 import (
+	"fmt"
+	"github.com/emersion/go-smtp"
 	"net/http"
 	"strings"
 )
@@ -40,3 +42,19 @@ func readQueryParam(r *http.Request, names ...string) string {
 	}
 	return ""
 }
+
+func logMessagePrefix(v *visitor, m *message) string {
+	return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID)
+}
+
+func logHTTPPrefix(v *visitor, r *http.Request) string {
+	requestURI := r.RequestURI
+	if requestURI == "" {
+		requestURI = r.URL.Path
+	}
+	return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
+}
+
+func logSMTPPrefix(state *smtp.ConnectionState) string {
+	return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
+}
diff --git a/server/visitor.go b/server/visitor.go
index 58cc28ab..5a8e186b 100644
--- a/server/visitor.go
+++ b/server/visitor.go
@@ -28,6 +28,7 @@ type visitor struct {
 	emails        *rate.Limiter
 	subscriptions util.Limiter
 	bandwidth     util.Limiter
+	firebase      time.Time // Next allowed Firebase message
 	seen          time.Time
 	mu            sync.Mutex
 }
@@ -48,14 +49,11 @@ func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
 		emails:        rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
 		subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
 		bandwidth:     util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
+		firebase:      time.Unix(0, 0),
 		seen:          time.Now(),
 	}
 }
 
-func (v *visitor) IP() string {
-	return v.ip
-}
-
 func (v *visitor) RequestAllowed() error {
 	if !v.requests.Allow() {
 		return errVisitorLimitReached
@@ -63,6 +61,21 @@ func (v *visitor) RequestAllowed() error {
 	return nil
 }
 
+func (v *visitor) FirebaseAllowed() error {
+	v.mu.Lock()
+	defer v.mu.Unlock()
+	if time.Now().Before(v.firebase) {
+		return errVisitorLimitReached
+	}
+	return nil
+}
+
+func (v *visitor) FirebaseTemporarilyDeny() {
+	v.mu.Lock()
+	defer v.mu.Unlock()
+	v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
+}
+
 func (v *visitor) EmailAllowed() error {
 	if !v.emails.Allow() {
 		return errVisitorLimitReached
diff --git a/tools/fbsend/main.go b/tools/fbsend/main.go
index cd3a06d1..832aeb79 100644
--- a/tools/fbsend/main.go
+++ b/tools/fbsend/main.go
@@ -2,8 +2,8 @@ package main
 
 import (
 	"context"
-	firebase "firebase.google.com/go"
-	"firebase.google.com/go/messaging"
+	firebase "firebase.google.com/go/v4"
+	"firebase.google.com/go/v4/messaging"
 	"flag"
 	"fmt"
 	"google.golang.org/api/option"
diff --git a/util/util.go b/util/util.go
index 7fc22d09..c8a42347 100644
--- a/util/util.go
+++ b/util/util.go
@@ -2,6 +2,7 @@ package util
 
 import (
 	"encoding/base64"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"github.com/gabriel-vasile/mimetype"
@@ -264,3 +265,16 @@ func ReadPassword(in io.Reader) ([]byte, error) {
 func BasicAuth(user, pass string) string {
 	return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
 }
+
+// MaybeMarshalJSON returns a JSON string of the given object, or "<cannot serialize>" if serialization failed.
+// This is useful for logging purposes where a failure doesn't matter that much.
+func MaybeMarshalJSON(v interface{}) string {
+	jsonBytes, err := json.MarshalIndent(v, "", "  ")
+	if err != nil {
+		return "<cannot serialize>"
+	}
+	if len(jsonBytes) > 5000 {
+		return string(jsonBytes)[:5000]
+	}
+	return string(jsonBytes)
+}
diff --git a/web/package-lock.json b/web/package-lock.json
index c12cf8a1..505f2c63 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -471,9 +471,9 @@
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.18.3",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.3.tgz",
-      "integrity": "sha512-rL50YcEuHbbauAFAysNsJA4/f89fGTOBRNs9P81sniKnKAr4xULe5AecolcsKbi88xu0ByWYDj/S1AJ3FSFuSQ==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.4.tgz",
+      "integrity": "sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==",
       "bin": {
         "parser": "bin/babel-parser.js"
       },
@@ -1063,9 +1063,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-block-scoping": {
-      "version": "7.17.12",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.17.12.tgz",
-      "integrity": "sha512-jw8XW/B1i7Lqwqj2CbrViPcZijSxfguBWZP2aN59NHgxUyO/OcO1mfdCxH13QhN5LbWhPkX+f+brKGhZTiqtZQ==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.4.tgz",
+      "integrity": "sha512-+Hq10ye+jlvLEogSOtq4mKvtk7qwcUQ1f0Mrueai866C82f844Yom2cttfJdMdqRLTxWpsbfbkIkOIfovyUQXw==",
       "dependencies": {
         "@babel/helper-plugin-utils": "^7.17.12"
       },
@@ -1077,16 +1077,16 @@
       }
     },
     "node_modules/@babel/plugin-transform-classes": {
-      "version": "7.17.12",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.17.12.tgz",
-      "integrity": "sha512-cvO7lc7pZat6BsvH6l/EGaI8zpl8paICaoGk+7x7guvtfak/TbIf66nYmJOH13EuG0H+Xx3M+9LQDtSvZFKXKw==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.18.4.tgz",
+      "integrity": "sha512-e42NSG2mlKWgxKUAD9EJJSkZxR67+wZqzNxLSpc51T8tRU5SLFHsPmgYR5yr7sdgX4u+iHA1C5VafJ6AyImV3A==",
       "dependencies": {
         "@babel/helper-annotate-as-pure": "^7.16.7",
-        "@babel/helper-environment-visitor": "^7.16.7",
+        "@babel/helper-environment-visitor": "^7.18.2",
         "@babel/helper-function-name": "^7.17.9",
         "@babel/helper-optimise-call-expression": "^7.16.7",
         "@babel/helper-plugin-utils": "^7.17.12",
-        "@babel/helper-replace-supers": "^7.16.7",
+        "@babel/helper-replace-supers": "^7.18.2",
         "@babel/helper-split-export-declaration": "^7.16.7",
         "globals": "^11.1.0"
       },
@@ -1276,9 +1276,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-modules-systemjs": {
-      "version": "7.18.0",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.0.tgz",
-      "integrity": "sha512-vwKpxdHnlM5tIrRt/eA0bzfbi7gUBLN08vLu38np1nZevlPySRe6yvuATJB5F/WPJ+ur4OXwpVYq9+BsxqAQuQ==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.4.tgz",
+      "integrity": "sha512-lH2UaQaHVOAeYrUUuZ8i38o76J/FnO8vu21OE+tD1MyP9lxdZoSfz+pDbWkq46GogUrdrMz3tiz/FYGB+bVThg==",
       "dependencies": {
         "@babel/helper-hoist-variables": "^7.16.7",
         "@babel/helper-module-transforms": "^7.18.0",
@@ -1575,9 +1575,9 @@
       }
     },
     "node_modules/@babel/plugin-transform-typescript": {
-      "version": "7.18.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.18.1.tgz",
-      "integrity": "sha512-F+RJmL479HJmC0KeqqwEGZMg1P7kWArLGbAKfEi9yPthJyMNjF+DjxFF/halfQvq1Q9GFM4TUbYDNV8xe4Ctqg==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.18.4.tgz",
+      "integrity": "sha512-l4vHuSLUajptpHNEOUDEGsnpl9pfRLsN1XUoDQDD/YBuXTM+v37SHGS+c6n4jdcZy96QtuUuSvZYMLSSsjH8Mw==",
       "dependencies": {
         "@babel/helper-create-class-features-plugin": "^7.18.0",
         "@babel/helper-plugin-utils": "^7.17.12",
@@ -1814,9 +1814,9 @@
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.18.2.tgz",
-      "integrity": "sha512-0On6B8A4/+mFUto5WERt3EEuG1NznDirvwca1O8UwXQHVY8g3R7OzYgxXdOfMwLO08UrpUD/2+3Bclyq+/C94Q==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.18.4.tgz",
+      "integrity": "sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw==",
       "dependencies": {
         "@babel/helper-validator-identifier": "^7.16.7",
         "to-fast-properties": "^2.0.0"
@@ -2004,6 +2004,24 @@
         "postcss": "^8.3"
       }
     },
+    "node_modules/@csstools/postcss-trigonometric-functions": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-1.0.1.tgz",
+      "integrity": "sha512-G78CY/+GePc6dDCTUbwI6TTFQ5fs3N9POHhI6v0QzteGpf6ylARiJUNz9HrRKi4eVYBNXjae1W2766iUEFxHlw==",
+      "dependencies": {
+        "postcss-value-parser": "^4.2.0"
+      },
+      "engines": {
+        "node": "^14 || >=16"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/csstools"
+      },
+      "peerDependencies": {
+        "postcss": "^8.4"
+      }
+    },
     "node_modules/@csstools/postcss-unset-value": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.1.tgz",
@@ -2947,6 +2965,28 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/@jridgewell/source-map": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
+      "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.0",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      }
+    },
+    "node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.1.tgz",
+      "integrity": "sha512-GcHwniMlA2z+WFPWuY8lp3fsza0I8xPFMWL5+n8LYyP6PSvPrXf4+n8stDHZY2DM0zy9sVkRDy1jDI4XGzYVqg==",
+      "dependencies": {
+        "@jridgewell/set-array": "^1.0.0",
+        "@jridgewell/sourcemap-codec": "^1.4.10",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/@jridgewell/sourcemap-codec": {
       "version": "1.4.13",
       "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz",
@@ -2967,9 +3007,9 @@
       "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
     },
     "node_modules/@mui/base": {
-      "version": "5.0.0-alpha.82",
-      "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.82.tgz",
-      "integrity": "sha512-WUVDjCGnLXzmGxrmfW31blhucg0sRX4YddK2Falq7FlVzwdJaPgWn/xzPZmdLL0+WXon0gQVnDrq2qvggE/GMg==",
+      "version": "5.0.0-alpha.83",
+      "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.83.tgz",
+      "integrity": "sha512-/bFcjiI36R2Epf2Y3BkZOIdxrz5uMLqOU4cRai4igJ8DHTRMZDeKbOff0SdvwJNwg8r6oPUyoeOpsWkaOOX9/g==",
       "dependencies": {
         "@babel/runtime": "^7.17.2",
         "@emotion/is-prop-valid": "^1.1.2",
@@ -2999,9 +3039,9 @@
       }
     },
     "node_modules/@mui/icons-material": {
-      "version": "5.8.0",
-      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.8.0.tgz",
-      "integrity": "sha512-ScwLxa0q5VYV70Jfc60V/9VD0b9SvIeZ0Jddx2Dt2pBUFFO9vKdrbt9LYiT+4p21Au5NdYIb2XSHj46CLN1v3g==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.8.2.tgz",
+      "integrity": "sha512-fP6KUCCZZjc2rdbMSmkNmBHDskLkmP0uCox57cbVXvomU6BOPrCxnr5YXsSsQrZB8fchx7hfH0bkAgvMZ5KM0Q==",
       "dependencies": {
         "@babel/runtime": "^7.17.2"
       },
@@ -3024,18 +3064,18 @@
       }
     },
     "node_modules/@mui/material": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.8.1.tgz",
-      "integrity": "sha512-Vl3BHFzOcAT5TJfvzoQUyuo/Xckn+/NSRyJ8upM4Hbz6Y1egW6P8f1RCa4FdkEfPSd5wSSYdmPfAiEh8eI4rPg==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.8.2.tgz",
+      "integrity": "sha512-w/A1KG9Czf42uTyJOiRU5U1VullOz1R3xcsBvv3BtKCCWdVP+D6v/Yb8v0tJpIixMEbjeWzWGjotQBU0nd+yNA==",
       "dependencies": {
         "@babel/runtime": "^7.17.2",
-        "@mui/base": "5.0.0-alpha.82",
-        "@mui/system": "^5.8.1",
+        "@mui/base": "5.0.0-alpha.83",
+        "@mui/system": "^5.8.2",
         "@mui/types": "^7.1.3",
         "@mui/utils": "^5.8.0",
         "@types/react-transition-group": "^4.4.4",
         "clsx": "^1.1.1",
-        "csstype": "^3.0.11",
+        "csstype": "^3.1.0",
         "hoist-non-react-statics": "^3.3.2",
         "prop-types": "^15.8.1",
         "react-is": "^17.0.2",
@@ -3124,9 +3164,9 @@
       }
     },
     "node_modules/@mui/system": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.8.1.tgz",
-      "integrity": "sha512-kWJMEN62+HJb4LMRNEAZQYc++FPYsqPsU9dCL7ByLgmz/ZzRrZ8FjDi2r4j0ZeE4kaVvqBXh+RA7tLzmCKqV9w==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.8.2.tgz",
+      "integrity": "sha512-N74gDNKM+MnWvKTMmCPvCVLH4f0ZzakP1bcMDaPctrHwcyxNcEmtTGNpIiVk0Iu7vtThZAFL3DjHpINPGF7+cg==",
       "dependencies": {
         "@babel/runtime": "^7.17.2",
         "@mui/private-theming": "^5.8.0",
@@ -3134,7 +3174,7 @@
         "@mui/types": "^7.1.3",
         "@mui/utils": "^5.8.0",
         "clsx": "^1.1.1",
-        "csstype": "^3.0.11",
+        "csstype": "^3.1.0",
         "prop-types": "^15.8.1"
       },
       "engines": {
@@ -3832,9 +3872,9 @@
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw=="
     },
     "node_modules/@types/node": {
-      "version": "17.0.36",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.36.tgz",
-      "integrity": "sha512-V3orv+ggDsWVHP99K3JlwtH20R7J4IhI1Kksgc+64q5VxgfRkQG8Ws3MFm/FZOKDYGy9feGFlZ70/HpCNe9QaA=="
+      "version": "17.0.38",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.38.tgz",
+      "integrity": "sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g=="
     },
     "node_modules/@types/parse-json": {
       "version": "4.0.0",
@@ -3867,9 +3907,9 @@
       "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw=="
     },
     "node_modules/@types/react": {
-      "version": "18.0.9",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.9.tgz",
-      "integrity": "sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==",
+      "version": "18.0.10",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.10.tgz",
+      "integrity": "sha512-dIugadZuIPrRzvIEevIu7A1smqOAjkSMv8qOfwPt9Ve6i6JT/FQcCHyk2qIAxwsQNKZt5/oGR0T4z9h2dXRAkg==",
       "dependencies": {
         "@types/prop-types": "*",
         "@types/scheduler": "*",
@@ -3967,13 +4007,13 @@
       "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA=="
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.26.0.tgz",
-      "integrity": "sha512-oGCmo0PqnRZZndr+KwvvAUvD3kNE4AfyoGCwOZpoCncSh4MVD06JTE8XQa2u9u+NX5CsyZMBTEc2C72zx38eYA==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.27.0.tgz",
+      "integrity": "sha512-DDrIA7GXtmHXr1VCcx9HivA39eprYBIFxbQEHI6NyraRDxCGpxAFiYQAT/1Y0vh1C+o2vfBiy4IuPoXxtTZCAQ==",
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.26.0",
-        "@typescript-eslint/type-utils": "5.26.0",
-        "@typescript-eslint/utils": "5.26.0",
+        "@typescript-eslint/scope-manager": "5.27.0",
+        "@typescript-eslint/type-utils": "5.27.0",
+        "@typescript-eslint/utils": "5.27.0",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -4013,11 +4053,11 @@
       }
     },
     "node_modules/@typescript-eslint/experimental-utils": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.26.0.tgz",
-      "integrity": "sha512-OgUGXC/teXD8PYOkn33RSwBJPVwL0I2ipm5OHr9g9cfAhVrPC2DxQiWqaq88MNO5mbr/ZWnav3EVBpuwDreS5Q==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.27.0.tgz",
+      "integrity": "sha512-ZOn342bYh19IYvkiorrqnzNoRAr91h3GiFSSfa4tlHV+R9GgR8SxCwAi8PKMyT8+pfwMxfQdNbwKsMurbF9hzg==",
       "dependencies": {
-        "@typescript-eslint/utils": "5.26.0"
+        "@typescript-eslint/utils": "5.27.0"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4031,13 +4071,13 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.26.0.tgz",
-      "integrity": "sha512-n/IzU87ttzIdnAH5vQ4BBDnLPly7rC5VnjN3m0xBG82HK6rhRxnCb3w/GyWbNDghPd+NktJqB/wl6+YkzZ5T5Q==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.27.0.tgz",
+      "integrity": "sha512-8oGjQF46c52l7fMiPPvX4It3u3V3JipssqDfHQ2hcR0AeR8Zge+OYyKUCm5b70X72N1qXt0qgHenwN6Gc2SXZA==",
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.26.0",
-        "@typescript-eslint/types": "5.26.0",
-        "@typescript-eslint/typescript-estree": "5.26.0",
+        "@typescript-eslint/scope-manager": "5.27.0",
+        "@typescript-eslint/types": "5.27.0",
+        "@typescript-eslint/typescript-estree": "5.27.0",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -4057,12 +4097,12 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.26.0.tgz",
-      "integrity": "sha512-gVzTJUESuTwiju/7NiTb4c5oqod8xt5GhMbExKsCTp6adU3mya6AGJ4Pl9xC7x2DX9UYFsjImC0mA62BCY22Iw==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.27.0.tgz",
+      "integrity": "sha512-VnykheBQ/sHd1Vt0LJ1JLrMH1GzHO+SzX6VTXuStISIsvRiurue/eRkTqSrG0CexHQgKG8shyJfR4o5VYioB9g==",
       "dependencies": {
-        "@typescript-eslint/types": "5.26.0",
-        "@typescript-eslint/visitor-keys": "5.26.0"
+        "@typescript-eslint/types": "5.27.0",
+        "@typescript-eslint/visitor-keys": "5.27.0"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4073,11 +4113,11 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.26.0.tgz",
-      "integrity": "sha512-7ccbUVWGLmcRDSA1+ADkDBl5fP87EJt0fnijsMFTVHXKGduYMgienC/i3QwoVhDADUAPoytgjbZbCOMj4TY55A==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.27.0.tgz",
+      "integrity": "sha512-vpTvRRchaf628Hb/Xzfek+85o//zEUotr1SmexKvTfs7czXfYjXVT/a5yDbpzLBX1rhbqxjDdr1Gyo0x1Fc64g==",
       "dependencies": {
-        "@typescript-eslint/utils": "5.26.0",
+        "@typescript-eslint/utils": "5.27.0",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       },
@@ -4098,9 +4138,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.26.0.tgz",
-      "integrity": "sha512-8794JZFE1RN4XaExLWLI2oSXsVImNkl79PzTOOWt9h0UHROwJedNOD2IJyfL0NbddFllcktGIO2aOu10avQQyA==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.0.tgz",
+      "integrity": "sha512-lY6C7oGm9a/GWhmUDOs3xAVRz4ty/XKlQ2fOLr8GAIryGn0+UBOoJDWyHer3UgrHkenorwvBnphhP+zPmzmw0A==",
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       },
@@ -4110,12 +4150,12 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.26.0.tgz",
-      "integrity": "sha512-EyGpw6eQDsfD6jIqmXP3rU5oHScZ51tL/cZgFbFBvWuCwrIptl+oueUZzSmLtxFuSOQ9vDcJIs+279gnJkfd1w==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.0.tgz",
+      "integrity": "sha512-QywPMFvgZ+MHSLRofLI7BDL+UczFFHyj0vF5ibeChDAJgdTV8k4xgEwF0geFhVlPc1p8r70eYewzpo6ps+9LJQ==",
       "dependencies": {
-        "@typescript-eslint/types": "5.26.0",
-        "@typescript-eslint/visitor-keys": "5.26.0",
+        "@typescript-eslint/types": "5.27.0",
+        "@typescript-eslint/visitor-keys": "5.27.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -4150,14 +4190,14 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.26.0.tgz",
-      "integrity": "sha512-PJFwcTq2Pt4AMOKfe3zQOdez6InIDOjUJJD3v3LyEtxHGVVRK3Vo7Dd923t/4M9hSH2q2CLvcTdxlLPjcIk3eg==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.27.0.tgz",
+      "integrity": "sha512-nZvCrkIJppym7cIbP3pOwIkAefXOmfGPnCM0LQfzNaKxJHI6VjI8NC662uoiPlaf5f6ymkTy9C3NQXev2mdXmA==",
       "dependencies": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.26.0",
-        "@typescript-eslint/types": "5.26.0",
-        "@typescript-eslint/typescript-estree": "5.26.0",
+        "@typescript-eslint/scope-manager": "5.27.0",
+        "@typescript-eslint/types": "5.27.0",
+        "@typescript-eslint/typescript-estree": "5.27.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       },
@@ -4193,11 +4233,11 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.26.0.tgz",
-      "integrity": "sha512-wei+ffqHanYDOQgg/fS6Hcar6wAWv0CUPQ3TZzOWd2BLfgP539rb49bwua8WRAs7R6kOSLn82rfEu2ro6Llt8Q==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.27.0.tgz",
+      "integrity": "sha512-46cYrteA2MrIAjv9ai44OQDUoCZyHeGIc4lsjCUX2WT6r4C+kidz1bNiR4017wHOPUythYeH+Sc7/cFP97KEAA==",
       "dependencies": {
-        "@typescript-eslint/types": "5.26.0",
+        "@typescript-eslint/types": "5.27.0",
         "eslint-visitor-keys": "^3.3.0"
       },
       "engines": {
@@ -5172,7 +5212,7 @@
     "node_modules/body-parser/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/bonjour-service": {
       "version": "1.0.12",
@@ -5335,9 +5375,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001344",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001344.tgz",
-      "integrity": "sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g==",
+      "version": "1.0.30001346",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001346.tgz",
+      "integrity": "sha512-q6ibZUO2t88QCIPayP/euuDREq+aMAxFE5S70PkrLh0iTDj/zEhgvJRKC2+CvXY6EWc6oQwUR48lL5vCW6jiXQ==",
       "funding": [
         {
           "type": "opencollective",
@@ -5617,7 +5657,7 @@
     "node_modules/compression/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/concat-map": {
       "version": "0.0.1",
@@ -5697,9 +5737,9 @@
       "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
     },
     "node_modules/core-js": {
-      "version": "3.22.7",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.7.tgz",
-      "integrity": "sha512-Jt8SReuDKVNZnZEzyEQT5eK6T2RRCXkfTq7Lo09kpm+fHjgGewSbNjV+Wt4yZMhPDdzz2x1ulI5z/w4nxpBseg==",
+      "version": "3.22.8",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.8.tgz",
+      "integrity": "sha512-UoGQ/cfzGYIuiq6Z7vWL1HfkE9U9IZ4Ub+0XSiJTCzvbZzgPA69oDF2f+lgJ6dFFLEdjW5O6svvoKzXX23xFkA==",
       "hasInstallScript": true,
       "funding": {
         "type": "opencollective",
@@ -5707,9 +5747,9 @@
       }
     },
     "node_modules/core-js-compat": {
-      "version": "3.22.7",
-      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.22.7.tgz",
-      "integrity": "sha512-uI9DAQKKiiE/mclIC5g4AjRpio27g+VMRhe6rQoz+q4Wm4L6A/fJhiLtBw+sfOpDG9wZ3O0pxIw7GbfOlBgjOA==",
+      "version": "3.22.8",
+      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.22.8.tgz",
+      "integrity": "sha512-pQnwg4xtuvc2Bs/5zYQPaEYYSuTxsF7LBWF0SvnVhthZo/Qe+rJpcEekrdNK5DWwDJ0gv0oI9NNX5Mppdy0ctg==",
       "dependencies": {
         "browserslist": "^4.20.3",
         "semver": "7.0.0"
@@ -5728,9 +5768,9 @@
       }
     },
     "node_modules/core-js-pure": {
-      "version": "3.22.7",
-      "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.7.tgz",
-      "integrity": "sha512-wTriFxiZI+C8msGeh7fJcbC/a0V8fdInN1oS2eK79DMBGs8iIJiXhtFJCiT3rBa8w6zroHWW3p8ArlujZ/Mz+w==",
+      "version": "3.22.8",
+      "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.8.tgz",
+      "integrity": "sha512-bOxbZIy9S5n4OVH63XaLVXZ49QKicjowDx/UELyJ68vxfCRpYsbyh/WNZNfEfAk+ekA8vSjt+gCDpvh672bc3w==",
       "hasInstallScript": true,
       "funding": {
         "type": "opencollective",
@@ -6050,11 +6090,11 @@
       }
     },
     "node_modules/cssnano": {
-      "version": "5.1.9",
-      "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.9.tgz",
-      "integrity": "sha512-hctQHIIeDrfMjq0bQhoVmRVaSeNNOGxkvkKVOcKpJzLr09wlRrZWH4GaYudp0aszpW8wJeaO5/yBmID9n7DNCg==",
+      "version": "5.1.10",
+      "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.10.tgz",
+      "integrity": "sha512-ACpnRgDg4m6CZD/+8SgnLcGCgy6DDGdkMbOawwdvVxNietTNLe/MtWcenp6qT0PRt5wzhGl6/cjMWCdhKXC9QA==",
       "dependencies": {
-        "cssnano-preset-default": "^5.2.9",
+        "cssnano-preset-default": "^5.2.10",
         "lilconfig": "^2.0.3",
         "yaml": "^1.10.2"
       },
@@ -6070,25 +6110,25 @@
       }
     },
     "node_modules/cssnano-preset-default": {
-      "version": "5.2.9",
-      "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.9.tgz",
-      "integrity": "sha512-/4qcQcAfFEg+gnXE5NxKmYJ9JcT+8S5SDuJCLYMDN8sM/ymZ+lgLXq5+ohx/7V2brUCkgW2OaoCzOdAN0zvhGw==",
+      "version": "5.2.10",
+      "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.10.tgz",
+      "integrity": "sha512-H8TJRhTjBKVOPltp9vr9El9I+IfYsOMhmXdK0LwdvwJcxYX9oWkY7ctacWusgPWAgQq1vt/WO8v+uqpfLnM7QA==",
       "dependencies": {
         "css-declaration-sorter": "^6.2.2",
         "cssnano-utils": "^3.1.0",
         "postcss-calc": "^8.2.3",
         "postcss-colormin": "^5.3.0",
-        "postcss-convert-values": "^5.1.1",
-        "postcss-discard-comments": "^5.1.1",
+        "postcss-convert-values": "^5.1.2",
+        "postcss-discard-comments": "^5.1.2",
         "postcss-discard-duplicates": "^5.1.0",
         "postcss-discard-empty": "^5.1.1",
         "postcss-discard-overridden": "^5.1.0",
         "postcss-merge-longhand": "^5.1.5",
-        "postcss-merge-rules": "^5.1.1",
+        "postcss-merge-rules": "^5.1.2",
         "postcss-minify-font-values": "^5.1.0",
         "postcss-minify-gradients": "^5.1.1",
         "postcss-minify-params": "^5.1.3",
-        "postcss-minify-selectors": "^5.2.0",
+        "postcss-minify-selectors": "^5.2.1",
         "postcss-normalize-charset": "^5.1.0",
         "postcss-normalize-display-values": "^5.1.0",
         "postcss-normalize-positions": "^5.1.0",
@@ -6369,7 +6409,7 @@
     "node_modules/detect-port-alt/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/detective": {
       "version": "5.2.1",
@@ -6595,9 +6635,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.4.141",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.141.tgz",
-      "integrity": "sha512-mfBcbqc0qc6RlxrsIgLG2wCqkiPAjEezHxGTu7p3dHHFOurH4EjS9rFZndX5axC8264rI1Pcbw8uQP39oZckeA=="
+      "version": "1.4.144",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.144.tgz",
+      "integrity": "sha512-R3RV3rU1xWwFJlSClVWDvARaOk6VUO/FubHLodIASDB3Mc2dzuWvNdfOgH9bwHUTqT79u92qw60NWfwUdzAqdg=="
     },
     "node_modules/emittery": {
       "version": "0.8.1",
@@ -6785,7 +6825,7 @@
     "node_modules/escodegen/node_modules/levn": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
-      "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+      "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==",
       "dependencies": {
         "prelude-ls": "~1.1.2",
         "type-check": "~0.3.2"
@@ -6813,7 +6853,7 @@
     "node_modules/escodegen/node_modules/prelude-ls": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
-      "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=",
+      "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==",
       "engines": {
         "node": ">= 0.8.0"
       }
@@ -6967,7 +7007,7 @@
     "node_modules/eslint-module-utils/node_modules/locate-path": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
-      "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
+      "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==",
       "dependencies": {
         "p-locate": "^2.0.0",
         "path-exists": "^3.0.0"
@@ -6990,7 +7030,7 @@
     "node_modules/eslint-module-utils/node_modules/p-locate": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
-      "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
+      "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==",
       "dependencies": {
         "p-limit": "^1.1.0"
       },
@@ -7001,7 +7041,7 @@
     "node_modules/eslint-module-utils/node_modules/p-try": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
-      "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=",
+      "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==",
       "engines": {
         "node": ">=4"
       }
@@ -7009,7 +7049,7 @@
     "node_modules/eslint-module-utils/node_modules/path-exists": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
-      "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
+      "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
       "engines": {
         "node": ">=4"
       }
@@ -7079,7 +7119,7 @@
     "node_modules/eslint-plugin-import/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/eslint-plugin-jest": {
       "version": "25.7.0",
@@ -7568,7 +7608,7 @@
     "node_modules/express/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/express/node_modules/safe-buffer": {
       "version": "5.2.1",
@@ -7761,7 +7801,7 @@
     "node_modules/finalhandler/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/find-cache-dir": {
       "version": "3.3.2",
@@ -8318,7 +8358,7 @@
     "node_modules/has-flag": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
-      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+      "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
       "engines": {
         "node": ">=4"
       }
@@ -8399,7 +8439,7 @@
     "node_modules/hpack.js": {
       "version": "2.1.6",
       "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
-      "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=",
+      "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==",
       "dependencies": {
         "inherits": "^2.0.1",
         "obuf": "^1.0.0",
@@ -8521,7 +8561,7 @@
     "node_modules/http-deceiver": {
       "version": "1.2.7",
       "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
-      "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc="
+      "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw=="
     },
     "node_modules/http-errors": {
       "version": "2.0.0",
@@ -8613,9 +8653,9 @@
       }
     },
     "node_modules/i18next": {
-      "version": "21.8.4",
-      "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.4.tgz",
-      "integrity": "sha512-b3LQ5n9V1juu8UItb5x1QTI4OTvNqsNs/wetwQlBvfijEqks+N5HKMKSoevf8w0/RGUrDQ7g4cvVzF8WBp9pUw==",
+      "version": "21.8.7",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.7.tgz",
+      "integrity": "sha512-fYbEmaCIMSDrAzXGnisZyBd0h3bcO43jVJa5fulk4ambTIOgs9tqgNsiyr1sy6Xi4iZpYMHp2ZBjU3IcCLGxPA==",
       "funding": [
         {
           "type": "individual",
@@ -8680,7 +8720,7 @@
     "node_modules/identity-obj-proxy": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
-      "integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=",
+      "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==",
       "dependencies": {
         "harmony-reflect": "^1.4.6"
       },
@@ -8741,7 +8781,7 @@
     "node_modules/imurmurhash": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
-      "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
       "engines": {
         "node": ">=0.8.19"
       }
@@ -8749,7 +8789,7 @@
     "node_modules/inflight": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
-      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
       "dependencies": {
         "once": "^1.3.0",
         "wrappy": "1"
@@ -8789,7 +8829,7 @@
     "node_modules/is-arrayish": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
-      "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0="
+      "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
     },
     "node_modules/is-bigint": {
       "version": "1.0.4",
@@ -8881,7 +8921,7 @@
     "node_modules/is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
       "engines": {
         "node": ">=0.10.0"
       }
@@ -8916,7 +8956,7 @@
     "node_modules/is-module": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
-      "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE="
+      "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="
     },
     "node_modules/is-negative-zero": {
       "version": "2.0.2",
@@ -8954,7 +8994,7 @@
     "node_modules/is-obj": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
-      "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=",
+      "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==",
       "engines": {
         "node": ">=0.10.0"
       }
@@ -8993,7 +9033,7 @@
     "node_modules/is-regexp": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
-      "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=",
+      "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==",
       "engines": {
         "node": ">=0.10.0"
       }
@@ -9059,7 +9099,7 @@
     "node_modules/is-typedarray": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
-      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
+      "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
     },
     "node_modules/is-weakref": {
       "version": "1.0.2",
@@ -9086,12 +9126,12 @@
     "node_modules/isarray": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
-      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
     },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
     },
     "node_modules/istanbul-lib-coverage": {
       "version": "3.2.0",
@@ -11273,7 +11313,7 @@
     "node_modules/json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
-      "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE="
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="
     },
     "node_modules/json5": {
       "version": "2.2.1",
@@ -11349,7 +11389,7 @@
     "node_modules/language-tags": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz",
-      "integrity": "sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=",
+      "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==",
       "dependencies": {
         "language-subtag-registry": "~0.3.2"
       }
@@ -11430,12 +11470,12 @@
     "node_modules/lodash.debounce": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
-      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
+      "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
     },
     "node_modules/lodash.memoize": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
-      "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4="
+      "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="
     },
     "node_modules/lodash.merge": {
       "version": "4.6.2",
@@ -11445,12 +11485,12 @@
     "node_modules/lodash.sortby": {
       "version": "4.7.0",
       "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
-      "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg="
+      "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="
     },
     "node_modules/lodash.uniq": {
       "version": "4.5.0",
       "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
-      "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M="
+      "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="
     },
     "node_modules/loose-envify": {
       "version": "1.4.0",
@@ -11520,7 +11560,7 @@
     "node_modules/media-typer": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
-      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
       "engines": {
         "node": ">= 0.6"
       }
@@ -11539,7 +11579,7 @@
     "node_modules/merge-descriptors": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
-      "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
+      "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w=="
     },
     "node_modules/merge-stream": {
       "version": "2.0.0",
@@ -11557,7 +11597,7 @@
     "node_modules/methods": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
-      "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
       "engines": {
         "node": ">= 0.6"
       }
@@ -11742,7 +11782,7 @@
     "node_modules/natural-compare": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
-      "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc="
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
     },
     "node_modules/negotiator": {
       "version": "0.6.3",
@@ -11796,7 +11836,7 @@
     "node_modules/node-int64": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
-      "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs="
+      "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="
     },
     "node_modules/node-releases": {
       "version": "2.0.5",
@@ -11814,7 +11854,7 @@
     "node_modules/normalize-range": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
-      "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=",
+      "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
       "engines": {
         "node": ">=0.10.0"
       }
@@ -11860,7 +11900,7 @@
     "node_modules/object-assign": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
       "engines": {
         "node": ">=0.10.0"
       }
@@ -12007,7 +12047,7 @@
     "node_modules/once": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
       "dependencies": {
         "wrappy": "1"
       }
@@ -12176,7 +12216,7 @@
     "node_modules/path-is-absolute": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
-      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
       "engines": {
         "node": ">=0.10.0"
       }
@@ -12197,7 +12237,7 @@
     "node_modules/path-to-regexp": {
       "version": "0.1.7",
       "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
-      "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
+      "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
     },
     "node_modules/path-type": {
       "version": "4.0.0",
@@ -12210,7 +12250,7 @@
     "node_modules/performance-now": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
-      "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
+      "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
     },
     "node_modules/picocolors": {
       "version": "1.0.0",
@@ -12357,7 +12397,7 @@
     "node_modules/pkg-up/node_modules/path-exists": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
-      "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
+      "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
       "engines": {
         "node": ">=4"
       }
@@ -12498,9 +12538,9 @@
       }
     },
     "node_modules/postcss-convert-values": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.1.tgz",
-      "integrity": "sha512-UjcYfl3wJJdcabGKk8lgetPvhi1Et7VDc3sYr9EyhNBeB00YD4vHgPBp+oMVoG/dDWCc6ASbmzPNV6jADTwh8Q==",
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.2.tgz",
+      "integrity": "sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g==",
       "dependencies": {
         "browserslist": "^4.20.3",
         "postcss-value-parser": "^4.2.0"
@@ -12570,9 +12610,9 @@
       }
     },
     "node_modules/postcss-discard-comments": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.1.tgz",
-      "integrity": "sha512-5JscyFmvkUxz/5/+TB3QTTT9Gi9jHkcn8dcmmuN68JQcv3aQg4y88yEHHhwFB52l/NkaJ43O0dbksGMAo49nfQ==",
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz",
+      "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==",
       "engines": {
         "node": "^10 || ^12 || >=14.0"
       },
@@ -12872,9 +12912,9 @@
       }
     },
     "node_modules/postcss-merge-rules": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.1.tgz",
-      "integrity": "sha512-8wv8q2cXjEuCcgpIB1Xx1pIy8/rhMPIQqYKNzEdyx37m6gpq83mQQdCxgIkFgliyEnKvdwJf/C61vN4tQDq4Ww==",
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.2.tgz",
+      "integrity": "sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ==",
       "dependencies": {
         "browserslist": "^4.16.6",
         "caniuse-api": "^3.0.0",
@@ -12935,9 +12975,9 @@
       }
     },
     "node_modules/postcss-minify-selectors": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.0.tgz",
-      "integrity": "sha512-vYxvHkW+iULstA+ctVNx0VoRAR4THQQRkG77o0oa4/mBS0OzGvvzLIvHDv/nNEM0crzN2WIyFU5X7wZhaUK3RA==",
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz",
+      "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==",
       "dependencies": {
         "postcss-selector-parser": "^6.0.5"
       },
@@ -13249,11 +13289,11 @@
       }
     },
     "node_modules/postcss-preset-env": {
-      "version": "7.6.0",
-      "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.6.0.tgz",
-      "integrity": "sha512-5cnzpSFZnQJOlBu85xn4Nnluy/WjIST/ugn+gOVcKnmFJ+GLtkfRhmJPo/TW9UDpG7oyA467kvDOO8mtcpOL4g==",
+      "version": "7.7.0",
+      "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.7.0.tgz",
+      "integrity": "sha512-2Q9YARQju+j2BVgAyDnW1pIWIMlaHZqbaGISPMmalznNlWcNFIZFQsJfRLXS+WHmHJDCmV7wIWpVf9JNKR4Elw==",
       "dependencies": {
-        "@csstools/postcss-cascade-layers": "^1.0.1",
+        "@csstools/postcss-cascade-layers": "^1.0.2",
         "@csstools/postcss-color-function": "^1.1.0",
         "@csstools/postcss-font-format-keywords": "^1.0.0",
         "@csstools/postcss-hwb-function": "^1.0.1",
@@ -13263,16 +13303,17 @@
         "@csstools/postcss-oklab-function": "^1.1.0",
         "@csstools/postcss-progressive-custom-properties": "^1.3.0",
         "@csstools/postcss-stepped-value-functions": "^1.0.0",
+        "@csstools/postcss-trigonometric-functions": "^1.0.0",
         "@csstools/postcss-unset-value": "^1.0.1",
         "autoprefixer": "^10.4.7",
         "browserslist": "^4.20.3",
         "css-blank-pseudo": "^3.0.3",
         "css-has-pseudo": "^3.0.4",
         "css-prefers-color-scheme": "^6.0.3",
-        "cssdb": "^6.6.1",
+        "cssdb": "^6.6.2",
         "postcss-attribute-case-insensitive": "^5.0.0",
         "postcss-clamp": "^4.1.0",
-        "postcss-color-functional-notation": "^4.2.2",
+        "postcss-color-functional-notation": "^4.2.3",
         "postcss-color-hex-alpha": "^8.0.3",
         "postcss-color-rebeccapurple": "^7.0.2",
         "postcss-custom-media": "^8.0.0",
@@ -13290,7 +13331,7 @@
         "postcss-lab-function": "^4.2.0",
         "postcss-logical": "^5.0.4",
         "postcss-media-minmax": "^5.0.0",
-        "postcss-nesting": "^10.1.6",
+        "postcss-nesting": "^10.1.7",
         "postcss-opacity-percentage": "^1.1.2",
         "postcss-overflow-shorthand": "^3.0.3",
         "postcss-page-break": "^3.0.4",
@@ -13604,7 +13645,7 @@
     "node_modules/q": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
-      "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=",
+      "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
       "engines": {
         "node": ">=0.6.0",
         "teleport": ">=0.2.0"
@@ -14174,7 +14215,7 @@
     "node_modules/regjsparser/node_modules/jsesc": {
       "version": "0.5.0",
       "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
-      "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=",
+      "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==",
       "bin": {
         "jsesc": "bin/jsesc"
       }
@@ -14359,9 +14400,9 @@
       }
     },
     "node_modules/rollup": {
-      "version": "2.75.1",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.1.tgz",
-      "integrity": "sha512-zD73rq3Fanr/spmiybMqmGEvOpryj/heLqOb+lubxiXlo8azeJ/z306T2dJYuzfWZPQBS0OT++GXG6Lbd4ToKw==",
+      "version": "2.75.5",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.5.tgz",
+      "integrity": "sha512-JzNlJZDison3o2mOxVmb44Oz7t74EfSd1SQrplQk0wSaXV7uLQXtVdHbxlcT3w+8tZ1TL4r/eLfc7nAbz38BBA==",
       "bin": {
         "rollup": "dist/bin/rollup"
       },
@@ -14599,7 +14640,7 @@
     "node_modules/send/node_modules/debug/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/send/node_modules/ms": {
       "version": "2.1.3",
@@ -14650,7 +14691,7 @@
     "node_modules/serve-index/node_modules/http-errors": {
       "version": "1.6.3",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
-      "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=",
+      "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==",
       "dependencies": {
         "depd": "~1.1.2",
         "inherits": "2.0.3",
@@ -14664,12 +14705,12 @@
     "node_modules/serve-index/node_modules/inherits": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-      "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+      "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw=="
     },
     "node_modules/serve-index/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/serve-index/node_modules/setprototypeof": {
       "version": "1.1.0",
@@ -15382,13 +15423,13 @@
       }
     },
     "node_modules/terser": {
-      "version": "5.13.1",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-5.13.1.tgz",
-      "integrity": "sha512-hn4WKOfwnwbYfe48NgrQjqNOH9jzLqRcIfbYytOXCOv46LBfWr9bDS17MQqOi+BWGD0sJK3Sj5NC/gJjiojaoA==",
+      "version": "5.14.0",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.0.tgz",
+      "integrity": "sha512-JC6qfIEkPBd9j1SMO3Pfn+A6w2kQV54tv+ABQLgZr7dA3k/DL/OBoYSWxzVpZev3J+bUHXfr55L8Mox7AaNo6g==",
       "dependencies": {
+        "@jridgewell/source-map": "^0.3.2",
         "acorn": "^8.5.0",
         "commander": "^2.20.0",
-        "source-map": "~0.8.0-beta.0",
         "source-map-support": "~0.5.20"
       },
       "bin": {
@@ -15399,14 +15440,14 @@
       }
     },
     "node_modules/terser-webpack-plugin": {
-      "version": "5.3.1",
-      "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1.tgz",
-      "integrity": "sha512-GvlZdT6wPQKbDNW/GDQzZFg/j4vKU96yl2q6mcUkzKOgW4gwf1Z8cZToUCrz31XHlPWH8MVb1r2tFtdDtTGJ7g==",
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.3.tgz",
+      "integrity": "sha512-Fx60G5HNYknNTNQnzQ1VePRuu89ZVYWfjRAeT5rITuCY/1b08s49e5kSQwHDirKZWuoKOBRFS98EUUoZ9kLEwQ==",
       "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.7",
         "jest-worker": "^27.4.5",
         "schema-utils": "^3.1.1",
         "serialize-javascript": "^6.0.0",
-        "source-map": "^0.6.1",
         "terser": "^5.7.2"
       },
       "engines": {
@@ -15431,53 +15472,11 @@
         }
       }
     },
-    "node_modules/terser-webpack-plugin/node_modules/source-map": {
-      "version": "0.6.1",
-      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
     "node_modules/terser/node_modules/commander": {
       "version": "2.20.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
       "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
     },
-    "node_modules/terser/node_modules/source-map": {
-      "version": "0.8.0-beta.0",
-      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
-      "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
-      "dependencies": {
-        "whatwg-url": "^7.0.0"
-      },
-      "engines": {
-        "node": ">= 8"
-      }
-    },
-    "node_modules/terser/node_modules/tr46": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
-      "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=",
-      "dependencies": {
-        "punycode": "^2.1.0"
-      }
-    },
-    "node_modules/terser/node_modules/webidl-conversions": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
-      "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
-    },
-    "node_modules/terser/node_modules/whatwg-url": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
-      "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
-      "dependencies": {
-        "lodash.sortby": "^4.7.0",
-        "tr46": "^1.0.1",
-        "webidl-conversions": "^4.0.2"
-      }
-    },
     "node_modules/test-exclude": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -15903,9 +15902,9 @@
       }
     },
     "node_modules/watchpack": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz",
-      "integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==",
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
+      "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
       "dependencies": {
         "glob-to-regexp": "^0.4.1",
         "graceful-fs": "^4.1.2"
@@ -15931,9 +15930,9 @@
       }
     },
     "node_modules/webpack": {
-      "version": "5.72.1",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.72.1.tgz",
-      "integrity": "sha512-dXG5zXCLspQR4krZVR6QgajnZOjW2K/djHvdcRaDQvsjV9z9vaW6+ja5dZOYbqBBjF6kGXka/2ZyxNdc+8Jung==",
+      "version": "5.73.0",
+      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.73.0.tgz",
+      "integrity": "sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA==",
       "dependencies": {
         "@types/eslint-scope": "^3.7.3",
         "@types/estree": "^0.0.51",
@@ -16048,9 +16047,9 @@
       }
     },
     "node_modules/webpack-dev-server": {
-      "version": "4.9.0",
-      "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.9.0.tgz",
-      "integrity": "sha512-+Nlb39iQSOSsFv0lWUuUTim3jDQO8nhK3E68f//J2r5rIcp4lULHXz2oZ0UVdEeWXEh5lSzYUlzarZhDAeAVQw==",
+      "version": "4.9.1",
+      "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.9.1.tgz",
+      "integrity": "sha512-CTMfu2UMdR/4OOZVHRpdy84pNopOuigVIsRbGX3LVDMWNP8EUgC5mUBMErbwBlHTEX99ejZJpVqrir6EXAEajA==",
       "dependencies": {
         "@types/bonjour": "^3.5.9",
         "@types/connect-history-api-fallback": "^1.3.5",
@@ -16076,7 +16075,7 @@
         "schema-utils": "^4.0.0",
         "selfsigned": "^2.0.1",
         "serve-index": "^1.9.1",
-        "sockjs": "^0.3.21",
+        "sockjs": "^0.3.24",
         "spdy": "^4.0.2",
         "webpack-dev-middleware": "^5.3.1",
         "ws": "^8.4.2"
@@ -17107,9 +17106,9 @@
       }
     },
     "@babel/parser": {
-      "version": "7.18.3",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.3.tgz",
-      "integrity": "sha512-rL50YcEuHbbauAFAysNsJA4/f89fGTOBRNs9P81sniKnKAr4xULe5AecolcsKbi88xu0ByWYDj/S1AJ3FSFuSQ=="
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.4.tgz",
+      "integrity": "sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow=="
     },
     "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
       "version": "7.17.12",
@@ -17480,24 +17479,24 @@
       }
     },
     "@babel/plugin-transform-block-scoping": {
-      "version": "7.17.12",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.17.12.tgz",
-      "integrity": "sha512-jw8XW/B1i7Lqwqj2CbrViPcZijSxfguBWZP2aN59NHgxUyO/OcO1mfdCxH13QhN5LbWhPkX+f+brKGhZTiqtZQ==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.4.tgz",
+      "integrity": "sha512-+Hq10ye+jlvLEogSOtq4mKvtk7qwcUQ1f0Mrueai866C82f844Yom2cttfJdMdqRLTxWpsbfbkIkOIfovyUQXw==",
       "requires": {
         "@babel/helper-plugin-utils": "^7.17.12"
       }
     },
     "@babel/plugin-transform-classes": {
-      "version": "7.17.12",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.17.12.tgz",
-      "integrity": "sha512-cvO7lc7pZat6BsvH6l/EGaI8zpl8paICaoGk+7x7guvtfak/TbIf66nYmJOH13EuG0H+Xx3M+9LQDtSvZFKXKw==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.18.4.tgz",
+      "integrity": "sha512-e42NSG2mlKWgxKUAD9EJJSkZxR67+wZqzNxLSpc51T8tRU5SLFHsPmgYR5yr7sdgX4u+iHA1C5VafJ6AyImV3A==",
       "requires": {
         "@babel/helper-annotate-as-pure": "^7.16.7",
-        "@babel/helper-environment-visitor": "^7.16.7",
+        "@babel/helper-environment-visitor": "^7.18.2",
         "@babel/helper-function-name": "^7.17.9",
         "@babel/helper-optimise-call-expression": "^7.16.7",
         "@babel/helper-plugin-utils": "^7.17.12",
-        "@babel/helper-replace-supers": "^7.16.7",
+        "@babel/helper-replace-supers": "^7.18.2",
         "@babel/helper-split-export-declaration": "^7.16.7",
         "globals": "^11.1.0"
       }
@@ -17609,9 +17608,9 @@
       }
     },
     "@babel/plugin-transform-modules-systemjs": {
-      "version": "7.18.0",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.0.tgz",
-      "integrity": "sha512-vwKpxdHnlM5tIrRt/eA0bzfbi7gUBLN08vLu38np1nZevlPySRe6yvuATJB5F/WPJ+ur4OXwpVYq9+BsxqAQuQ==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.4.tgz",
+      "integrity": "sha512-lH2UaQaHVOAeYrUUuZ8i38o76J/FnO8vu21OE+tD1MyP9lxdZoSfz+pDbWkq46GogUrdrMz3tiz/FYGB+bVThg==",
       "requires": {
         "@babel/helper-hoist-variables": "^7.16.7",
         "@babel/helper-module-transforms": "^7.18.0",
@@ -17788,9 +17787,9 @@
       }
     },
     "@babel/plugin-transform-typescript": {
-      "version": "7.18.1",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.18.1.tgz",
-      "integrity": "sha512-F+RJmL479HJmC0KeqqwEGZMg1P7kWArLGbAKfEi9yPthJyMNjF+DjxFF/halfQvq1Q9GFM4TUbYDNV8xe4Ctqg==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.18.4.tgz",
+      "integrity": "sha512-l4vHuSLUajptpHNEOUDEGsnpl9pfRLsN1XUoDQDD/YBuXTM+v37SHGS+c6n4jdcZy96QtuUuSvZYMLSSsjH8Mw==",
       "requires": {
         "@babel/helper-create-class-features-plugin": "^7.18.0",
         "@babel/helper-plugin-utils": "^7.17.12",
@@ -17976,9 +17975,9 @@
       }
     },
     "@babel/types": {
-      "version": "7.18.2",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.18.2.tgz",
-      "integrity": "sha512-0On6B8A4/+mFUto5WERt3EEuG1NznDirvwca1O8UwXQHVY8g3R7OzYgxXdOfMwLO08UrpUD/2+3Bclyq+/C94Q==",
+      "version": "7.18.4",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.18.4.tgz",
+      "integrity": "sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw==",
       "requires": {
         "@babel/helper-validator-identifier": "^7.16.7",
         "to-fast-properties": "^2.0.0"
@@ -18079,6 +18078,14 @@
         "postcss-value-parser": "^4.2.0"
       }
     },
+    "@csstools/postcss-trigonometric-functions": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-1.0.1.tgz",
+      "integrity": "sha512-G78CY/+GePc6dDCTUbwI6TTFQ5fs3N9POHhI6v0QzteGpf6ylARiJUNz9HrRKi4eVYBNXjae1W2766iUEFxHlw==",
+      "requires": {
+        "postcss-value-parser": "^4.2.0"
+      }
+    },
     "@csstools/postcss-unset-value": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.1.tgz",
@@ -18768,6 +18775,27 @@
       "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.1.tgz",
       "integrity": "sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ=="
     },
+    "@jridgewell/source-map": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
+      "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
+      "requires": {
+        "@jridgewell/gen-mapping": "^0.3.0",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      },
+      "dependencies": {
+        "@jridgewell/gen-mapping": {
+          "version": "0.3.1",
+          "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.1.tgz",
+          "integrity": "sha512-GcHwniMlA2z+WFPWuY8lp3fsza0I8xPFMWL5+n8LYyP6PSvPrXf4+n8stDHZY2DM0zy9sVkRDy1jDI4XGzYVqg==",
+          "requires": {
+            "@jridgewell/set-array": "^1.0.0",
+            "@jridgewell/sourcemap-codec": "^1.4.10",
+            "@jridgewell/trace-mapping": "^0.3.9"
+          }
+        }
+      }
+    },
     "@jridgewell/sourcemap-codec": {
       "version": "1.4.13",
       "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz",
@@ -18788,9 +18816,9 @@
       "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
     },
     "@mui/base": {
-      "version": "5.0.0-alpha.82",
-      "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.82.tgz",
-      "integrity": "sha512-WUVDjCGnLXzmGxrmfW31blhucg0sRX4YddK2Falq7FlVzwdJaPgWn/xzPZmdLL0+WXon0gQVnDrq2qvggE/GMg==",
+      "version": "5.0.0-alpha.83",
+      "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.83.tgz",
+      "integrity": "sha512-/bFcjiI36R2Epf2Y3BkZOIdxrz5uMLqOU4cRai4igJ8DHTRMZDeKbOff0SdvwJNwg8r6oPUyoeOpsWkaOOX9/g==",
       "requires": {
         "@babel/runtime": "^7.17.2",
         "@emotion/is-prop-valid": "^1.1.2",
@@ -18803,26 +18831,26 @@
       }
     },
     "@mui/icons-material": {
-      "version": "5.8.0",
-      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.8.0.tgz",
-      "integrity": "sha512-ScwLxa0q5VYV70Jfc60V/9VD0b9SvIeZ0Jddx2Dt2pBUFFO9vKdrbt9LYiT+4p21Au5NdYIb2XSHj46CLN1v3g==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.8.2.tgz",
+      "integrity": "sha512-fP6KUCCZZjc2rdbMSmkNmBHDskLkmP0uCox57cbVXvomU6BOPrCxnr5YXsSsQrZB8fchx7hfH0bkAgvMZ5KM0Q==",
       "requires": {
         "@babel/runtime": "^7.17.2"
       }
     },
     "@mui/material": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.8.1.tgz",
-      "integrity": "sha512-Vl3BHFzOcAT5TJfvzoQUyuo/Xckn+/NSRyJ8upM4Hbz6Y1egW6P8f1RCa4FdkEfPSd5wSSYdmPfAiEh8eI4rPg==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.8.2.tgz",
+      "integrity": "sha512-w/A1KG9Czf42uTyJOiRU5U1VullOz1R3xcsBvv3BtKCCWdVP+D6v/Yb8v0tJpIixMEbjeWzWGjotQBU0nd+yNA==",
       "requires": {
         "@babel/runtime": "^7.17.2",
-        "@mui/base": "5.0.0-alpha.82",
-        "@mui/system": "^5.8.1",
+        "@mui/base": "5.0.0-alpha.83",
+        "@mui/system": "^5.8.2",
         "@mui/types": "^7.1.3",
         "@mui/utils": "^5.8.0",
         "@types/react-transition-group": "^4.4.4",
         "clsx": "^1.1.1",
-        "csstype": "^3.0.11",
+        "csstype": "^3.1.0",
         "hoist-non-react-statics": "^3.3.2",
         "prop-types": "^15.8.1",
         "react-is": "^17.0.2",
@@ -18850,9 +18878,9 @@
       }
     },
     "@mui/system": {
-      "version": "5.8.1",
-      "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.8.1.tgz",
-      "integrity": "sha512-kWJMEN62+HJb4LMRNEAZQYc++FPYsqPsU9dCL7ByLgmz/ZzRrZ8FjDi2r4j0ZeE4kaVvqBXh+RA7tLzmCKqV9w==",
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.8.2.tgz",
+      "integrity": "sha512-N74gDNKM+MnWvKTMmCPvCVLH4f0ZzakP1bcMDaPctrHwcyxNcEmtTGNpIiVk0Iu7vtThZAFL3DjHpINPGF7+cg==",
       "requires": {
         "@babel/runtime": "^7.17.2",
         "@mui/private-theming": "^5.8.0",
@@ -18860,7 +18888,7 @@
         "@mui/types": "^7.1.3",
         "@mui/utils": "^5.8.0",
         "clsx": "^1.1.1",
-        "csstype": "^3.0.11",
+        "csstype": "^3.1.0",
         "prop-types": "^15.8.1"
       }
     },
@@ -19338,9 +19366,9 @@
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw=="
     },
     "@types/node": {
-      "version": "17.0.36",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.36.tgz",
-      "integrity": "sha512-V3orv+ggDsWVHP99K3JlwtH20R7J4IhI1Kksgc+64q5VxgfRkQG8Ws3MFm/FZOKDYGy9feGFlZ70/HpCNe9QaA=="
+      "version": "17.0.38",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.38.tgz",
+      "integrity": "sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g=="
     },
     "@types/parse-json": {
       "version": "4.0.0",
@@ -19373,9 +19401,9 @@
       "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw=="
     },
     "@types/react": {
-      "version": "18.0.9",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.9.tgz",
-      "integrity": "sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==",
+      "version": "18.0.10",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.10.tgz",
+      "integrity": "sha512-dIugadZuIPrRzvIEevIu7A1smqOAjkSMv8qOfwPt9Ve6i6JT/FQcCHyk2qIAxwsQNKZt5/oGR0T4z9h2dXRAkg==",
       "requires": {
         "@types/prop-types": "*",
         "@types/scheduler": "*",
@@ -19473,13 +19501,13 @@
       "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA=="
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.26.0.tgz",
-      "integrity": "sha512-oGCmo0PqnRZZndr+KwvvAUvD3kNE4AfyoGCwOZpoCncSh4MVD06JTE8XQa2u9u+NX5CsyZMBTEc2C72zx38eYA==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.27.0.tgz",
+      "integrity": "sha512-DDrIA7GXtmHXr1VCcx9HivA39eprYBIFxbQEHI6NyraRDxCGpxAFiYQAT/1Y0vh1C+o2vfBiy4IuPoXxtTZCAQ==",
       "requires": {
-        "@typescript-eslint/scope-manager": "5.26.0",
-        "@typescript-eslint/type-utils": "5.26.0",
-        "@typescript-eslint/utils": "5.26.0",
+        "@typescript-eslint/scope-manager": "5.27.0",
+        "@typescript-eslint/type-utils": "5.27.0",
+        "@typescript-eslint/utils": "5.27.0",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -19499,55 +19527,55 @@
       }
     },
     "@typescript-eslint/experimental-utils": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.26.0.tgz",
-      "integrity": "sha512-OgUGXC/teXD8PYOkn33RSwBJPVwL0I2ipm5OHr9g9cfAhVrPC2DxQiWqaq88MNO5mbr/ZWnav3EVBpuwDreS5Q==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.27.0.tgz",
+      "integrity": "sha512-ZOn342bYh19IYvkiorrqnzNoRAr91h3GiFSSfa4tlHV+R9GgR8SxCwAi8PKMyT8+pfwMxfQdNbwKsMurbF9hzg==",
       "requires": {
-        "@typescript-eslint/utils": "5.26.0"
+        "@typescript-eslint/utils": "5.27.0"
       }
     },
     "@typescript-eslint/parser": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.26.0.tgz",
-      "integrity": "sha512-n/IzU87ttzIdnAH5vQ4BBDnLPly7rC5VnjN3m0xBG82HK6rhRxnCb3w/GyWbNDghPd+NktJqB/wl6+YkzZ5T5Q==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.27.0.tgz",
+      "integrity": "sha512-8oGjQF46c52l7fMiPPvX4It3u3V3JipssqDfHQ2hcR0AeR8Zge+OYyKUCm5b70X72N1qXt0qgHenwN6Gc2SXZA==",
       "requires": {
-        "@typescript-eslint/scope-manager": "5.26.0",
-        "@typescript-eslint/types": "5.26.0",
-        "@typescript-eslint/typescript-estree": "5.26.0",
+        "@typescript-eslint/scope-manager": "5.27.0",
+        "@typescript-eslint/types": "5.27.0",
+        "@typescript-eslint/typescript-estree": "5.27.0",
         "debug": "^4.3.4"
       }
     },
     "@typescript-eslint/scope-manager": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.26.0.tgz",
-      "integrity": "sha512-gVzTJUESuTwiju/7NiTb4c5oqod8xt5GhMbExKsCTp6adU3mya6AGJ4Pl9xC7x2DX9UYFsjImC0mA62BCY22Iw==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.27.0.tgz",
+      "integrity": "sha512-VnykheBQ/sHd1Vt0LJ1JLrMH1GzHO+SzX6VTXuStISIsvRiurue/eRkTqSrG0CexHQgKG8shyJfR4o5VYioB9g==",
       "requires": {
-        "@typescript-eslint/types": "5.26.0",
-        "@typescript-eslint/visitor-keys": "5.26.0"
+        "@typescript-eslint/types": "5.27.0",
+        "@typescript-eslint/visitor-keys": "5.27.0"
       }
     },
     "@typescript-eslint/type-utils": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.26.0.tgz",
-      "integrity": "sha512-7ccbUVWGLmcRDSA1+ADkDBl5fP87EJt0fnijsMFTVHXKGduYMgienC/i3QwoVhDADUAPoytgjbZbCOMj4TY55A==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.27.0.tgz",
+      "integrity": "sha512-vpTvRRchaf628Hb/Xzfek+85o//zEUotr1SmexKvTfs7czXfYjXVT/a5yDbpzLBX1rhbqxjDdr1Gyo0x1Fc64g==",
       "requires": {
-        "@typescript-eslint/utils": "5.26.0",
+        "@typescript-eslint/utils": "5.27.0",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       }
     },
     "@typescript-eslint/types": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.26.0.tgz",
-      "integrity": "sha512-8794JZFE1RN4XaExLWLI2oSXsVImNkl79PzTOOWt9h0UHROwJedNOD2IJyfL0NbddFllcktGIO2aOu10avQQyA=="
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.27.0.tgz",
+      "integrity": "sha512-lY6C7oGm9a/GWhmUDOs3xAVRz4ty/XKlQ2fOLr8GAIryGn0+UBOoJDWyHer3UgrHkenorwvBnphhP+zPmzmw0A=="
     },
     "@typescript-eslint/typescript-estree": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.26.0.tgz",
-      "integrity": "sha512-EyGpw6eQDsfD6jIqmXP3rU5oHScZ51tL/cZgFbFBvWuCwrIptl+oueUZzSmLtxFuSOQ9vDcJIs+279gnJkfd1w==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.0.tgz",
+      "integrity": "sha512-QywPMFvgZ+MHSLRofLI7BDL+UczFFHyj0vF5ibeChDAJgdTV8k4xgEwF0geFhVlPc1p8r70eYewzpo6ps+9LJQ==",
       "requires": {
-        "@typescript-eslint/types": "5.26.0",
-        "@typescript-eslint/visitor-keys": "5.26.0",
+        "@typescript-eslint/types": "5.27.0",
+        "@typescript-eslint/visitor-keys": "5.27.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -19566,14 +19594,14 @@
       }
     },
     "@typescript-eslint/utils": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.26.0.tgz",
-      "integrity": "sha512-PJFwcTq2Pt4AMOKfe3zQOdez6InIDOjUJJD3v3LyEtxHGVVRK3Vo7Dd923t/4M9hSH2q2CLvcTdxlLPjcIk3eg==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.27.0.tgz",
+      "integrity": "sha512-nZvCrkIJppym7cIbP3pOwIkAefXOmfGPnCM0LQfzNaKxJHI6VjI8NC662uoiPlaf5f6ymkTy9C3NQXev2mdXmA==",
       "requires": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.26.0",
-        "@typescript-eslint/types": "5.26.0",
-        "@typescript-eslint/typescript-estree": "5.26.0",
+        "@typescript-eslint/scope-manager": "5.27.0",
+        "@typescript-eslint/types": "5.27.0",
+        "@typescript-eslint/typescript-estree": "5.27.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       },
@@ -19595,11 +19623,11 @@
       }
     },
     "@typescript-eslint/visitor-keys": {
-      "version": "5.26.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.26.0.tgz",
-      "integrity": "sha512-wei+ffqHanYDOQgg/fS6Hcar6wAWv0CUPQ3TZzOWd2BLfgP539rb49bwua8WRAs7R6kOSLn82rfEu2ro6Llt8Q==",
+      "version": "5.27.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.27.0.tgz",
+      "integrity": "sha512-46cYrteA2MrIAjv9ai44OQDUoCZyHeGIc4lsjCUX2WT6r4C+kidz1bNiR4017wHOPUythYeH+Sc7/cFP97KEAA==",
       "requires": {
-        "@typescript-eslint/types": "5.26.0",
+        "@typescript-eslint/types": "5.27.0",
         "eslint-visitor-keys": "^3.3.0"
       }
     },
@@ -20355,7 +20383,7 @@
         "ms": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+          "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
         }
       }
     },
@@ -20477,9 +20505,9 @@
       }
     },
     "caniuse-lite": {
-      "version": "1.0.30001344",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001344.tgz",
-      "integrity": "sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g=="
+      "version": "1.0.30001346",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001346.tgz",
+      "integrity": "sha512-q6ibZUO2t88QCIPayP/euuDREq+aMAxFE5S70PkrLh0iTDj/zEhgvJRKC2+CvXY6EWc6oQwUR48lL5vCW6jiXQ=="
     },
     "case-sensitive-paths-webpack-plugin": {
       "version": "2.4.0",
@@ -20692,7 +20720,7 @@
         "ms": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+          "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
         }
       }
     },
@@ -20750,14 +20778,14 @@
       "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
     },
     "core-js": {
-      "version": "3.22.7",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.7.tgz",
-      "integrity": "sha512-Jt8SReuDKVNZnZEzyEQT5eK6T2RRCXkfTq7Lo09kpm+fHjgGewSbNjV+Wt4yZMhPDdzz2x1ulI5z/w4nxpBseg=="
+      "version": "3.22.8",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.22.8.tgz",
+      "integrity": "sha512-UoGQ/cfzGYIuiq6Z7vWL1HfkE9U9IZ4Ub+0XSiJTCzvbZzgPA69oDF2f+lgJ6dFFLEdjW5O6svvoKzXX23xFkA=="
     },
     "core-js-compat": {
-      "version": "3.22.7",
-      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.22.7.tgz",
-      "integrity": "sha512-uI9DAQKKiiE/mclIC5g4AjRpio27g+VMRhe6rQoz+q4Wm4L6A/fJhiLtBw+sfOpDG9wZ3O0pxIw7GbfOlBgjOA==",
+      "version": "3.22.8",
+      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.22.8.tgz",
+      "integrity": "sha512-pQnwg4xtuvc2Bs/5zYQPaEYYSuTxsF7LBWF0SvnVhthZo/Qe+rJpcEekrdNK5DWwDJ0gv0oI9NNX5Mppdy0ctg==",
       "requires": {
         "browserslist": "^4.20.3",
         "semver": "7.0.0"
@@ -20771,9 +20799,9 @@
       }
     },
     "core-js-pure": {
-      "version": "3.22.7",
-      "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.7.tgz",
-      "integrity": "sha512-wTriFxiZI+C8msGeh7fJcbC/a0V8fdInN1oS2eK79DMBGs8iIJiXhtFJCiT3rBa8w6zroHWW3p8ArlujZ/Mz+w=="
+      "version": "3.22.8",
+      "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.8.tgz",
+      "integrity": "sha512-bOxbZIy9S5n4OVH63XaLVXZ49QKicjowDx/UELyJ68vxfCRpYsbyh/WNZNfEfAk+ekA8vSjt+gCDpvh672bc3w=="
     },
     "core-util-is": {
       "version": "1.0.3",
@@ -20972,35 +21000,35 @@
       "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
     },
     "cssnano": {
-      "version": "5.1.9",
-      "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.9.tgz",
-      "integrity": "sha512-hctQHIIeDrfMjq0bQhoVmRVaSeNNOGxkvkKVOcKpJzLr09wlRrZWH4GaYudp0aszpW8wJeaO5/yBmID9n7DNCg==",
+      "version": "5.1.10",
+      "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.10.tgz",
+      "integrity": "sha512-ACpnRgDg4m6CZD/+8SgnLcGCgy6DDGdkMbOawwdvVxNietTNLe/MtWcenp6qT0PRt5wzhGl6/cjMWCdhKXC9QA==",
       "requires": {
-        "cssnano-preset-default": "^5.2.9",
+        "cssnano-preset-default": "^5.2.10",
         "lilconfig": "^2.0.3",
         "yaml": "^1.10.2"
       }
     },
     "cssnano-preset-default": {
-      "version": "5.2.9",
-      "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.9.tgz",
-      "integrity": "sha512-/4qcQcAfFEg+gnXE5NxKmYJ9JcT+8S5SDuJCLYMDN8sM/ymZ+lgLXq5+ohx/7V2brUCkgW2OaoCzOdAN0zvhGw==",
+      "version": "5.2.10",
+      "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.10.tgz",
+      "integrity": "sha512-H8TJRhTjBKVOPltp9vr9El9I+IfYsOMhmXdK0LwdvwJcxYX9oWkY7ctacWusgPWAgQq1vt/WO8v+uqpfLnM7QA==",
       "requires": {
         "css-declaration-sorter": "^6.2.2",
         "cssnano-utils": "^3.1.0",
         "postcss-calc": "^8.2.3",
         "postcss-colormin": "^5.3.0",
-        "postcss-convert-values": "^5.1.1",
-        "postcss-discard-comments": "^5.1.1",
+        "postcss-convert-values": "^5.1.2",
+        "postcss-discard-comments": "^5.1.2",
         "postcss-discard-duplicates": "^5.1.0",
         "postcss-discard-empty": "^5.1.1",
         "postcss-discard-overridden": "^5.1.0",
         "postcss-merge-longhand": "^5.1.5",
-        "postcss-merge-rules": "^5.1.1",
+        "postcss-merge-rules": "^5.1.2",
         "postcss-minify-font-values": "^5.1.0",
         "postcss-minify-gradients": "^5.1.1",
         "postcss-minify-params": "^5.1.3",
-        "postcss-minify-selectors": "^5.2.0",
+        "postcss-minify-selectors": "^5.2.1",
         "postcss-normalize-charset": "^5.1.0",
         "postcss-normalize-display-values": "^5.1.0",
         "postcss-normalize-positions": "^5.1.0",
@@ -21212,7 +21240,7 @@
         "ms": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+          "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
         }
       }
     },
@@ -21384,9 +21412,9 @@
       }
     },
     "electron-to-chromium": {
-      "version": "1.4.141",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.141.tgz",
-      "integrity": "sha512-mfBcbqc0qc6RlxrsIgLG2wCqkiPAjEezHxGTu7p3dHHFOurH4EjS9rFZndX5axC8264rI1Pcbw8uQP39oZckeA=="
+      "version": "1.4.144",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.144.tgz",
+      "integrity": "sha512-R3RV3rU1xWwFJlSClVWDvARaOk6VUO/FubHLodIASDB3Mc2dzuWvNdfOgH9bwHUTqT79u92qw60NWfwUdzAqdg=="
     },
     "emittery": {
       "version": "0.8.1",
@@ -21526,7 +21554,7 @@
         "levn": {
           "version": "0.3.0",
           "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
-          "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+          "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==",
           "requires": {
             "prelude-ls": "~1.1.2",
             "type-check": "~0.3.2"
@@ -21548,7 +21576,7 @@
         "prelude-ls": {
           "version": "1.1.2",
           "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
-          "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ="
+          "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="
         },
         "source-map": {
           "version": "0.6.1",
@@ -21747,7 +21775,7 @@
         "locate-path": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
-          "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
+          "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==",
           "requires": {
             "p-locate": "^2.0.0",
             "path-exists": "^3.0.0"
@@ -21764,7 +21792,7 @@
         "p-locate": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
-          "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
+          "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==",
           "requires": {
             "p-limit": "^1.1.0"
           }
@@ -21772,12 +21800,12 @@
         "p-try": {
           "version": "1.0.0",
           "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
-          "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M="
+          "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="
         },
         "path-exists": {
           "version": "3.0.0",
           "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
-          "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU="
+          "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="
         }
       }
     },
@@ -21829,7 +21857,7 @@
         "ms": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+          "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
         }
       }
     },
@@ -22102,7 +22130,7 @@
         "ms": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+          "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
         },
         "safe-buffer": {
           "version": "5.2.1",
@@ -22253,7 +22281,7 @@
         "ms": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+          "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
         }
       }
     },
@@ -22631,7 +22659,7 @@
     "has-flag": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
-      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
+      "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="
     },
     "has-property-descriptors": {
       "version": "1.0.0",
@@ -22690,7 +22718,7 @@
     "hpack.js": {
       "version": "2.1.6",
       "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
-      "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=",
+      "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==",
       "requires": {
         "inherits": "^2.0.1",
         "obuf": "^1.0.0",
@@ -22788,7 +22816,7 @@
     "http-deceiver": {
       "version": "1.2.7",
       "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
-      "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc="
+      "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw=="
     },
     "http-errors": {
       "version": "2.0.0",
@@ -22854,9 +22882,9 @@
       "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="
     },
     "i18next": {
-      "version": "21.8.4",
-      "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.4.tgz",
-      "integrity": "sha512-b3LQ5n9V1juu8UItb5x1QTI4OTvNqsNs/wetwQlBvfijEqks+N5HKMKSoevf8w0/RGUrDQ7g4cvVzF8WBp9pUw==",
+      "version": "21.8.7",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.7.tgz",
+      "integrity": "sha512-fYbEmaCIMSDrAzXGnisZyBd0h3bcO43jVJa5fulk4ambTIOgs9tqgNsiyr1sy6Xi4iZpYMHp2ZBjU3IcCLGxPA==",
       "requires": {
         "@babel/runtime": "^7.17.2"
       }
@@ -22899,7 +22927,7 @@
     "identity-obj-proxy": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
-      "integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=",
+      "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==",
       "requires": {
         "harmony-reflect": "^1.4.6"
       }
@@ -22935,12 +22963,12 @@
     "imurmurhash": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
-      "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o="
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
     },
     "inflight": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
-      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
       "requires": {
         "once": "^1.3.0",
         "wrappy": "1"
@@ -22974,7 +23002,7 @@
     "is-arrayish": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
-      "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0="
+      "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
     },
     "is-bigint": {
       "version": "1.0.4",
@@ -23030,7 +23058,7 @@
     "is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
     },
     "is-fullwidth-code-point": {
       "version": "3.0.0",
@@ -23053,7 +23081,7 @@
     "is-module": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
-      "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE="
+      "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="
     },
     "is-negative-zero": {
       "version": "2.0.2",
@@ -23076,7 +23104,7 @@
     "is-obj": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
-      "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8="
+      "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="
     },
     "is-plain-obj": {
       "version": "3.0.0",
@@ -23100,7 +23128,7 @@
     "is-regexp": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
-      "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk="
+      "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="
     },
     "is-root": {
       "version": "2.1.0",
@@ -23139,7 +23167,7 @@
     "is-typedarray": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
-      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
+      "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
     },
     "is-weakref": {
       "version": "1.0.2",
@@ -23160,12 +23188,12 @@
     "isarray": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
-      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
     },
     "isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
     },
     "istanbul-lib-coverage": {
       "version": "3.2.0",
@@ -24766,7 +24794,7 @@
     "json-stable-stringify-without-jsonify": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
-      "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE="
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="
     },
     "json5": {
       "version": "2.2.1",
@@ -24819,7 +24847,7 @@
     "language-tags": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz",
-      "integrity": "sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=",
+      "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==",
       "requires": {
         "language-subtag-registry": "~0.3.2"
       }
@@ -24879,12 +24907,12 @@
     "lodash.debounce": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
-      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
+      "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
     },
     "lodash.memoize": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
-      "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4="
+      "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="
     },
     "lodash.merge": {
       "version": "4.6.2",
@@ -24894,12 +24922,12 @@
     "lodash.sortby": {
       "version": "4.7.0",
       "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
-      "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg="
+      "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="
     },
     "lodash.uniq": {
       "version": "4.5.0",
       "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
-      "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M="
+      "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="
     },
     "loose-envify": {
       "version": "1.4.0",
@@ -24957,7 +24985,7 @@
     "media-typer": {
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
-      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
     },
     "memfs": {
       "version": "3.4.4",
@@ -24970,7 +24998,7 @@
     "merge-descriptors": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
-      "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
+      "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w=="
     },
     "merge-stream": {
       "version": "2.0.0",
@@ -24985,7 +25013,7 @@
     "methods": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
-      "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="
     },
     "micromatch": {
       "version": "4.0.5",
@@ -25112,7 +25140,7 @@
     "natural-compare": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
-      "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc="
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
     },
     "negotiator": {
       "version": "0.6.3",
@@ -25149,7 +25177,7 @@
     "node-int64": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
-      "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs="
+      "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="
     },
     "node-releases": {
       "version": "2.0.5",
@@ -25164,7 +25192,7 @@
     "normalize-range": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
-      "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI="
+      "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="
     },
     "normalize-url": {
       "version": "6.1.0",
@@ -25195,7 +25223,7 @@
     "object-assign": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
     },
     "object-hash": {
       "version": "3.0.0",
@@ -25294,7 +25322,7 @@
     "once": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
       "requires": {
         "wrappy": "1"
       }
@@ -25415,7 +25443,7 @@
     "path-is-absolute": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
-      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="
     },
     "path-key": {
       "version": "3.1.1",
@@ -25430,7 +25458,7 @@
     "path-to-regexp": {
       "version": "0.1.7",
       "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
-      "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
+      "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
     },
     "path-type": {
       "version": "4.0.0",
@@ -25440,7 +25468,7 @@
     "performance-now": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
-      "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
+      "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
     },
     "picocolors": {
       "version": "1.0.0",
@@ -25544,7 +25572,7 @@
         "path-exists": {
           "version": "3.0.0",
           "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
-          "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU="
+          "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="
         }
       }
     },
@@ -25625,9 +25653,9 @@
       }
     },
     "postcss-convert-values": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.1.tgz",
-      "integrity": "sha512-UjcYfl3wJJdcabGKk8lgetPvhi1Et7VDc3sYr9EyhNBeB00YD4vHgPBp+oMVoG/dDWCc6ASbmzPNV6jADTwh8Q==",
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.2.tgz",
+      "integrity": "sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g==",
       "requires": {
         "browserslist": "^4.20.3",
         "postcss-value-parser": "^4.2.0"
@@ -25664,9 +25692,9 @@
       }
     },
     "postcss-discard-comments": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.1.tgz",
-      "integrity": "sha512-5JscyFmvkUxz/5/+TB3QTTT9Gi9jHkcn8dcmmuN68JQcv3aQg4y88yEHHhwFB52l/NkaJ43O0dbksGMAo49nfQ==",
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz",
+      "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==",
       "requires": {}
     },
     "postcss-discard-duplicates": {
@@ -25832,9 +25860,9 @@
       }
     },
     "postcss-merge-rules": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.1.tgz",
-      "integrity": "sha512-8wv8q2cXjEuCcgpIB1Xx1pIy8/rhMPIQqYKNzEdyx37m6gpq83mQQdCxgIkFgliyEnKvdwJf/C61vN4tQDq4Ww==",
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.2.tgz",
+      "integrity": "sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ==",
       "requires": {
         "browserslist": "^4.16.6",
         "caniuse-api": "^3.0.0",
@@ -25871,9 +25899,9 @@
       }
     },
     "postcss-minify-selectors": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.0.tgz",
-      "integrity": "sha512-vYxvHkW+iULstA+ctVNx0VoRAR4THQQRkG77o0oa4/mBS0OzGvvzLIvHDv/nNEM0crzN2WIyFU5X7wZhaUK3RA==",
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz",
+      "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==",
       "requires": {
         "postcss-selector-parser": "^6.0.5"
       }
@@ -26044,11 +26072,11 @@
       }
     },
     "postcss-preset-env": {
-      "version": "7.6.0",
-      "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.6.0.tgz",
-      "integrity": "sha512-5cnzpSFZnQJOlBu85xn4Nnluy/WjIST/ugn+gOVcKnmFJ+GLtkfRhmJPo/TW9UDpG7oyA467kvDOO8mtcpOL4g==",
+      "version": "7.7.0",
+      "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.7.0.tgz",
+      "integrity": "sha512-2Q9YARQju+j2BVgAyDnW1pIWIMlaHZqbaGISPMmalznNlWcNFIZFQsJfRLXS+WHmHJDCmV7wIWpVf9JNKR4Elw==",
       "requires": {
-        "@csstools/postcss-cascade-layers": "^1.0.1",
+        "@csstools/postcss-cascade-layers": "^1.0.2",
         "@csstools/postcss-color-function": "^1.1.0",
         "@csstools/postcss-font-format-keywords": "^1.0.0",
         "@csstools/postcss-hwb-function": "^1.0.1",
@@ -26058,16 +26086,17 @@
         "@csstools/postcss-oklab-function": "^1.1.0",
         "@csstools/postcss-progressive-custom-properties": "^1.3.0",
         "@csstools/postcss-stepped-value-functions": "^1.0.0",
+        "@csstools/postcss-trigonometric-functions": "^1.0.0",
         "@csstools/postcss-unset-value": "^1.0.1",
         "autoprefixer": "^10.4.7",
         "browserslist": "^4.20.3",
         "css-blank-pseudo": "^3.0.3",
         "css-has-pseudo": "^3.0.4",
         "css-prefers-color-scheme": "^6.0.3",
-        "cssdb": "^6.6.1",
+        "cssdb": "^6.6.2",
         "postcss-attribute-case-insensitive": "^5.0.0",
         "postcss-clamp": "^4.1.0",
-        "postcss-color-functional-notation": "^4.2.2",
+        "postcss-color-functional-notation": "^4.2.3",
         "postcss-color-hex-alpha": "^8.0.3",
         "postcss-color-rebeccapurple": "^7.0.2",
         "postcss-custom-media": "^8.0.0",
@@ -26085,7 +26114,7 @@
         "postcss-lab-function": "^4.2.0",
         "postcss-logical": "^5.0.4",
         "postcss-media-minmax": "^5.0.0",
-        "postcss-nesting": "^10.1.6",
+        "postcss-nesting": "^10.1.7",
         "postcss-opacity-percentage": "^1.1.2",
         "postcss-overflow-shorthand": "^3.0.3",
         "postcss-page-break": "^3.0.4",
@@ -26310,7 +26339,7 @@
     "q": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
-      "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc="
+      "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="
     },
     "qs": {
       "version": "6.10.3",
@@ -26725,7 +26754,7 @@
         "jsesc": {
           "version": "0.5.0",
           "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
-          "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0="
+          "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA=="
         }
       }
     },
@@ -26848,9 +26877,9 @@
       }
     },
     "rollup": {
-      "version": "2.75.1",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.1.tgz",
-      "integrity": "sha512-zD73rq3Fanr/spmiybMqmGEvOpryj/heLqOb+lubxiXlo8azeJ/z306T2dJYuzfWZPQBS0OT++GXG6Lbd4ToKw==",
+      "version": "2.75.5",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.5.tgz",
+      "integrity": "sha512-JzNlJZDison3o2mOxVmb44Oz7t74EfSd1SQrplQk0wSaXV7uLQXtVdHbxlcT3w+8tZ1TL4r/eLfc7nAbz38BBA==",
       "requires": {
         "fsevents": "~2.3.2"
       }
@@ -27011,7 +27040,7 @@
             "ms": {
               "version": "2.0.0",
               "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+              "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
             }
           }
         },
@@ -27060,7 +27089,7 @@
         "http-errors": {
           "version": "1.6.3",
           "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz",
-          "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=",
+          "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==",
           "requires": {
             "depd": "~1.1.2",
             "inherits": "2.0.3",
@@ -27071,12 +27100,12 @@
         "inherits": {
           "version": "2.0.3",
           "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-          "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+          "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw=="
         },
         "ms": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+          "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
         },
         "setprototypeof": {
           "version": "1.1.0",
@@ -27629,13 +27658,13 @@
       }
     },
     "terser": {
-      "version": "5.13.1",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-5.13.1.tgz",
-      "integrity": "sha512-hn4WKOfwnwbYfe48NgrQjqNOH9jzLqRcIfbYytOXCOv46LBfWr9bDS17MQqOi+BWGD0sJK3Sj5NC/gJjiojaoA==",
+      "version": "5.14.0",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.0.tgz",
+      "integrity": "sha512-JC6qfIEkPBd9j1SMO3Pfn+A6w2kQV54tv+ABQLgZr7dA3k/DL/OBoYSWxzVpZev3J+bUHXfr55L8Mox7AaNo6g==",
       "requires": {
+        "@jridgewell/source-map": "^0.3.2",
         "acorn": "^8.5.0",
         "commander": "^2.20.0",
-        "source-map": "~0.8.0-beta.0",
         "source-map-support": "~0.5.20"
       },
       "dependencies": {
@@ -27643,57 +27672,19 @@
           "version": "2.20.3",
           "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
           "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
-        },
-        "source-map": {
-          "version": "0.8.0-beta.0",
-          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
-          "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
-          "requires": {
-            "whatwg-url": "^7.0.0"
-          }
-        },
-        "tr46": {
-          "version": "1.0.1",
-          "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
-          "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=",
-          "requires": {
-            "punycode": "^2.1.0"
-          }
-        },
-        "webidl-conversions": {
-          "version": "4.0.2",
-          "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
-          "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
-        },
-        "whatwg-url": {
-          "version": "7.1.0",
-          "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
-          "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
-          "requires": {
-            "lodash.sortby": "^4.7.0",
-            "tr46": "^1.0.1",
-            "webidl-conversions": "^4.0.2"
-          }
         }
       }
     },
     "terser-webpack-plugin": {
-      "version": "5.3.1",
-      "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1.tgz",
-      "integrity": "sha512-GvlZdT6wPQKbDNW/GDQzZFg/j4vKU96yl2q6mcUkzKOgW4gwf1Z8cZToUCrz31XHlPWH8MVb1r2tFtdDtTGJ7g==",
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.3.tgz",
+      "integrity": "sha512-Fx60G5HNYknNTNQnzQ1VePRuu89ZVYWfjRAeT5rITuCY/1b08s49e5kSQwHDirKZWuoKOBRFS98EUUoZ9kLEwQ==",
       "requires": {
+        "@jridgewell/trace-mapping": "^0.3.7",
         "jest-worker": "^27.4.5",
         "schema-utils": "^3.1.1",
         "serialize-javascript": "^6.0.0",
-        "source-map": "^0.6.1",
         "terser": "^5.7.2"
-      },
-      "dependencies": {
-        "source-map": {
-          "version": "0.6.1",
-          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
-        }
       }
     },
     "test-exclude": {
@@ -28022,9 +28013,9 @@
       }
     },
     "watchpack": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz",
-      "integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==",
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
+      "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
       "requires": {
         "glob-to-regexp": "^0.4.1",
         "graceful-fs": "^4.1.2"
@@ -28044,9 +28035,9 @@
       "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w=="
     },
     "webpack": {
-      "version": "5.72.1",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.72.1.tgz",
-      "integrity": "sha512-dXG5zXCLspQR4krZVR6QgajnZOjW2K/djHvdcRaDQvsjV9z9vaW6+ja5dZOYbqBBjF6kGXka/2ZyxNdc+8Jung==",
+      "version": "5.73.0",
+      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.73.0.tgz",
+      "integrity": "sha512-svjudQRPPa0YiOYa2lM/Gacw0r6PvxptHj4FuEKQ2kX05ZLkjbVc5MnPs6its5j7IZljnIqSVo/OsY2X0IpHGA==",
       "requires": {
         "@types/eslint-scope": "^3.7.3",
         "@types/estree": "^0.0.51",
@@ -28140,9 +28131,9 @@
       }
     },
     "webpack-dev-server": {
-      "version": "4.9.0",
-      "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.9.0.tgz",
-      "integrity": "sha512-+Nlb39iQSOSsFv0lWUuUTim3jDQO8nhK3E68f//J2r5rIcp4lULHXz2oZ0UVdEeWXEh5lSzYUlzarZhDAeAVQw==",
+      "version": "4.9.1",
+      "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.9.1.tgz",
+      "integrity": "sha512-CTMfu2UMdR/4OOZVHRpdy84pNopOuigVIsRbGX3LVDMWNP8EUgC5mUBMErbwBlHTEX99ejZJpVqrir6EXAEajA==",
       "requires": {
         "@types/bonjour": "^3.5.9",
         "@types/connect-history-api-fallback": "^1.3.5",
@@ -28168,7 +28159,7 @@
         "schema-utils": "^4.0.0",
         "selfsigned": "^2.0.1",
         "serve-index": "^1.9.1",
-        "sockjs": "^0.3.21",
+        "sockjs": "^0.3.24",
         "spdy": "^4.0.2",
         "webpack-dev-middleware": "^5.3.1",
         "ws": "^8.4.2"
diff --git a/web/public/home.html b/web/public/home.html
index 3e3a95d8..43007ca3 100644
--- a/web/public/home.html
+++ b/web/public/home.html
@@ -110,7 +110,7 @@
     <p>
         <a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
         <a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
-        <a href="https://github.com/binwiederhier/ntfy/issues/4"><img src="static/img/badge-appstore.png"></a>
+        <a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a>
     </p>
     <p>
         Here's a video showing the app in action:
diff --git a/web/public/static/langs/nl.json b/web/public/static/langs/nl.json
index 56fdf389..11954852 100644
--- a/web/public/static/langs/nl.json
+++ b/web/public/static/langs/nl.json
@@ -1,7 +1,191 @@
 {
     "action_bar_settings": "Instellingen",
-    "action_bar_send_test_notification": "Stuur testmelding",
-    "action_bar_clear_notifications": "Alle meldingen wissen",
+    "action_bar_send_test_notification": "Stuur test notificatie",
+    "action_bar_clear_notifications": "Wis alle notificaties",
     "message_bar_type_message": "Typ hier een bericht",
-    "action_bar_unsubscribe": "Afmelden"
+    "action_bar_unsubscribe": "Afmelden",
+    "message_bar_error_publishing": "Fout bij publiceren notificatie",
+    "nav_topics_title": "Geabonneerde onderwerpen",
+    "nav_button_settings": "Instellingen",
+    "alert_not_supported_description": "Notificaties worden niet ondersteund in je browser.",
+    "notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.",
+    "publish_dialog_tags_label": "Tags",
+    "publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen",
+    "prefs_users_dialog_title_edit": "Gebruiker bewerken",
+    "error_boundary_title": "Oh nee, ntfy is vastgelopen",
+    "error_boundary_description": "Dit hoort natuurlijk niet te gebeuren. Onze excuses.<br/>Wanneer het mogelijk is, <githubLink>meld deze fout op GitHub</githubLink>, of laat het ons weten via <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.",
+    "error_boundary_button_copy_stack_trace": "Stack trace kopiëren",
+    "error_boundary_stack_trace": "Stacktrace",
+    "error_boundary_gathering_info": "Meer informatie verzamelen …",
+    "prefs_users_delete_button": "Gebruiker verwijderen",
+    "prefs_notifications_delete_after_one_week": "Na één week",
+    "prefs_notifications_delete_after_one_month": "Na één maand",
+    "prefs_users_dialog_title_add": "Gebruiker toevoegen",
+    "prefs_users_dialog_password_label": "Wachtwoord",
+    "error_boundary_unsupported_indexeddb_description": "De ntfy web applicatie heeft IndexedDB nodig om correct te kunnen functioneren, helaas ondersteund jouw browser IndexedDB niet in privé / incognito modus.<br/><br/>Dit is jammer maar het is ook onlogisch om de ntfy web applicatie in privé / incognito modus te gebruiken want alle gegevens worden bewaard in de browser zijn lokale opslag. Je kan hier meer over lezen <githubLink>in deze GitHub issue</githubLink>, of praat met ons op <discordLink>Discord</discordLink> of <matrixLink>Matrix</matrixLink>.",
+    "action_bar_show_menu": "Toon menu",
+    "action_bar_logo_alt": "ntfy logo",
+    "action_bar_toggle_mute": "Notificaties dempen/opheffen",
+    "action_bar_toggle_action_menu": "Actie menu openen/sluiten",
+    "message_bar_show_dialog": "Toon publicatie venster",
+    "message_bar_publish": "Bericht publiceren",
+    "nav_button_all_notifications": "Alle notificaties",
+    "nav_button_documentation": "Documentatie",
+    "nav_button_publish_message": "Notificatie publiceren",
+    "nav_button_subscribe": "Onderwerp abonneren",
+    "nav_button_muted": "Notificaties gedempt",
+    "nav_button_connecting": "verbinden",
+    "alert_grant_title": "Notificaties zijn uitgeschakeld",
+    "alert_grant_description": "Geef je browser toestemming om meldingen weer te geven.",
+    "alert_grant_button": "Nu toestaan",
+    "alert_not_supported_title": "Notificaties zijn niet ondersteund",
+    "notifications_list": "Notificaties lijst",
+    "notifications_list_item": "Notificatie",
+    "notifications_mark_read": "Markeer als gelezen",
+    "notifications_delete": "Verwijder",
+    "notifications_copied_to_clipboard": "Gekopieerd naar klembord",
+    "notifications_tags": "Tags",
+    "notifications_priority_x": "Prioriteit {{priority}}",
+    "notifications_new_indicator": "Nieuwe notificatie",
+    "notifications_attachment_image": "Afbeelding bijlage",
+    "notifications_attachment_copy_url_title": "Kopieer URL van bijlage naar klembord",
+    "notifications_attachment_copy_url_button": "URL kopiëren",
+    "notifications_attachment_open_title": "Ga naar {{url}}",
+    "notifications_attachment_open_button": "Bijlage openen",
+    "notifications_attachment_link_expires": "link vervalt op {{date}}",
+    "notifications_attachment_link_expired": "download link is verlopen",
+    "notifications_attachment_file_image": "afbeeldingsbestand",
+    "notifications_attachment_file_video": "videobestand",
+    "notifications_attachment_file_audio": "audiobestand",
+    "notifications_attachment_file_app": "Android app bestand",
+    "notifications_attachment_file_document": "overig document",
+    "notifications_click_copy_url_title": "URL naar klembord kopiëren",
+    "notifications_click_copy_url_button": "Link kopiëren",
+    "notifications_click_open_button": "Link openen",
+    "notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL.",
+    "notifications_none_for_any_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL. Hier is een voorbeeld met één van je onderwerpen.",
+    "notifications_no_subscriptions_title": "Het lijkt erop dat je nog op geen onderwerpen geabonneerd bent.",
+    "notifications_no_subscriptions_description": "Klik op de \"{{linktext}}\" link om een onderwerp te maken of erop te abonneren. Daarna kan je berichten sturen via PUT of POST and ontvang je hier notificaties.",
+    "notifications_example": "Voorbeeld",
+    "notifications_more_details": "Voor meer informatie, bezoek de <websiteLink>website</websiteLink> of <docsLink>documentatie</docsLink>.",
+    "notifications_loading": "Notificaties laden …",
+    "publish_dialog_title_topic": "Publiceren naar {{topic}}",
+    "publish_dialog_title_no_topic": "Notificatie publiceren",
+    "publish_dialog_progress_uploading": "Uploaden …",
+    "notifications_actions_open_url_title": "Ga naar {{url}}",
+    "notifications_actions_not_supported": "Deze actie is niet ondersteund in de web applicatie",
+    "notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}",
+    "notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.",
+    "publish_dialog_priority_low": "Lage prioriteit",
+    "publish_dialog_progress_uploading_detail": "Uploaden {{loaded}}/{{total}} ({{percent}}%) …",
+    "publish_dialog_message_published": "Notificatie gepubliceerd",
+    "publish_dialog_attachment_limits_file_and_quota_reached": "overschrijd {{fileSizeLimit}} bestandslimiet en quotum, {{remainingBytes}} resterend",
+    "publish_dialog_attachment_limits_file_reached": "overschrijd {{fileSizeLimit}} bestandslimiet",
+    "publish_dialog_priority_default": "Standaard prioriteit",
+    "publish_dialog_attachment_limits_quota_reached": "overschrijd quotum, {{remainingBytes}} resterend",
+    "publish_dialog_emoji_picker_show": "Kies een emoji",
+    "publish_dialog_priority_high": "Hoge prioriteit",
+    "publish_dialog_priority_max": "Maximale prioriteit",
+    "publish_dialog_priority_min": "Minimale prioriteit",
+    "publish_dialog_base_url_label": "Service URL",
+    "publish_dialog_base_url_placeholder": "Service URL, bijvoorbeeld: https://voorbeeld.com",
+    "publish_dialog_topic_label": "Onderwerp",
+    "publish_dialog_topic_placeholder": "Onderwerp, bijv. phil_alerts",
+    "publish_dialog_topic_reset": "Onderwerp resetten",
+    "publish_dialog_title_label": "Titel",
+    "publish_dialog_title_placeholder": "Notificatie titel , bijv. Schijfruimte alarm",
+    "publish_dialog_message_label": "Bericht",
+    "publish_dialog_message_placeholder": "Typ hier een bericht",
+    "publish_dialog_tags_placeholder": "Komma gescheiden lijst met tags, bijv. waarschuwing, srv1-backup",
+    "publish_dialog_priority_label": "Prioriteit",
+    "publish_dialog_click_label": "Klik URL",
+    "publish_dialog_click_reset": "Verwijder klik URL",
+    "publish_dialog_email_label": "Email",
+    "publish_dialog_email_placeholder": "Adres om de notificatie naar door te sturen, bijv. phil@voorbeeld.com",
+    "publish_dialog_email_reset": "Email doorsturen verwijderen",
+    "publish_dialog_attach_label": "URL van bijlage",
+    "publish_dialog_click_placeholder": "URL die geopend zal worden wanneer op de notificatie geklikt wordt",
+    "publish_dialog_attach_placeholder": "Bestand bijvoegen via URL, bijv. https://f-droid.org/F-Droid.apk",
+    "publish_dialog_attach_reset": "Bijlage URL verwijderen",
+    "publish_dialog_filename_label": "Bestandsnaam",
+    "publish_dialog_filename_placeholder": "Bestandsnaam van bijlage",
+    "publish_dialog_delay_label": "Uitstellen",
+    "publish_dialog_delay_placeholder": "Bezorging uitstellen, bijv. {{unixTimestamp}}, {{relativeTime}}, of \"{{naturalLanguage}}\" (alleen Engels)",
+    "publish_dialog_delay_reset": "Verwijder uitgestelde bezorging",
+    "publish_dialog_other_features": "Andere functionaliteiten:",
+    "publish_dialog_chip_click_label": "Klik URL",
+    "publish_dialog_chip_email_label": "Doorsturen naar email",
+    "publish_dialog_chip_attach_url_label": "Bestand bijvoegen via URL",
+    "publish_dialog_chip_delay_label": "Uitgestelde bezorging",
+    "publish_dialog_chip_topic_label": "Onderwerp veranderen",
+    "publish_dialog_details_examples_description": "Voor meer voorbeelden en gedetailleerde beschrijvingen van alle functionaliteiten, bekijk de <docsLink>documentatie</docsLink>.",
+    "publish_dialog_button_cancel_sending": "Versturen annuleren",
+    "publish_dialog_button_cancel": "Annuleer",
+    "publish_dialog_button_send": "Verstuur",
+    "publish_dialog_checkbox_publish_another": "Nog een bericht versturen",
+    "publish_dialog_attached_file_title": "Bijgevoegd bestand:",
+    "publish_dialog_attached_file_filename_placeholder": "Bijlage bestandsnaam",
+    "publish_dialog_attached_file_remove": "Verwijder bijgevoegd bestand",
+    "publish_dialog_drop_file_here": "Bestand hier slepen",
+    "emoji_picker_search_placeholder": "Emoji zoeken",
+    "emoji_picker_search_clear": "Zoeken leegmaken",
+    "subscribe_dialog_subscribe_topic_placeholder": "Onderwerp naam, bijv. phils_waarschuwingen",
+    "subscribe_dialog_subscribe_use_another_label": "Gebruik een andere server",
+    "subscribe_dialog_subscribe_base_url_label": "Service URL",
+    "subscribe_dialog_subscribe_button_cancel": "Annuleren",
+    "subscribe_dialog_subscribe_button_subscribe": "Abonneren",
+    "subscribe_dialog_login_title": "Aanmelding vereist",
+    "subscribe_dialog_login_description": "Dit onderwerp is beveiligd met een wachtwoord. Geef een gebruikersnaam en wachtwoord op om te abonneren.",
+    "subscribe_dialog_login_username_label": "Gebruikersnaam, bijv. phil",
+    "subscribe_dialog_subscribe_title": "Onderwerp abonneren",
+    "subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.",
+    "subscribe_dialog_login_password_label": "Wachtwoord",
+    "subscribe_dialog_login_button_back": "Terug",
+    "subscribe_dialog_login_button_login": "Aanmelden",
+    "subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang",
+    "subscribe_dialog_error_user_anonymous": "anoniem",
+    "prefs_notifications_title": "Notificaties",
+    "prefs_notifications_sound_title": "Meldingsgeluid",
+    "prefs_notifications_sound_description_none": "Notificaties zullen geen geluid geven",
+    "prefs_notifications_sound_play": "Geselecteerd geluid afspelen",
+    "prefs_notifications_sound_description_some": "Inkomende notificaties zullen het {{sound}} afspelen",
+    "prefs_notifications_sound_no_sound": "Geen geluid",
+    "prefs_notifications_min_priority_title": "Minimale prioriteit",
+    "prefs_notifications_min_priority_description_any": "Toon alle notificaties, ongeacht prioriteit",
+    "prefs_notifications_min_priority_description_x_or_higher": "Toon notificaties als prioriteit is {{number}} ({{name}}) of hoger",
+    "prefs_notifications_min_priority_description_max": "Toon notificaties als prioriteit is 5 (maximaal)",
+    "prefs_notifications_min_priority_any": "Elke prioriteit",
+    "prefs_notifications_min_priority_low_and_higher": "Lage prioriteit en hoger",
+    "prefs_notifications_min_priority_default_and_higher": "Standaard prioriteit en hoger",
+    "prefs_notifications_min_priority_high_and_higher": "Hoge prioriteit en hoger",
+    "prefs_notifications_min_priority_max_only": "Alleen maximale prioriteit",
+    "prefs_notifications_delete_after_title": "Notificaties verwijderen",
+    "prefs_notifications_delete_after_never": "Nooit",
+    "prefs_notifications_delete_after_three_hours": "Na drie uur",
+    "prefs_notifications_delete_after_one_day": "Na één dag",
+    "prefs_notifications_delete_after_never_description": "Notificaties worden nooit automatisch verwijderd",
+    "prefs_notifications_delete_after_three_hours_description": "Notificaties worden na drie uur automatisch verwijderd",
+    "prefs_notifications_delete_after_one_day_description": "Notificaties worden na één dag automatisch verwijderd",
+    "prefs_notifications_delete_after_one_week_description": "Notificaties worden na één week automatisch verwijderd",
+    "prefs_notifications_delete_after_one_month_description": "Notificaties worden na één maand automatisch verwijderd",
+    "prefs_users_title": "Gebruikers beheren",
+    "prefs_users_description": "Gebruikers voor beveiligde onderwerpen kunnen hier toegevoegd of verwijderd worden. Let op: gebruikersnaam en wachtwoord worden opgeslagen in lokale browser opslag.",
+    "prefs_users_table": "Gebruikerstabel",
+    "prefs_users_add_button": "Gebruiker toevoegen",
+    "prefs_users_edit_button": "Gebruiker bewerken",
+    "prefs_users_table_user_header": "Gebruiker",
+    "prefs_users_table_base_url_header": "Service URL",
+    "prefs_users_dialog_base_url_label": "Service URL, bijv. https://ntfy.sh",
+    "prefs_users_dialog_username_label": "Gebruikersnaam, bijv. phil",
+    "prefs_users_dialog_button_cancel": "Annuleren",
+    "prefs_users_dialog_button_add": "Toevoegen",
+    "prefs_users_dialog_button_save": "Bewaren",
+    "prefs_appearance_title": "Weergave",
+    "prefs_appearance_language_title": "Taal",
+    "priority_min": "min",
+    "priority_low": "laag",
+    "priority_default": "standaard",
+    "priority_high": "hoog",
+    "priority_max": "max",
+    "error_boundary_unsupported_indexeddb_title": "Privé / incognito browservensters worden niet ondersteund"
 }
diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json
new file mode 100644
index 00000000..e084bcf2
--- /dev/null
+++ b/web/public/static/langs/zh_Hans.json
@@ -0,0 +1,191 @@
+{
+    "action_bar_show_menu": "显示菜单",
+    "action_bar_logo_alt": "ntfy图标",
+    "action_bar_settings": "设置",
+    "action_bar_send_test_notification": "发送测试通知",
+    "action_bar_clear_notifications": "清除所有通知",
+    "action_bar_unsubscribe": "取消订阅",
+    "action_bar_toggle_action_menu": "开启或关闭操作菜单",
+    "message_bar_type_message": "在此处输入消息",
+    "message_bar_show_dialog": "显示发布对话框",
+    "message_bar_publish": "发布消息",
+    "nav_topics_title": "订阅主题",
+    "nav_button_all_notifications": "全部通知",
+    "nav_button_documentation": "文档",
+    "nav_button_publish_message": "发布通知",
+    "nav_button_subscribe": "订阅主题",
+    "nav_button_connecting": "正在连接",
+    "alert_grant_title": "已禁用通知",
+    "alert_grant_description": "授予浏览器显示桌面通知的权限。",
+    "alert_grant_button": "现在授予",
+    "alert_not_supported_title": "不支持通知",
+    "alert_not_supported_description": "您的浏览器不支持通知。",
+    "notifications_list": "通知列表",
+    "notifications_list_item": "通知",
+    "notifications_mark_read": "标记为已读",
+    "notifications_copied_to_clipboard": "复制到剪贴板",
+    "notifications_tags": "标记",
+    "notifications_priority_x": "优先级 {{priority}}",
+    "notifications_new_indicator": "新通知",
+    "notifications_attachment_open_button": "打开附件",
+    "notifications_attachment_link_expires": "链接过期 {{date}}",
+    "notifications_attachment_link_expired": "下载链接已过期",
+    "notifications_attachment_file_image": "图片文件",
+    "notifications_attachment_image": "附件图片",
+    "notifications_attachment_file_video": "视频文件",
+    "notifications_attachment_file_audio": "音频文件",
+    "notifications_attachment_file_app": "安卓应用文件",
+    "notifications_attachment_file_document": "其他文件",
+    "notifications_click_copy_url_title": "复制链接地址到剪贴板",
+    "notifications_click_copy_url_button": "复制链接",
+    "notifications_click_open_button": "打开链接",
+    "action_bar_toggle_mute": "暂停或恢复通知",
+    "nav_button_muted": "已暂停通知",
+    "notifications_actions_not_supported": "网页应用程序不支持操作",
+    "notifications_none_for_topic_title": "您尚未收到有关此主题的任何通知。",
+    "notifications_none_for_any_title": "您尚未收到任何通知。",
+    "notifications_none_for_any_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。以下是使用您的主题的示例。",
+    "notifications_no_subscriptions_title": "看起来你还没有任何订阅。",
+    "notifications_example": "示例",
+    "notifications_more_details": "有关更多信息,请查看<websiteLink>网站</websiteLink>或<docsLink>文档</docsLink>。",
+    "notifications_loading": "正在加载通知……",
+    "publish_dialog_title_topic": "发布到 {{topic}}",
+    "publish_dialog_title_no_topic": "发布通知",
+    "publish_dialog_progress_uploading": "正在上传……",
+    "publish_dialog_progress_uploading_detail": "正在上传 {{loaded}}/{{total}} ({{percent}}%) ……",
+    "publish_dialog_message_published": "已发布通知",
+    "publish_dialog_attachment_limits_file_and_quota_reached": "超过 {{fileSizeLimit}} 文件限制和配额,剩余 {{remainingBytes}}",
+    "publish_dialog_emoji_picker_show": "选择表情符号",
+    "publish_dialog_priority_min": "最低优先级",
+    "publish_dialog_priority_low": "低优先级",
+    "publish_dialog_priority_default": "默认优先级",
+    "publish_dialog_priority_high": "高优先级",
+    "publish_dialog_priority_max": "最高优先级",
+    "publish_dialog_topic_label": "主题名称",
+    "publish_dialog_topic_placeholder": "主题名称,例如 phil_alerts",
+    "publish_dialog_topic_reset": "重置主题",
+    "publish_dialog_title_label": "主题",
+    "publish_dialog_message_label": "消息",
+    "publish_dialog_message_placeholder": "在此输入消息",
+    "publish_dialog_tags_label": "标记",
+    "publish_dialog_priority_label": "优先级",
+    "publish_dialog_base_url_label": "服务链接地址",
+    "publish_dialog_base_url_placeholder": "服务链接地址,例如 https://example.com",
+    "publish_dialog_click_label": "点击链接地址",
+    "publish_dialog_click_placeholder": "点击通知时打开链接地址",
+    "publish_dialog_email_placeholder": "将通知转发到的地址,例如 phil@example.com",
+    "publish_dialog_email_reset": "移除电子邮件转发",
+    "publish_dialog_filename_label": "文件名",
+    "publish_dialog_filename_placeholder": "附件文件名",
+    "publish_dialog_delay_label": "延期",
+    "publish_dialog_other_features": "其它功能:",
+    "publish_dialog_attach_placeholder": "使用链接地址附加文件,例如 https://f-droid.org/F-Droid.apk",
+    "publish_dialog_delay_reset": "删除延迟交付",
+    "publish_dialog_attach_reset": "移除附件链接地址",
+    "publish_dialog_chip_click_label": "点击链接地址",
+    "publish_dialog_chip_email_label": "转发邮件",
+    "publish_dialog_chip_attach_file_label": "本地文件附件",
+    "publish_dialog_chip_topic_label": "变更主题",
+    "publish_dialog_button_cancel_sending": "取消发送",
+    "publish_dialog_checkbox_publish_another": "发布另一个",
+    "publish_dialog_attached_file_title": "附件文件:",
+    "publish_dialog_attached_file_filename_placeholder": "附件文件名",
+    "publish_dialog_attached_file_remove": "删除附件文件",
+    "publish_dialog_drop_file_here": "将文件拖拽至此",
+    "emoji_picker_search_placeholder": "查找表情符号",
+    "emoji_picker_search_clear": "清除搜索",
+    "subscribe_dialog_subscribe_title": "订阅主题",
+    "publish_dialog_chip_delay_label": "延迟交付",
+    "publish_dialog_chip_attach_url_label": "链接附件地址",
+    "subscribe_dialog_subscribe_use_another_label": "使用其他服务器",
+    "subscribe_dialog_subscribe_button_subscribe": "订阅",
+    "subscribe_dialog_login_title": "请登录",
+    "subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。",
+    "subscribe_dialog_login_username_label": "用户名,例如 phil",
+    "subscribe_dialog_login_password_label": "密码",
+    "subscribe_dialog_login_button_back": "返回",
+    "subscribe_dialog_login_button_login": "登录",
+    "subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户",
+    "subscribe_dialog_error_user_anonymous": "匿名",
+    "prefs_notifications_title": "通知",
+    "prefs_notifications_sound_title": "通知提示音",
+    "prefs_notifications_sound_description_none": "收到通知时不播放任何声音",
+    "prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 声音",
+    "prefs_notifications_sound_no_sound": "静音",
+    "prefs_notifications_sound_play": "播放选中声音",
+    "prefs_notifications_min_priority_title": "最低优先级",
+    "prefs_notifications_min_priority_description_x_or_higher": "仅显示优先级为{{number}}({{name}})或以上的通知",
+    "prefs_notifications_min_priority_description_max": "仅显示最高优先级的通知",
+    "prefs_notifications_min_priority_any": "任意优先级",
+    "prefs_notifications_min_priority_low_and_higher": "低优先级和更高优先级",
+    "prefs_notifications_min_priority_default_and_higher": "默认优先级或更高优先级",
+    "prefs_notifications_min_priority_high_and_higher": "高优先级或更高优先级",
+    "prefs_notifications_min_priority_max_only": "仅最高优先级",
+    "prefs_notifications_delete_after_never": "从不",
+    "prefs_notifications_delete_after_one_month": "一月后",
+    "prefs_notifications_delete_after_one_week": "一周后",
+    "prefs_notifications_delete_after_never_description": "永不自动删除通知",
+    "prefs_notifications_delete_after_three_hours_description": "三小时后自动删除通知",
+    "prefs_notifications_delete_after_one_day_description": "一天后自动删除通知",
+    "prefs_notifications_delete_after_one_week_description": "一周后自动删除通知",
+    "prefs_notifications_delete_after_one_month_description": "一月后后自动删除通知",
+    "prefs_users_title": "管理用户",
+    "prefs_users_description": "在此处添加/删除受保护主题的用户。请注意,用户名和密码存储在浏览器的本地存储中。",
+    "prefs_users_add_button": "添加用户",
+    "prefs_users_dialog_title_add": "添加用户",
+    "prefs_users_dialog_title_edit": "编辑用户",
+    "prefs_users_dialog_username_label": "用户名,例如 phil",
+    "prefs_users_dialog_password_label": "密码",
+    "prefs_users_dialog_button_cancel": "取消",
+    "prefs_users_dialog_button_save": "保存",
+    "prefs_appearance_title": "外观",
+    "prefs_appearance_language_title": "语言",
+    "priority_min": "最低",
+    "priority_low": "低",
+    "priority_default": "默认",
+    "priority_high": "高",
+    "priority_max": "最高",
+    "error_boundary_title": "天啊,ntfy 崩溃了",
+    "prefs_users_table_base_url_header": "服务链接地址",
+    "prefs_users_dialog_base_url_label": "服务链接地址,例如 https://ntfy.sh",
+    "error_boundary_button_copy_stack_trace": "复制堆栈跟踪",
+    "error_boundary_stack_trace": "堆栈跟踪",
+    "error_boundary_gathering_info": "收集更多信息……",
+    "error_boundary_unsupported_indexeddb_title": "不支持隐私浏览",
+    "error_boundary_unsupported_indexeddb_description": "Ntfy Web应用程序需要IndexedDB才能运行,并且您的浏览器在私隐私浏览模式下不支持IndexedDB。<br/><br/>虽然这很不幸,但在隐私浏览模式下使用ntfy Web应用程序也没有多大意义,因为所有东西都存储在浏览器存储中。您可以在<githubLink>本GitHub问题</githubLink>中阅读有关它的更多信息,或者在<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>上与我们交谈。",
+    "message_bar_error_publishing": "发布通知时出错",
+    "nav_button_settings": "设置",
+    "notifications_delete": "删除",
+    "notifications_attachment_copy_url_title": "将附件中链接地址复制到剪贴板",
+    "notifications_attachment_copy_url_button": "复制链接地址",
+    "notifications_attachment_open_title": "转到 {{url}}",
+    "notifications_actions_http_request_title": "发送 HTTP {{method}} 到 {{url}}",
+    "notifications_actions_open_url_title": "转到 {{url}}",
+    "notifications_none_for_topic_description": "要向此主题发送通知,只需使用 PUT 或 POST 到主题链接即可。",
+    "subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts",
+    "notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。",
+    "publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制",
+    "publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警",
+    "publish_dialog_email_label": "电子邮件",
+    "publish_dialog_button_send": "发送",
+    "publish_dialog_attachment_limits_quota_reached": "超过配额,剩余 {{remainingBytes}}",
+    "publish_dialog_attach_label": "附件链接地址",
+    "publish_dialog_click_reset": "移除点击连接地址",
+    "publish_dialog_button_cancel": "取消",
+    "subscribe_dialog_subscribe_button_cancel": "取消",
+    "subscribe_dialog_subscribe_base_url_label": "服务地址地址",
+    "prefs_notifications_min_priority_description_any": "显示所有通知,无论优先级如何",
+    "prefs_notifications_delete_after_title": "删除通知",
+    "prefs_notifications_delete_after_three_hours": "三小时后",
+    "prefs_users_delete_button": "删除用户",
+    "prefs_users_table_user_header": "用户",
+    "prefs_users_dialog_button_add": "添加",
+    "prefs_notifications_delete_after_one_day": "一天后",
+    "error_boundary_description": "这显然不应该发生。对此非常抱歉。<br/>如果您有时间,请<githubLink>在GitHub</githubLink>上报告,或通过<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告诉我们。",
+    "prefs_users_table": "用户表",
+    "prefs_users_edit_button": "编辑用户",
+    "publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup",
+    "publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。",
+    "subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易猜测的名字。订阅后,您可以使用 PUT/POST 通知。",
+    "publish_dialog_delay_placeholder": "延迟交付,例如{{unixTimestamp}}、{{relativeTime}}或“{{naturalLanguage}}”(仅限英语)"
+}
diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js
index b93702fa..468cc4c3 100644
--- a/web/src/components/Preferences.js
+++ b/web/src/components/Preferences.js
@@ -436,7 +436,7 @@ const Appearance = () => {
 const Language = () => {
     const { t, i18n } = useTranslation();
     const labelId = "prefLanguage";
-    const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇮🇹", "🇭🇺", "🇧🇷", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
+    const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
     const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
     const lang = i18n.language ?? "en";
 
@@ -452,12 +452,14 @@ const Language = () => {
                     <MenuItem value="id">Bahasa Indonesia</MenuItem>
                     <MenuItem value="bg">Български</MenuItem>
                     <MenuItem value="cs">Čeština</MenuItem>
+                    <MenuItem value="zh_Hans">中文</MenuItem>
                     <MenuItem value="de">Deutsch</MenuItem>
                     <MenuItem value="es">Español</MenuItem>
                     <MenuItem value="fr">Français</MenuItem>
                     <MenuItem value="it">Italiano</MenuItem>
                     <MenuItem value="hu">Magyar</MenuItem>
                     <MenuItem value="ja">日本語</MenuItem>
+                    <MenuItem value="nl">Nederlands</MenuItem>
                     <MenuItem value="nb_NO">Norsk bokmål</MenuItem>
                     <MenuItem value="pt_BR">Português (Brasil)</MenuItem>
                     <MenuItem value="ru">Русский</MenuItem>