diff --git a/cmd/publish.go b/cmd/publish.go
index c2860b43..89e2ca90 100644
--- a/cmd/publish.go
+++ b/cmd/publish.go
@@ -25,7 +25,7 @@ var cmdPublish = &cli.Command{
 		&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"},
 		&cli.StringFlag{Name: "click", Aliases: []string{"U"}, Usage: "URL to open when notification is clicked"},
 		&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, Usage: "URL to send as an external attachment"},
-		&cli.StringFlag{Name: "filename", Aliases: []string{"n"}, Usage: "Filename for the attachment"},
+		&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, Usage: "Filename for the attachment"},
 		&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an attachment"},
 		&cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"},
 		&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"},
diff --git a/docs/config.md b/docs/config.md
index c608fd36..7609b5b8 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -36,13 +36,13 @@ Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscri
 [`since=` parameter](subscribe/api.md#fetch-cached-messages).
 
 ## Attachments
-If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments-send-files). To enable
+If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable
 this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). 
 Once these options are set and the directory is writable by the server user, you can upload attachments via PUT.
 
-By default, attachments are stored in the disk-case **for only 3 hours**. The main reason for this is to avoid legal issues
-and such when hosting user controlled content. Typically, this is more than enough time for the user (or the phone) to download 
-the file. The following config options are relevant to attachments:
+By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues
+and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download 
+feature) to download the file. The following config options are relevant to attachments:
 
 * `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs
 * `attachment-cache-dir` is the cache directory for attached files
@@ -356,8 +356,15 @@ request every 10s (defined by `visitor-request-limit-replenish`)
 * `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s.
 
 ### Attachment limits
+Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant 
+per-visitor limits:
 
