# Store attachments

URL: https://better-helpdesk.com/docs/inbox/attachments/

Connect a storage bucket so customers and agents can attach files and screenshots, and email attachments are kept.



Attachments live in a bucket you own. With a `storage` adapter, the widget and the agent UI upload files straight from the browser to that bucket, and the `helpdesk` schema holds only their keys and metadata. Without one, uploads are refused and attachments on inbound email are dropped. [How it works](/docs/how-it-works#adapters-for-everything-optional) explains why storage is yours to provide.

## Write the adapter [#write-the-adapter]

The adapter has five functions. This one uses S3 with presigned POST uploads and presigned downloads:

```ts title="lib/helpdesk-storage.ts"
import {
  DeleteObjectCommand,
  GetObjectCommand,
  HeadObjectCommand,
  PutObjectCommand,
  S3Client,
} from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import type { StorageAdapter } from 'better-helpdesk';

const s3 = new S3Client({});
const Bucket = process.env.HELPDESK_BUCKET!;

export const storage: StorageAdapter = {
  presignUpload: (Key, { contentType, maxBytes }) =>
    createPresignedPost(s3, {
      Bucket,
      Key,
      Fields: { 'Content-Type': contentType },
      Conditions: [['content-length-range', 0, maxBytes]],
      Expires: 300,
    }),
  presignDownload: (Key, filename) =>
    getSignedUrl(
      s3,
      new GetObjectCommand({
        Bucket,
        Key,
        ResponseContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
      }),
      { expiresIn: 300 }
    ),
  async put(Key, Body, ContentType) {
    await s3.send(new PutObjectCommand({ Bucket, Key, Body, ContentType }));
  },
  exists: Key =>
    s3.send(new HeadObjectCommand({ Bucket, Key })).then(
      () => true,
      error => {
        if (error.name === 'NotFound') return false;
        throw error;
      }
    ),
  async delete(Key) {
    await s3.send(new DeleteObjectCommand({ Bucket, Key }));
  },
};
```

The adapter above is [`storage-s3.ts`](https://github.com/better-helpdesk/better-helpdesk/blob/main/examples/adapters/storage-s3.ts) in the repository's examples. For a host on Vercel, [`storage-vercel-blob.ts`](https://github.com/better-helpdesk/better-helpdesk/blob/main/examples/adapters/storage-vercel-blob.ts) does the same with a private Blob store. Vercel Blob takes uploads only as a `PUT`, and the widget sends a multipart `POST`, so that example also exports `handleUpload`, a route handler you mount in your app and name in `HELPDESK_UPLOAD_URL`; it relays the file to a presigned URL that Blob checks for path, type, size and expiry. A Vercel Function takes a request body of at most 4.5 MB, so set `maxAttachmentBytes` to 4 MiB or less with this example. Downloads come straight from Blob, as a download, under the key's own name.

Pass it to the config, with a size limit if 10 MiB is not the one you want. Both are described under [Adapters and host hooks](/docs/reference/configuration#adapters-and-host-hooks).

```ts title="lib/helpdesk.ts"
import { storage } from './helpdesk-storage';

export const helpdesk = buildHelpdesk({
  // ...
  storage,
  maxAttachmentBytes: 5 * 1024 * 1024,
});
```

## Allow uploads from the browser [#allow-uploads-from-the-browser]

The widget and the agent UI POST each file directly to the URL `presignUpload` returned, as a multipart form: the returned `fields` first, then the file as `file`. The bucket's CORS rules must allow that POST from every origin the widget runs on, and the one that serves the agent UI. On S3:

```json title="cors.json"
[
  {
    "AllowedOrigins": ["https://app.example.com", "https://www.example.com"],
    "AllowedMethods": ["POST"],
    "AllowedHeaders": ["*"]
  }
]
```

Downloads need no CORS rule: the handler answers a download with a redirect to the URL `presignDownload` returned.

## Make deletes stick [#make-deletes-stick]

The helpdesk calls `exists` after each `delete`, and it must then return `false`. If the object is still there, the deletion stops with an error and the conversation's rows stay, so the next run can try again. [Storage](/docs/reference/adapters#storage) in the adapters reference says when each function is called.

## What customers and agents see [#what-customers-and-agents-see]

The widget offers **Attach file** and a screenshot on the first message of a question, bug report or feature request, and takes a file dropped on any new message, or a pasted image. In an open conversation the person who opened it adds files to a reply the same three ways: the paperclip beside the reply box, a drop anywhere on the thread, or a pasted image. Inbound email brings its attachments along. Without `storage`, the widget offers none of this. An inbox with [`uploads: false`](/docs/inbox/inboxes#inboxconfig) offers none of it either, and drops the attachments of email that lands there: for a public inbox on your marketing site, where anyone could otherwise store files in your bucket, while your signed-in customers keep uploads on theirs.

An agent attaches files to a reply or an internal note with **Attach file** in the composer, by dropping them anywhere on the conversation, or by pasting a copied image. A file added to a reply or a note, by an agent or a customer, uploads as soon as it is added and goes out with the message; one removed before sending stays in your bucket until its conversation is deleted. A file on an internal note is for agents only: the widget neither lists nor serves it. The email a customer gets about a reply carries the text, not the files, which they open in the widget.

Each file shows under the message it came with, in the widget and in the agent UI, and the agent UI's sidebar lists all of a conversation's files. A PNG, JPEG, GIF or WebP image shows as a picture, and an animated GIF or WebP plays; any other file shows as its name. Either one opens the file in a new tab. A visitor without a session sees the names only, since a download link needs a signed-in customer. Of a conversation's customers, only the person who opened it can attach files, so erasing a person erases their files.

The widget and the agent UI accept PNG, JPEG, GIF, WebP, PDF and plain text, up to `maxAttachmentBytes` each. A customer may add 20 to a conversation, counting those they started and removed, and an agent 20 to a message. An email attachment of any other type is kept, but served as `application/octet-stream`, and a larger one is skipped. The package checks the type and size the browser declares; your `presignUpload` must bind them into the signed upload so the bucket enforces them. The limits are listed under [Storage](/docs/reference/adapters#storage).
