---
tags: [payloadcms, tech-patterns]
topic: payloadcms
sources: [email__overview.md, folders__overview.md, trash__overview.md, query-presets__overview.md]
created: 2026-05-15
---

# PayloadCMS — Email, Folders, Trash & Query Presets

## Overview

Four built-in features that address cross-cutting concerns:

- **Email** — adapter-based transactional email (password resets, custom sending)
- **Folders** — group documents across collections via hidden relationship fields (beta)
- **Trash** — soft-delete with restore workflow; adds `deletedAt` timestamp
- **Query Presets** — saved/shared filters, columns, sort orders for List Views

---

## Email

### Setup / Config

Install an adapter — most projects want Nodemailer:

```bash
pnpm add @payloadcms/email-nodemailer
# or for serverless / Vercel:
pnpm add @payloadcms/email-resend
```

SMTP via Nodemailer:

```ts
import { buildConfig } from 'payload'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'

export default buildConfig({
  email: nodemailerAdapter({
    defaultFromAddress: 'no-reply@example.com',
    defaultFromName: 'My App',
    transportOptions: {
      host: process.env.SMTP_HOST,
      port: 587,
      auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
    },
  }),
})
```

Resend (preferred for Vercel — lighter than Nodemailer):

```ts
import { resendAdapter } from '@payloadcms/email-resend'

export default buildConfig({
  email: resendAdapter({
    defaultFromAddress: 'dev@example.com',
    defaultFromName: 'My App',
    apiKey: process.env.RESEND_API_KEY || '',
  }),
})
```

Dev shortcut (uses ethereal.email, logs creds to console):

```ts
email: nodemailerAdapter()
```

### Sending email

```ts
await payload.sendEmail({
  to: 'user@example.com',
  subject: 'Hello',
  html: '<p>Body</p>',
})
```

With attachment (Nodemailer):

```ts
await payload.sendEmail({
  to: 'user@example.com',
  subject: 'Report',
  html: '<p>See attached.</p>',
  attachments: [
    { filename: 'invoice.pdf', path: '/var/data/invoice.pdf', contentType: 'application/pdf' },
    { filename: 'data.csv', content: Buffer.from('col1,col2\n'), contentType: 'text/csv' },
  ],
})
```

Attach from Payload media collection:

```ts
const doc = await payload.findByID({ collection: 'media', id })
// Local storage:
await payload.sendEmail({ attachments: [{ filename: doc.filename, path: doc.url }] })
// Cloud storage (Nodemailer):
const buf = Buffer.from(await (await fetch(doc.url)).arrayBuffer())
await payload.sendEmail({ attachments: [{ filename: doc.filename, content: buf }] })
// Resend from URL directly:
await payload.sendEmail({ attachments: [{ filename: doc.filename, path: doc.url }] })
```

### Key Options

| Adapter | Required options |
|---|---|
| Nodemailer | `defaultFromAddress`, `defaultFromName`, `transport` or `transportOptions` |
| Resend | `defaultFromAddress`, `defaultFromName`, `apiKey` |

### Gotchas

- When email is not configured Payload logs a startup warning and logs on every send attempt — not a hard error
- Resend attachments from local disk require Base64 encoding (`buf.toString('base64')`)
- Multiple providers: Payload supports one transport per config; use hooks to route bulk vs. transactional to different services

---

## Folders

> **Beta** — may change before stable release.

### Setup / Config

Enable globally in Payload config:

```ts
import { buildConfig } from 'payload'

export default buildConfig({
  folders: {
    slug: 'payload-folders', // default
    fieldName: 'folder',     // default
    debug: false,            // show hidden folder fields in admin
    collectionOverrides: [   // optional — modify the folder collection
      async ({ collection }) => collection,
    ],
  },
})
```

Enable per collection:

```ts
{
  slug: 'pages',
  folders: true,
  // or: folders: { browseByFolder: false } to exclude from browse view
}
```

### How it works

- Payload adds a hidden `folder` relationship field to enabled collections
- Folders nest via a self-referential `folder` field on the `payload-folders` collection
- Browse-by-folder UI appears in the Admin Panel list view when `browseByFolder: true` (default)

### Key Options

| Option | Default | Description |
|---|---|---|
| `browseByFolder` | `true` | Show folder browser in list view |
| `slug` | `payload-folders` | Collection slug for the folder documents |
| `fieldName` | `folder` | Hidden field name injected into enabled collections |
| `debug` | `false` | Reveal hidden folder fields in Admin Panel |