-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx
+* `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M.
+  The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`, 
+  see [publishing docs](publish.md#attachments)) do not count here. 
+* `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor, 
+  including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in
+  most cloud providers. This defaults to 500M.
 
 ### E-mail limits
 Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications) 
@@ -470,38 +477,38 @@ Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `l
 CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
 variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 
-| 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`. |
-| `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. |
-| `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`. |
-| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | 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. |
-| `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-` |
-| `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 | 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 | 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 | 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* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
-| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Initial limit of e-mails per visitor |
-| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
-| `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. |
+| 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`.                                                                                                                               |
+| `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.                                        |
+| `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*       | 55s     | 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.                                                                                                                                                     |
+| `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*       | 10s     | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled                                                                                                                      |
+| `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                                                                                                                        |
 
 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.
diff --git a/docs/publish.md b/docs/publish.md
index ace4c593..2d5369ad 100644
--- a/docs/publish.md
+++ b/docs/publish.md
@@ -659,26 +659,33 @@ Here's an example that will open Reddit when the notification is clicked:
     ]));
     ```
 
-## Attachments (send files)
+## Attachments
 You can send images and other files to your phone as attachments to a notification. The attachments are then downloaded
 onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
 
-There are two different ways to send attachments, either via PUT or by passing an external URL.  
+There are two different ways to send attachments: 
 
-**Upload attachments from your computer**: To send an attachment from your computer as a file, you can send it as the 
-PUT request body. If a message is greater than the maximum message size or consists of non-UTF-8 characters, the ntfy 
-server will automatically detect the mime type and size, and send the message as an attachment file. 
+* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3`
+* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk` 
 
-You can optionally pass a filename (or force attachment mode for small text-messages) by passing the `X-Filename` header
-or query parameter (or any of its aliases `Filename`, `File` or `f`). 
+### Attach local file
+To send an attachment from your computer as a file, you can send it as the PUT request body. If a message is greater 
+than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically 
+detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files 
+as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases 
+`Filename`, `File` or `f`). 
+
+By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor). 
+Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app
+to auto-download it. Please also check out the [other limits below](#limitations).
 
 Here's an example showing how to upload an image:
 
-
 === "Command line (curl)"
     ```
     curl \
         -T flower.jpg \
+        -H "Filename: flower.jpg" \
         ntfy.sh/flowers
     ```
 
@@ -693,6 +700,7 @@ Here's an example showing how to upload an image:
     ``` http
     PUT /flowers HTTP/1.1
     Host: ntfy.sh
+    Filename: flower.jpg
 
     <binary JPEG data>
     ```
@@ -701,7 +709,8 @@ Here's an example showing how to upload an image:
     ``` javascript
     fetch('https://ntfy.sh/flowers', {
         method: 'PUT',
-        body: document.getElementById("file").files[0]
+        body: document.getElementById("file").files[0],
+        headers: { 'Filename': 'flower.jpg' }
     })
     ```
 
@@ -709,44 +718,108 @@ Here's an example showing how to upload an image:
     ``` go
     file, _ := os.Open("flower.jpg")
     req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file)
+    req.Header.Set("Filename", "flower.jpg")
     http.DefaultClient.Do(req)
     ```
 
 === "Python"
     ``` python
     requests.put("https://ntfy.sh/flowers",
-        data=open("flower.jpg", 'rb'))
+        data=open("flower.jpg", 'rb'),
+        headers={ "Filename": "flower.jpg" })
     ```
 
 === "PHP"
     ``` php-inline
-    file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
+    file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([
         'http' => [
             'method' => 'PUT',
-            'content' => XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx 
+            'header' =>
+                "Content-Type: application/octet-stream\r\n" . // Does not matter
+                "Filename: flower.jpg",
+            'content' => file_get_contents('flower.jpg') // Dangerous for large files 
         ]
     ]));
     ```
 
-```
-- Uploaded attachment
-- External attachment
-- Preview without attachment 
+Here's what that looks like on Android:
 
+<figure markdown>
+  ![image attachment](static/img/android-screenshot-attachment-image.png){ width=500 }
+  <figcaption>Image attachment sent from a local file</figcaption>
+</figure>
 
-# Upload and send attachment with custom message and filename
-curl \
-    -T flower.jpg \
-    -H "Message: Here's a flower for you" \
-    -H "Filename: flower.jpg" \
-    ntfy.sh/howdy
+### Attach file from a URL
+Instead of sending a local file to your phone, you can use an external URL to specify where the attachment is hosted.
+This could be a Google Drive or Dropbox link, or any other publicly available URL. The ntfy server will briefly probe
+the URL to retrieve type and size for you. Since the files are externally hosted, the expiration or size limits from 
+above do not apply here.
 
-# Send external attachment from other URL, with custom message 
-curl \
-    -H "Attachment: https://example.com/files.zip" \
-    "ntfy.sh/howdy?m=Important+documents+attached"
+To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)
+to specify the attachment URL. It can be any type of file.
+
+Here's an example showing how to upload an image:
+
+=== "Command line (curl)"
+    ```
+    curl \
+        -X POST \
+        -H "Attach: https://f-droid.org/F-Droid.apk" \
+        ntfy.sh/mydownloads
+    ```
+
+=== "ntfy CLI"
+    ```
+    ntfy publish \
+        --attach="https://f-droid.org/F-Droid.apk" \
+        mydownloads
+    ```
+
+=== "HTTP"
+    ``` http
+    POST /mydownloads HTTP/1.1
+    Host: ntfy.sh
+    Attach: https://f-droid.org/F-Droid.apk
+    ```
+
+=== "JavaScript"
+    ``` javascript
+    fetch('https://ntfy.sh/mydownloads', {
+        method: 'POST',
+        headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' }
+    })
+    ```
+
+=== "Go"
+    ``` go
+    req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file)
+    req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk")
+    http.DefaultClient.Do(req)
+    ```
+
+=== "Python"
+    ``` python
+    requests.put("https://ntfy.sh/mydownloads",
+        headers={ "Attach": "https://f-droid.org/F-Droid.apk" })
+    ```
+
+=== "PHP"
+    ``` php-inline
+    file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([
+        'http' => [
+        'method' => 'PUT',
+        'header' =>
+            "Content-Type: text/plain\r\n" . // Does not matter
+            "Attach: https://f-droid.org/F-Droid.apk",
+        ]
+    ]));
+    ```
+
+<figure markdown>
+  ![file attachment](static/img/android-screenshot-attachment-file.png){ width=500 }
+  <figcaption>File attachment sent from an external URL</figcaption>
+</figure>
 
-```
 
 ## E-mail notifications
 You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that 
@@ -1029,17 +1102,20 @@ parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Fireb
 option is equivalent to `Firebase: no`, but was introduced to allow future flexibility.
 
 ## Limitations
