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

8.9 KiB

tags topic sources created
payloadcms
tech-patterns
payloadcms
email__overview.md
folders__overview.md
trash__overview.md
query-presets__overview.md
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:

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

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:

// 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 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:

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:

  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):

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