Navanem projectTypeScriptMIT

Payload Contact

A Payload CMS 3 contact-form plugin with validation, spam protection, an admin inbox, and runtime configuration.

ShareLinkedInX
2Stars
0Forks
0Open issues
ActiveStatus

Built in the open.

A Payload CMS 3 contact-form plugin with validation, spam protection, an admin inbox, and runtime configuration.

From the repository.

@navanem/payload-contact

A contact-form plugin for Payload 3.x. It gives you a public form component, a validated submit endpoint with built-in spam protection, an admin inbox to read and triage messages, and a settings global to open/close the form and tune it at runtime, no redeploy required. No email is sent or exposed; messages are stored in your Payload admin.

Built by navanem, the same author as @navanem/payload-comments.

Features

  • Message inbox, a contact-messages collection (admin-read) with new / read / replied status, so the form acts as a triageable inbox.
  • Settings global, open or close the form, set min/max length, require a subject, block spam keywords, and set the success message, all at runtime.
  • Spam protection, a hidden honeypot field plus a per-IP sliding-window rate limit; blocked submissions are silently dropped (no useful signal to bots).
  • Admin Inbox view, a dedicated stats page (/admin/contact-inbox) with KPIs (total / new / read / replied), period filters and a recent-messages table.
  • Drop-in form, a styled, themeable `` client component.
  • Optional email notifications, opt-in SMTP delivery configured from the admin settings (no static email adapter required); off by default.

Installation

This plugin follows the same vendored model as @navanem/payload-comments: copy the source into your project (so the admin component paths resolve via your import map), or install from npm once published.

A. Vendor it (recommended)

# copy the plugin source into your project
cp -r node_modules/@navanem/payload-contact/src src/plugins/payload-contact
# (or git clone and copy src/ in)

Then register the admin components in your generated app/(payload)/admin/importMap.js:

import { ContactInboxView } from '@/plugins/payload-contact/components/ContactInboxView'
import { ContactInboxNavLink } from '@/plugins/payload-contact/components/ContactInboxNavLink'

export const importMap = {
  // ...
  '@/plugins/payload-contact/components/ContactInboxView#ContactInboxView': ContactInboxView,
  '@/plugins/payload-contact/components/ContactInboxNavLink#ContactInboxNavLink': ContactInboxNavLink,
}

B. Install from npm

npm install @navanem/payload-contact

Quick start

1. Register the plugin

// payload.config.ts
import { contactPlugin } from '@navanem/payload-contact' // or '@/plugins/payload-contact'

export default buildConfig({
  // ...
  plugins: [
    contactPlugin({
      notificationEmail: 'you@example.com',
    }),
  ],
})

2. Create the database tables

The plugin adds a contact-messages collection and a contact-settings global. Generate and run a migration:

payload migrate:create contact_plugin
payload migrate

The expected tables are contact_messages (with an enum_contact_messages_status enum and a payload_locked_documents_rels.contact_messages_id FK) and contact_settings.

3. Add the form to a page

import { ContactForm } from '@navanem/payload-contact/client'

export default function ContactPage() {
  return 
}

The form POSTs JSON to the plugin's endpoint:

POST /api/contact-api/submit   { name, email, subject?, message, company? }  -> { ok: true }

company is the honeypot field (kept hidden by the component). The endpoint path lives under /contact-api/*, not the collection slug, so it never collides with the contact-messages REST namespace.

Configuration

contactPlugin(options), all options are optional:

OptionTypeDefaultDescription
enabledbooleantrueAccept new submissions (runtime-overridable via the settings global).
minLengthnumber10Minimum message length.
maxLengthnumber5000Maximum message length.
requireSubjectbooleanfalseRequire the subject field.
blockedKeywordsstring[][]A message containing any of these is dropped as spam.
notificationEmailstring''Stored for future email notifications (no email is sent without an adapter).
successMessagestringsensible defaultShown to the visitor after a successful submission.
rateLimit{ windowMs: number; max: number }{ 60000, 3 }Per-IP sliding-window limit.
messagesSlugstringcontact-messagesSlug for the inbox collection.
ipSaltstringCONTACT_IP_SALT envSalt for hashing IPs in the rate limiter.
disabledbooleanfalseNo-op (collection + global still registered so the schema stays stable).

Runtime settings

A Contact Settings global appears in the admin (group "Contact"). An admin can toggle the form on/off, change the min/max length, require a subject, edit the blocked keywords, and set the success message, applied immediately, no redeploy. The endpoint and the page fail open to the plugin options if the global has never been saved.

Admin inbox

  • Contact Messages, the collection list; open a message, change its status.
  • Contact Inbox (/admin/contact-inbox), KPIs, period filters and a recent table.
  • The view is auth-guarded server-side (if (!req.user) return null) so message data never leaks into unauthenticated SSR output.

The form component

`` is a client component styled with a CSS module using --pc-* custom properties (with sensible defaults), so you can theme it by overriding those variables.

PropDefaultDescription
successMessagea default thank-youShown after submit.
disabledfalseRender a "closed" notice instead of the form.
endpoint/api/contact-api/submitSubmit URL.
className,Extra class on the root element.

Email notifications (optional)

Off by default, messages always land in the inbox. To also get emailed on each new message, open Contact Settings in the admin, expand Email notifications, tick "Send an email on each new message", and fill in the recipient + SMTP host/port/user/ password/TLS/from. An afterChange hook then sends via nodemailer on every new submission. The SMTP credentials live on an admin-read-only global, so they never reach the client.

Development

Use Node.js 22.12+ in the 22.x line, 24.x, or 26+, and pnpm 11.11.0 (the version used by CI).

pnpm install --frozen-lockfile
pnpm dev          # boots a sqlite-backed Payload admin with the plugin registered

The dev environment lives in dev/ (sqlite, schema auto-push, no migrations needed).

Testing

pnpm test         # run the vitest suite once
pnpm test:watch   # watch mode
pnpm build        # compile the plugin and copy its assets

Tests live in tests/ and exercise the plugin's pure and server logic through a fake payload object, plus the development collection's unlock rule through Payload's real access evaluator. No database is required to run them.

See SECURITY.md for the temporary Payload account-unlock mitigation and the remaining upstream advisory.

Documentation

License

MIT © navanem · www.navanem.com

Why it exists.

The code is public and the scope stays focused. Read the README, open an issue, or send a pull request if you have an idea.