### Gotchas

- Feature is in beta — minor version updates may introduce breaking changes before stable
- Folder support is purely relational — no filesystem paths involved

---

## Trash (Soft Delete)

### Setup / Config

```ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  trash: true,
  fields: [{ name: 'title', type: 'text' }],
}
```

Payload injects a `deletedAt` timestamp field automatically.

### Admin Panel behavior

- New route `/collections/:slug/trash` shows all soft-deleted documents
- Bulk actions: **Restore**, **Delete** (permanent), **Empty Trash**
- Edit view of trashed doc: read-only fields, only Restore and Permanently Delete actions visible
- From main list view: delete = soft-delete by default; a checkbox in the modal allows permanent delete

### API

All Local / REST / GraphQL operations support a `trash` flag:

```ts
// Include trashed docs
await payload.find({ collection: 'posts', trash: true })

// Only trashed
await payload.find({ collection: 'posts', trash: true, where: { deletedAt: { exists: true } } })

// Only non-trashed
await payload.find({ collection: 'posts', trash: false })
```

REST equivalents: `GET /api/posts?trash=true`, `?trash=true&where[deletedAt][exists]=true`

### Access Control — separate trash from permanent delete

```ts
access: {
  delete: ({ req: { user }, data }) => {
    if (!user) return false
    if (user.roles?.includes('admin')) return true
    // data.deletedAt set = trash operation; undefined = permanent delete
    if (data?.deletedAt) return true  // allow editors to trash
    return false                       // block editors from permanent delete
  },
},
```

Pattern mirrors `data._status` for draft/publish access control.

### Gotchas

- Soft-deleted documents cannot have versions restored until the document itself is restored from trash
- Trash respects `delete` access control — same function gates both soft and permanent delete
- `deletedAt` field is injected automatically; do not add it manually

---

## Query Presets

### Setup / Config

Per-collection opt-in:

```ts
export const MyCollection: CollectionConfig = {
  slug: 'my-collection',
  enableQueryPresets: true,
}
```

Global config (optional customization):

```ts
import { buildConfig } from 'payload'

export default buildConfig({
  queryPresets: {
    labels: { singular: 'Preset', plural: 'Presets' },
    access: {
      read: ({ req: { user } }) => Boolean(user),
      update: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
    },
  },
})
```

Presets are stored as records in the `payload-query-presets` collection.

### Access Control

Two layers:

1. **Collection-level** (`queryPresets.access`) — static rules for all presets
2. **Document-level** (`queryPresets.constraints`) — per-preset rules the user can configure

Default document-level options: **Only Me**, **Everyone**, **Specific Users**

Custom constraint (RBAC example):

```ts
queryPresets: {
  constraints: {
    read: [
      {
        label: 'Specific Roles',
        value: 'specificRoles',
        fields: [
          {
            name: 'roles',
            type: 'select',
            hasMany: true,
            options: [{ label: 'Admin', value: 'admin' }, { label: 'User', value: 'user' }],
          },
        ],
        access: ({ req: { user } }) => ({
          'access.read.roles': { in: [user?.roles] },
        }),
      },
    ],
  },
}
```

Limit who can apply "Everyone" visibility (hide option for non-admins):

```ts
queryPresets: {
  filterConstraints: ({ req, options }) =>
    !req.user?.roles?.includes('admin')
      ? options.filter((o) => (typeof o === 'string' ? o : o.value) !== 'everyone')
      : options,
}
```

### Key Options

| Option | Description |
|---|---|
| `access` | Collection-level access for all presets |
| `constraints` | Custom per-operation document-level rules (label, value, fields, access) |
| `filterConstraints` | Function to hide/disable constraint options per user |
| `labels` | Custom collection labels |

### Gotchas

- Custom `access` rules override all defaults including the auth requirement — always include a user check
- Custom constraint fields land in the `access[operation]` field group — access function must query that path
- Presets are user-defined at runtime, not hard-coded in config — unsuitable for mandatory server-side filtering

---

## Related

- [[wiki/payloadcms/configuration|Configuration]]
- [[wiki/payloadcms/fields-complex|Fields Complex]]
- [[wiki/payloadcms/admin-panel-overview|Admin Panel Overview]]