-There are a few limitations to the API to prevent abuse and to keep the server healthy. Most of them you won't run into,
+There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings 
+are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
 but just in case, let's list them all:
 
-| Limit | Description |
-|---|---|
-| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are truncated. |
-| **Requests** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
-| **E-mails** | By default, the server is configured to allow sending 16 e-mails at once, and then refills the your allowed e-mail bucket at a rate of one per hour. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
-| **Subscription limits** | By default, the server allows each visitor to keep 30 connections to the server open. |
-| **Bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. |
-| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
+| Limit                     | Description                                                                                                                                                               |
+|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Message length**        | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments).                                                                   |
+| **Requests**              | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. |
+| **E-mails**               | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour.          |
+| **Subscription limit**    | By default, the server allows each visitor to keep 30 connections to the server open.                                                                                     |
+| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors.                                      |
+| **Attachment expiry**     | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit.                                              |
+| **Attachment bandwidth**  | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected.                         |
+| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though.                                                                 |
 
 ## List of all parameters
 The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
@@ -1053,8 +1129,8 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
 | `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
 | `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
 | `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
-| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments-send-files), as an alternative to PUT/POST-ing an attachment |
-| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments-send-files) filename, as it appears in the client |
+| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
+| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
 | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
 | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
 | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
diff --git a/docs/static/img/android-screenshot-attachment-file.png b/docs/static/img/android-screenshot-attachment-file.png
new file mode 100644
index 00000000..f151c936
Binary files /dev/null and b/docs/static/img/android-screenshot-attachment-file.png differ
diff --git a/docs/static/img/android-screenshot-attachment-image.png b/docs/static/img/android-screenshot-attachment-image.png
new file mode 100644
index 00000000..42afba8b
Binary files /dev/null and b/docs/static/img/android-screenshot-attachment-image.png differ
diff --git a/server/cache_mem.go b/server/cache_mem.go
index 04e57be9..96c9831e 100644
--- a/server/cache_mem.go
+++ b/server/cache_mem.go
@@ -131,7 +131,8 @@ func (c *memCache) AttachmentsSize(owner string) (int64, error) {
 	var size int64
 	for topic := range c.messages {
 		for _, m := range c.messages[topic] {
-			if m.Attachment != nil && m.Attachment.Owner == owner {
+			counted := m.Attachment != nil && m.Attachment.Owner == owner && m.Attachment.Expires > time.Now().Unix()
+			if counted {
 				size += m.Attachment.Size
 			}
 		}
diff --git a/server/cache_mem_test.go b/server/cache_mem_test.go
index 831703a0..6e37ab48 100644
--- a/server/cache_mem_test.go
+++ b/server/cache_mem_test.go
@@ -25,6 +25,10 @@ func TestMemCache_Prune(t *testing.T) {
 	testCachePrune(t, newMemCache())
 }
 
+func TestMemCache_Attachments(t *testing.T) {
+	testCacheAttachments(t, newMemCache())
+}
+
 func TestMemCache_NopCache(t *testing.T) {
 	c := newNopCache()
 	assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
diff --git a/server/cache_sqlite_test.go b/server/cache_sqlite_test.go
index 384da256..a512e6b2 100644
--- a/server/cache_sqlite_test.go
+++ b/server/cache_sqlite_test.go
@@ -29,6 +29,10 @@ func TestSqliteCache_Prune(t *testing.T) {
 	testCachePrune(t, newSqliteTestCache(t))
 }
 
+func TestSqliteCache_Attachments(t *testing.T) {
+	testCacheAttachments(t, newSqliteTestCache(t))
+}
+
 func TestSqliteCache_Migration_From0(t *testing.T) {
 	filename := newSqliteTestCacheFile(t)
 	db, err := sql.Open("sqlite3", filename)
diff --git a/server/cache_test.go b/server/cache_test.go
index 1eae0919..71ba5497 100644
--- a/server/cache_test.go
+++ b/server/cache_test.go
@@ -1,7 +1,7 @@
 package server
 
 import (
-	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"testing"
 	"time"
 )
@@ -13,71 +13,71 @@ func testCacheMessages(t *testing.T, c cache) {
 	m2 := newDefaultMessage("mytopic", "my other message")
 	m2.Time = 2
 
-	assert.Nil(t, c.AddMessage(m1))
-	assert.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
-	assert.Nil(t, c.AddMessage(m2))
+	require.Nil(t, c.AddMessage(m1))
+	require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
+	require.Nil(t, c.AddMessage(m2))
 
 	// Adding invalid
-	assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
-	assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example")))      // These should not be added!
+	require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
+	require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example")))      // These should not be added!
 
 	// mytopic: count
 	count, err := c.MessageCount("mytopic")
-	assert.Nil(t, err)
-	assert.Equal(t, 2, count)
+	require.Nil(t, err)
+	require.Equal(t, 2, count)
 
 	// mytopic: since all
 	messages, _ := c.Messages("mytopic", sinceAllMessages, false)
-	assert.Equal(t, 2, len(messages))
-	assert.Equal(t, "my message", messages[0].Message)
-	assert.Equal(t, "mytopic", messages[0].Topic)
-	assert.Equal(t, messageEvent, messages[0].Event)
-	assert.Equal(t, "", messages[0].Title)
-	assert.Equal(t, 0, messages[0].Priority)
-	assert.Nil(t, messages[0].Tags)
-	assert.Equal(t, "my other message", messages[1].Message)
+	require.Equal(t, 2, len(messages))
+	require.Equal(t, "my message", messages[0].Message)
+	require.Equal(t, "mytopic", messages[0].Topic)
+	require.Equal(t, messageEvent, messages[0].Event)
+	require.Equal(t, "", messages[0].Title)
+	require.Equal(t, 0, messages[0].Priority)
+	require.Nil(t, messages[0].Tags)
+	require.Equal(t, "my other message", messages[1].Message)
 
 	// mytopic: since none
 	messages, _ = c.Messages("mytopic", sinceNoMessages, false)
-	assert.Empty(t, messages)
+	require.Empty(t, messages)
 
 	// mytopic: since 2
 	messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)), false)
-	assert.Equal(t, 1, len(messages))
-	assert.Equal(t, "my other message", messages[0].Message)
+	require.Equal(t, 1, len(messages))
+	require.Equal(t, "my other message", messages[0].Message)
 
 	// example: count
 	count, err = c.MessageCount("example")
-	assert.Nil(t, err)
-	assert.Equal(t, 1, count)
+	require.Nil(t, err)
+	require.Equal(t, 1, count)
 
 	// example: since all
 	messages, _ = c.Messages("example", sinceAllMessages, false)
-	assert.Equal(t, "my example message", messages[0].Message)
+	require.Equal(t, "my example message", messages[0].Message)
 
 	// non-existing: count
 	count, err = c.MessageCount("doesnotexist")
-	assert.Nil(t, err)
-	assert.Equal(t, 0, count)
+	require.Nil(t, err)
+	require.Equal(t, 0, count)
 
 	// non-existing: since all
 	messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
-	assert.Empty(t, messages)
+	require.Empty(t, messages)
 }
 
 func testCacheTopics(t *testing.T, c cache) {
-	assert.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
-	assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
-	assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
-	assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
+	require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
+	require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
+	require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
+	require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
 
 	topics, err := c.Topics()
 	if err != nil {
 		t.Fatal(err)
 	}
-	assert.Equal(t, 2, len(topics))
-	assert.Equal(t, "topic1", topics["topic1"].ID)
-	assert.Equal(t, "topic2", topics["topic2"].ID)
+	require.Equal(t, 2, len(topics))
+	require.Equal(t, "topic1", topics["topic1"].ID)
+	require.Equal(t, "topic2", topics["topic2"].ID)
 }
 
 func testCachePrune(t *testing.T, c cache) {
@@ -90,23 +90,23 @@ func testCachePrune(t *testing.T, c cache) {
 	m3 := newDefaultMessage("another_topic", "and another one")
 	m3.Time = 1
 
-	assert.Nil(t, c.AddMessage(m1))
-	assert.Nil(t, c.AddMessage(m2))
-	assert.Nil(t, c.AddMessage(m3))
-	assert.Nil(t, c.Prune(time.Unix(2, 0)))
+	require.Nil(t, c.AddMessage(m1))
+	require.Nil(t, c.AddMessage(m2))
+	require.Nil(t, c.AddMessage(m3))
+	require.Nil(t, c.Prune(time.Unix(2, 0)))
 
 	count, err := c.MessageCount("mytopic")
-	assert.Nil(t, err)
-	assert.Equal(t, 1, count)
+	require.Nil(t, err)
+	require.Equal(t, 1, count)
 
 	count, err = c.MessageCount("another_topic")
-	assert.Nil(t, err)
-	assert.Equal(t, 0, count)
+	require.Nil(t, err)
+	require.Equal(t, 0, count)
 
 	messages, err := c.Messages("mytopic", sinceAllMessages, false)
-	assert.Nil(t, err)
-	assert.Equal(t, 1, len(messages))
-	assert.Equal(t, "my other message", messages[0].Message)
+	require.Nil(t, err)
+	require.Equal(t, 1, len(messages))
+	require.Equal(t, "my other message", messages[0].Message)
 }
 
 func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
@@ -114,12 +114,12 @@ func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
 	m.Tags = []string{"tag1", "tag2"}
 	m.Priority = 5
 	m.Title = "some title"
-	assert.Nil(t, c.AddMessage(m))
+	require.Nil(t, c.AddMessage(m))
 
 	messages, _ := c.Messages("mytopic", sinceAllMessages, false)
-	assert.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
-	assert.Equal(t, 5, messages[0].Priority)
-	assert.Equal(t, "some title", messages[0].Title)
+	require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
+	require.Equal(t, 5, messages[0].Priority)
+	require.Equal(t, "some title", messages[0].Title)
 }
 
 func testCacheMessagesScheduled(t *testing.T, c cache) {
@@ -130,20 +130,93 @@ func testCacheMessagesScheduled(t *testing.T, c cache) {
 	m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
 	m4 := newDefaultMessage("mytopic2", "message 4")
 	m4.Time = time.Now().Add(time.Minute).Unix()
-	assert.Nil(t, c.AddMessage(m1))
-	assert.Nil(t, c.AddMessage(m2))
-	assert.Nil(t, c.AddMessage(m3))
+	require.Nil(t, c.AddMessage(m1))
+	require.Nil(t, c.AddMessage(m2))
+	require.Nil(t, c.AddMessage(m3))
 
 	messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
-	assert.Equal(t, 1, len(messages))
-	assert.Equal(t, "message 1", messages[0].Message)
+	require.Equal(t, 1, len(messages))
+	require.Equal(t, "message 1", messages[0].Message)
 
 	messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
-	assert.Equal(t, 3, len(messages))
-	assert.Equal(t, "message 1", messages[0].Message)
-	assert.Equal(t, "message 3", messages[1].Message) // Order!
-	assert.Equal(t, "message 2", messages[2].Message)
+	require.Equal(t, 3, len(messages))
+	require.Equal(t, "message 1", messages[0].Message)
+	require.Equal(t, "message 3", messages[1].Message) // Order!
+	require.Equal(t, "message 2", messages[2].Message)
 
 	messages, _ = c.MessagesDue()
-	assert.Empty(t, messages)
+	require.Empty(t, messages)
+}
+
+func testCacheAttachments(t *testing.T, c cache) {
+	expires1 := time.Now().Add(-4 * time.Hour).Unix()
+	m := newDefaultMessage("mytopic", "flower for you")
+	m.ID = "m1"
+	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.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.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))
+
+	messages, err := c.Messages("mytopic", sinceAllMessages, false)
+	require.Nil(t, err)
+	require.Equal(t, 2, len(messages))
+
+	require.Equal(t, "flower for you", messages[0].Message)
+	require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
+	require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
+	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, "sending you a car", messages[1].Message)
+	require.Equal(t, "car.jpg", messages[1].Attachment.Name)
+	require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
+	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)
+
+	size, err := c.AttachmentsSize("1.2.3.4")
+	require.Nil(t, err)
+	require.Equal(t, int64(30000), size)
+
+	size, err = c.AttachmentsSize("5.6.7.8")
+	require.Nil(t, err)
+	require.Equal(t, int64(0), size)
+
+	ids, err := c.AttachmentsExpired()
+	require.Nil(t, err)
+	require.Equal(t, []string{"m1"}, ids)
 }
diff --git a/server/server.yml b/server/server.yml
index a00acc2b..d1650474 100644
--- a/server/server.yml
+++ b/server/server.yml
@@ -36,7 +36,7 @@
 #
 # You can disable the cache entirely by setting this to 0.
 #
-# cache-duration: 12h
+# cache-duration: "12h"
 
 # If set, the X-Forwarded-For header is used to determine the visitor IP address
 # instead of the remote address of the connection.
@@ -46,6 +46,19 @@
 #
 # behind-proxy: false
 
+# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
+# are "attachment-cache-dir" and "base-url".
+#
+# - attachment-cache-dir is the cache directory for attached files
+# - attachment-total-size-limit is the limit of the on-disk attachment cache directory (total size)
+# - attachment-file-size-limit is the per-file attachment size limit (e.g. 300k, 2M, 100M)
+# - attachment-expiry-duration is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h)
+#
+# attachment-cache-dir:
+# attachment-total-size-limit: "5G"
+# attachment-file-size-limit: "15M"
+# attachment-expiry-duration: "3h"
+
 # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
 # messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
 # SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
@@ -78,12 +91,12 @@
 #
 # Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.
 #
-# keepalive-interval: 30s
+# keepalive-interval: "30s"
 
 # Interval in which the manager prunes old messages, deletes topics
 # and prints the stats.
 #
-# manager-interval: 1m
+# manager-interval: "1m"
 
 # Rate limiting: Total number of topics before the server rejects new topics.
 #
@@ -98,11 +111,18 @@
 # - visitor-request-limit-replenish is the rate at which the bucket is refilled
 #
 # visitor-request-limit-burst: 60
-# visitor-request-limit-replenish: 10s
+# visitor-request-limit-replenish: "10s"
 
 # Rate limiting: Allowed emails per visitor:
 # - visitor-email-limit-burst is the initial bucket of emails each visitor has
 # - visitor-email-limit-replenish is the rate at which the bucket is refilled
 #
 # visitor-email-limit-burst: 16
-# visitor-email-limit-replenish: 1h
+# visitor-email-limit-replenish: "1h"
+
+# Rate limiting: Attachment size and bandwidth limits per visitor:
+# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
+# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor
+#
+# visitor-attachment-total-size-limit: "100M"
+# visitor-attachment-daily-bandwidth-limit: "500M"
diff --git a/server/server_test.go b/server/server_test.go
index 04ac5586..4867e4a1 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -699,12 +699,21 @@ func TestServer_PublishAttachment(t *testing.T) {
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, "5000", response.Header().Get("Content-Length"))
 	require.Equal(t, content, response.Body.String())
+
+	// Slightly unrelated cross-test: make sure we add an owner for internal attachments
+	size, err := s.cache.AttachmentsSize("9.9.9.9") // See request()
+	require.Nil(t, err)
+	require.Equal(t, int64(5000), size)
 }
 
 func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
-	s := newTestServer(t, newTestConfig(t))
+	c := newTestConfig(t)
+	c.BehindProxy = true
+	s := newTestServer(t, c)
 	content := "this is an ATTACHMENT"
-	response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil)
+	response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, map[string]string{
+		"X-Forwarded-For": "1.2.3.4",
+	})
 	msg := toMessage(t, response.Body.String())
 	require.Equal(t, "myfile.txt", msg.Attachment.Name)
 	require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
@@ -719,6 +728,11 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, "21", response.Header().Get("Content-Length"))
 	require.Equal(t, content, response.Body.String())
+
+	// Slightly unrelated cross-test: make sure we add an owner for internal attachments
+	size, err := s.cache.AttachmentsSize("1.2.3.4")
+	require.Nil(t, err)
+	require.Equal(t, int64(21), size)
 }
 
 func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
@@ -734,6 +748,11 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
 	require.Equal(t, int64(0), msg.Attachment.Expires)
 	require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL)
 	require.Equal(t, "", msg.Attachment.Owner)
+
+	// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
+	size, err := s.cache.AttachmentsSize("127.0.0.1")
+	require.Nil(t, err)
+	require.Equal(t, int64(0), size)
 }
 
 func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
@@ -914,6 +933,7 @@ func request(t *testing.T, s *Server, method, url, body string, headers map[stri
 	if err != nil {
 		t.Fatal(err)
 	}
+	req.RemoteAddr = "9.9.9.9" // Used for tests
 	for k, v := range headers {
 		req.Header.Set(k, v)
 	}