How to Send Email Notifications in Payload CMS Using the Native Plugin

Use Payload's native email plugin and server actions for reliable notifications

·Matija Žiberna·
How to Send Email Notifications in Payload CMS Using the Native Plugin

I was building an inquiry form for a client's website when I needed to send email notifications to both the business owner and the customer after form submission. Initially, I thought Payload's afterChange hook would be the perfect place for this logic. After spending hours troubleshooting why emails weren't being sent, I discovered a crucial gotcha that led me to a much more reliable approach.

This guide shows you exactly how to implement email notifications in Payload CMS using the native email plugin, with a real visitor inquiry form as our example. By the end, you'll know how to set up reliable email delivery and avoid the common hook execution pitfall I encountered.

Setting Up Payload's Native Email System

Before we can send any emails, we need to configure Payload's email adapter. The beauty of Payload's approach is that it provides a unified email interface regardless of your SMTP provider.

First, install the nodemailer adapter:

npm install @payloadcms/email-nodemailer

Then configure it in your Payload config. Here's how I set it up with Brevo SMTP:

// File: payload.config.ts
import { buildConfig } from 'payload'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'

export default buildConfig({
  // ... other config
  email: nodemailerAdapter({
    defaultFromAddress: 'info@yoursite.com',
    defaultFromName: 'Your Business Name',
    transportOptions: {
      host: process.env.BREVO_SMTP_HOST,
      port: parseInt(process.env.BREVO_SMTP_PORT || '587', 10),
      auth: {
        user: process.env.BREVO_SMTP_LOGIN,
        pass: process.env.BREVO_SMTP_KEY,
      },
    },
  }),
  // ... rest of config
})

This configuration creates a unified email interface that Payload can use throughout your application. The nodemailer adapter handles the underlying SMTP connection while providing Payload's consistent sendEmail API. You can easily switch between different email providers by just changing the transport options.

Creating the Order Collection

For our inquiry system, we need collections to store both customer data and orders. Here's the Orders collection that handles the inquiry submissions:

// File: src/collections/Orders.ts
import { CollectionConfig } from 'payload'

export const Orders: CollectionConfig = {
  slug: 'orders',
  labels: {
    singular: 'Order',
    plural: 'Orders',
  },
  
  hooks: {
    beforeChange: [
      async ({ data, operation, req }) => {
        // Generate order number for new orders
        if (operation === 'create' && !data.orderNumber) {
          const latestOrder = await req.payload.find({
            collection: 'orders',
            sort: '-createdAt', // Important: descending order
            limit: 1,
          })
          const latestOrderNumber = latestOrder.docs[0]?.orderNumber || 'ORDER-0'
          const latestOrderNumberInt = parseInt(latestOrderNumber.replace('ORDER-', ''))
          const newOrderNumber = `ORDER-${latestOrderNumberInt + 1}`
          data.orderNumber = newOrderNumber
        }
        
        // Handle customer creation/lookup
        if (operation === 'create' && data.customerData) {
          const existingCustomer = await req.payload.find({
            collection: 'customers',
            where: { email: { equals: data.customerData.email } },
            limit: 1,
          })
          
          if (existingCustomer.docs.length > 0) {
            data.customer = existingCustomer.docs[0].id
          } else {
            const newCustomer = await req.payload.create({
              collection: 'customers',
              data: {
                firstName: data.customerData.firstName,
                lastName: data.customerData.lastName,
                email: data.customerData.email,
                phone: data.customerData.phone,
                address: {
                  streetAddress: data.customerData.streetAddress,
                  postalCode: data.customerData.postalCode,
                  town: data.customerData.town,
                  country: 'Slovenia',
                },
              },
            })
            data.customer = newCustomer.id
          }
          
          delete data.customerData
        }
        
        return data
      },
    ],
  },
  
  fields: [
    {
      name: 'orderNumber',
      type: 'text',
      required: true,
      unique: true,
      admin: { readOnly: true },
    },
    {
      name: 'customer',
      type: 'relationship',
      relationTo: 'customers',
      required: true,
    },
    {
      name: 'product',
      type: 'relationship',
      relationTo: 'products',
      required: true,
    },
    {
      name: 'quantity',
      type: 'number',
      required: true,
      min: 1,
    },
    {
      name: 'customerMessage',
      type: 'textarea',
    },
    // Hidden field for form submissions
    {
      name: 'customerData',
      type: 'group',
      admin: { hidden: true },
      fields: [
        { name: 'firstName', type: 'text' },
        { name: 'lastName', type: 'text' },
        { name: 'email', type: 'email' },
        { name: 'phone', type: 'text' },
        { name: 'streetAddress', type: 'text' },
        { name: 'postalCode', type: 'text' },
        { name: 'town', type: 'text' },
        { name: 'message', type: 'textarea' },
      ],
    },
  ],
  
  timestamps: true,
}

