8.9 KiB
| tags | topic | sources | created | ||||||
|---|---|---|---|---|---|---|---|---|---|
|
payloadcms |
|
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
deletedAttimestamp - Query Presets — saved/shared filters, columns, sort orders for List Views
Setup / Config
Install an adapter — most projects want Nodemailer:
pnpm add @payloadcms/email-nodemailer
# or for serverless / Vercel:
pnpm add @payloadcms/email-resend
SMTP via Nodemailer:
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):
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):
email: nodemailerAdapter()
Sending email
await payload.sendEmail({
to: 'user@example.com',
subject: 'Hello',
html: '<p>Body</p>',
})
With attachment (Nodemailer):
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:
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:
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:
{
slug: 'pages',
folders: true,
// or: folders: { browseByFolder: false } to exclude from browse view
}
How it works
- Payload adds a hidden
folderrelationship field to enabled collections - Folders nest via a self-referential
folderfield on thepayload-folderscollection - 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
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/trashshows 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:
// 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
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
deleteaccess control — same function gates both soft and permanent delete deletedAtfield is injected automatically; do not add it manually
Query Presets
Setup / Config
Per-collection opt-in:
export const MyCollection: CollectionConfig = {
slug: 'my-collection',
enableQueryPresets: true,
}
Global config (optional customization):
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:
- Collection-level (
queryPresets.access) — static rules for all presets - Document-level (
queryPresets.constraints) — per-preset rules the user can configure
Default document-level options: Only Me, Everyone, Specific Users
Custom constraint (RBAC example):
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):
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
accessrules 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