341 lines
8.9 KiB
Markdown
341 lines
8.9 KiB
Markdown
---
|
|
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]]
|