The beforeChange hook handles the business logic of generating order numbers and managing customer relationships. This hook works reliably because it's part of Payload's core document lifecycle, unlike the issues we'll encounter with afterChange hooks in server actions.

The afterChange Hook Gotcha

Initially, I thought the logical place for sending emails would be the afterChange hook. After all, we want to send notifications after the order is successfully created. Here's what I tried first:

// File: src/collections/Orders.ts (what I thought would work)
export const Orders: CollectionConfig = {
  // ... other config
  hooks: {
    // ... beforeChange hook
    afterChange: [
      async ({ doc, operation, req }) => {
        if (operation === 'create') {
          console.log('Sending email notifications...')
          
          // Get product and customer details
          const product = await req.payload.findByID({
            collection: 'products',
            id: doc.product,
          })
          
          const customer = await req.payload.findByID({
            collection: 'customers',
            id: doc.customer,
          })
          
          if (product && customer) {
            await req.payload.sendEmail({
              to: 'admin@yoursite.com',
              subject: `New Inquiry - ${doc.orderNumber}`,
              html: `<h2>New inquiry received...</h2>`,
            })
          }
        }
      },
    ],
  },
}

This approach seems logical and follows Payload's hook documentation. However, when I tested it with server actions, the hook never executed. No console logs appeared, no emails were sent, and no errors were thrown. The order was created successfully, but the email logic was completely ignored.

The issue lies in how server actions interact with Payload's hook system. When you create documents through server actions, the execution context differs from standard Payload admin operations, causing afterChange hooks to behave unreliably or not execute at all.

The Server Action Solution

After discovering the hook limitation, I moved the email logic directly into the server action. This approach is more reliable and gives you complete control over the email sending process:

// File: src/actions/order.ts
'use server'

import { z } from 'zod'
import { getPayloadClient } from '@/lib/payload'

const OrderSchema = z.object({
  productId: z.string().min(1),
  quantity: z.coerce.number().int().min(1),
  firstName: z.string().min(1),
  lastName: z.string().min(1),
  email: z.string().email(),
  phone: z.string().optional(),
  streetAddress: z.string().min(1),
  postalCode: z.string().min(3).regex(/^\d{3,5}$/),
  town: z.string().min(1),
  message: z.string().optional(),
})

