obsidian/wiki/payloadcms/features.md
2026-05-15 15:13:56 +01:00

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]]