--- 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: '
Body
', }) ``` With attachment (Nodemailer): ```ts await payload.sendEmail({ to: 'user@example.com', subject: 'Report', html: 'See attached.
', 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]]