export async function submitOrder(prevState: any, formData: FormData): Promise<any> {
  // Validate form data
  const validatedFields = OrderSchema.safeParse({
    productId: formData.get('productId'),
    quantity: formData.get('quantity'),
    firstName: formData.get('firstName'),
    lastName: formData.get('lastName'),
    email: formData.get('email'),
    phone: formData.get('phone') || undefined,
    streetAddress: formData.get('streetAddress'),
    postalCode: formData.get('postalCode'),
    town: formData.get('town'),
    message: formData.get('message') || undefined,
  })

  if (!validatedFields.success) {
    const fieldErrors: Record<string, string[]> = {}
    validatedFields.error.issues.forEach((error) => {
      const fieldName = error.path[0] as string
      if (!fieldErrors[fieldName]) {
        fieldErrors[fieldName] = []
      }
      fieldErrors[fieldName].push(error.message)
    })

    return {
      success: false,
      message: 'Please correct the errors in the form.',
      errors: fieldErrors,
    }
  }
  
  const {
    productId,
    quantity,
    firstName,
    lastName,
    email,
    phone,
    streetAddress,
    postalCode,
    town,
    message
  } = validatedFields.data

  try {
    const payload = await getPayloadClient()

    // Verify product exists
    const product = await payload.findByID({
      collection: 'products',
      id: productId,
    })

    if (!product) {
      return {
        success: false,
        message: 'Product not found.',
        errors: {},
      }
    }

    // Create the order
    const newOrder = await payload.create({
      collection: 'orders',
      data: {
        product: parseInt(productId, 10),
        quantity: quantity,
        customerData: {
          firstName,
          lastName,
          email,
          phone,
          streetAddress,
          postalCode,
          town,
          message,
        },
        customerMessage: message,
        source: 'website',
        status: 'pending',
      } as any,
    })

    console.log('Order created successfully:', newOrder.orderNumber)

    // Send email notifications
    try {
      // Get customer details for emails
      const customerId = typeof newOrder.customer === 'object' ? newOrder.customer.id : newOrder.customer
      const customer = await payload.findByID({
        collection: 'customers',
        id: customerId,
      })

      if (product && customer) {
        console.log('Sending email notifications...')
        
        // Send admin notification email
        await payload.sendEmail({
          to: 'admin@yoursite.com',
          bcc: 'dev@yoursite.com',
          replyTo: customer.email,
          subject: `New Inquiry - ${newOrder.orderNumber}`,
          html: `
            <h2>New Product Inquiry</h2>
            <p><strong>Order Number:</strong> ${newOrder.orderNumber}</p>
            
            <h3>Customer Details:</h3>
            <ul>
              <li><strong>Name:</strong> ${customer.firstName} ${customer.lastName}</li>
              <li><strong>Email:</strong> ${customer.email}</li>
              <li><strong>Phone:</strong> ${customer.phone || 'Not provided'}</li>
              <li><strong>Address:</strong> ${customer.address?.streetAddress}, ${customer.address?.postalCode} ${customer.address?.town}</li>
            </ul>
            
            <div style="margin: 20px 0;">
              <a href="tel:${customer.phone}" style="display: inline-block; background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; font-weight: bold;">
                📞 CALL CUSTOMER
              </a>
            </div>
            
            <h3>Product Details:</h3>
            <ul>
              <li><strong>Product:</strong> ${product.title}</li>
              <li><strong>SKU:</strong> ${product.sku || 'Not available'}</li>
              <li><strong>Quantity:</strong> ${quantity}</li>
              <li><strong>Unit Price:</strong> ${product.price ? `€${product.price}` : 'Not available'}</li>
              <li><strong>Total:</strong> ${newOrder.total ? `€${newOrder.total}` : 'Not available'}</li>
              <li><strong>Product URL:</strong> <a href="${process.env.NEXT_PUBLIC_SERVER_URL}/products/${product.slug}" target="_blank">${process.env.NEXT_PUBLIC_SERVER_URL}/products/${product.slug}</a></li>
            </ul>
            
            ${message ? `
              <h3>Customer Message:</h3>
              <p>${message}</p>
            ` : ''}
            
            <p>You can review this inquiry in the admin panel.</p>
          `,
        })

        // Send customer confirmation email
        await payload.sendEmail({
          to: customer.email,
          replyTo: 'admin@yoursite.com',
          subject: `Inquiry Confirmation - ${newOrder.orderNumber}`,
          html: `
            <h2>Thank you for your inquiry!</h2>
            <p>Your inquiry has been successfully received.</p>
            
            <h3>Inquiry Details:</h3>
            <ul>
              <li><strong>Number:</strong> ${newOrder.orderNumber}</li>
              <li><strong>Product:</strong> ${product.title}</li>
              <li><strong>Quantity:</strong> ${quantity}</li>
            </ul>
            
            <p>We will contact you shortly with additional information and a quote.</p>
            
            <p>For any questions, you can contact us at admin@yoursite.com or +1-234-567-8900.</p>
            
            <p>Best regards,<br>Your Team</p>
          `,
        })
        
        console.log('Email notifications sent successfully')
      } else {
        console.error('Missing product or customer data for emails')
      }
    } catch (emailError) {
      console.error('Error sending email notifications:', emailError)
      // Don't fail the entire operation if emails fail
    }

    return {
      success: true,
      message: 'Your inquiry has been successfully submitted!',
      orderId: newOrder.id,
      orderNumber: newOrder.orderNumber,
      errors: {},
    }

  } catch (error) {
    console.error('Error creating order:', error)
    
    return {
      success: false,
      message: 'There was an error submitting your inquiry. Please try again.',
      errors: {},
    }
  }
}

This server action approach provides several advantages over the hook method. The email sending happens in the same execution context as the order creation, ensuring reliable delivery. You have complete control over error handling, and you can see console output for debugging. The payload.sendEmail() method leverages your configured email adapter seamlessly.

Notice how I handle the customer ID extraction with typeof newOrder.customer === 'object' ? newOrder.customer.id : newOrder.customer. This accounts for Payload sometimes returning populated relationships as objects rather than just IDs.

Key Benefits of This Approach

Moving email logic to server actions instead of collection hooks solved multiple problems I encountered. The execution is predictable and reliable, making debugging much easier when issues arise. Error handling becomes straightforward since you can catch and respond to email failures without affecting the core order creation process.

The email templates can be as complex as needed, including styled HTML, multiple recipients, and dynamic content based on the order data. Using Payload's native sendEmail() method means you automatically get the benefits of your configured SMTP provider without managing transport connections directly.

Conclusion

When implementing email notifications in Payload CMS, the native email plugin combined with server actions provides the most reliable approach. While afterChange hooks seem like the obvious choice for post-creation tasks, they don't execute consistently in server action contexts, leading to frustrating silent failures.

By moving your email logic directly into server actions, you gain complete control over the notification process while still leveraging Payload's powerful email configuration system. Your inquiry forms will reliably send notifications to both administrators and customers, creating a seamless user experience.

The next time you need to send emails after user actions in Payload CMS, skip the hooks and implement the logic directly in your server actions. Your future self will thank you when the emails actually get delivered.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in