npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

payload-order-emails

v1.0.1

Published

Transactional email on Payload order creation and status changes, with overridable templates and customer or admin recipients.

Readme

payload-order-emails

npm node license payload

Sends a transactional email when a Payload order is created and on every status change after that, through the email adapter your Payload config already carries, so that a customer who pays receives something.

  • Works with @payloadcms/plugin-ecommerce and with any collection that holds orders
  • No runtime dependencies, and no mail library of its own
  • No admin components, so it survives minor releases
  • No fields and no collections added to your database

Install

Requires Payload 3.88 or newer and @payloadcms/plugin-ecommerce 3.88 or newer. Verified against Payload 3.88.0 with the official plugin installed.

pnpm add payload-order-emails
import { orderEmailsPlugin } from 'payload-order-emails'

export default buildConfig({
  email: nodemailerAdapter({ defaultFromAddress: '[email protected]', defaultFromName: 'Shop' }),
  plugins: [
    orderEmailsPlugin({
      adminEmail: '[email protected]',
    }),
  ],
})

Sending goes through payload.sendEmail, so whichever adapter you configure under email is the one used. Payload's own fallback adapter only writes the attempt to the log, which is what you get if you configure none.

What was measured

Measured on 19 August 2026 against the contents of @payloadcms/[email protected].

The official plugin sends no email. Searching its whole dist/ for sendEmail, sendMail, nodemailer and transporter returns nothing. Payload ships @payloadcms/email-nodemailer and exposes payload.sendEmail, and the ecommerce plugin never calls it. A customer pays, confirmOrder writes the order, and nothing is sent to anyone.

An order carries four statuses. From dist/collections/orders/createOrdersCollection.js, the status field is a select over processing, completed, cancelled and refunded, defaulting to processing. This package keys its events on exactly those values, plus created.

An order does not always carry a customer email. In dist/payments/adapters/stripe/confirmOrder.js the order is created with customer when a user is logged in, and with customerEmail only when there is no user:

...req.user ? { customer: req.user.id } : { customerEmail },

So an order placed by a logged-in customer has an empty customerEmail. The plugin therefore reads the customer record to find the address, and does so on the same req so that the read stays inside the transaction of the order.

The amount is held in the minor unit. dist/ui/utilities.js divides by 10 ** currency.decimals to display a price, and the three shipped currencies declare decimals: 2. An order of 25 euro is stored as 2500, which is why the amount is formatted rather than printed raw.

How events are decided

The plugin adds one afterChange hook to the orders collection.

| Situation | Event fired | | --- | --- | | operation is create | created | | operation is update and previousDoc.status differs from doc.status | the new status | | Any other update | none |

A newly created order holds processing, but only created fires for it. processing fires only when an order moves back to that status, and is disabled by default.

A status the plugin does not know about, such as one added by another plugin, uses defaultEvent, which is disabled until you enable it:

orderEmailsPlugin({ events: { shipped: true } })

Options

| Option | Default | Meaning | | --- | --- | --- | | adminEmail | [] | Address or list of addresses receiving the admin copy. Without one, no admin copy is sent | | currencyDecimals | 2 | Decimals of the minor unit, as one number or a map keyed by currency code, such as { JPY: 0 } | | customersSlug | 'users' | Collection read to resolve a missing customer email | | defaultEvent | disabled, 'customer' | Applied to a status with no entry in events | | disabled | false | Stops the plugin sending or logging anything | | dryRun | false | Resolves every message and logs it, without handing it to the adapter | | events | see below | Per event configuration, keyed by created or by the status moved to | | from | unset | From address. Without one, the address of your email adapter is used | | orderNumberField | 'orderNumber' | Field the order number is read from | | ordersSlug | 'orders' | Slug of the orders collection | | replyTo | unset | Reply-To address. Omitted when not set | | statusField | 'status' | Field the order status is read from |

Events and their defaults

| Event | Enabled | Recipients | | --- | --- | --- | | created | yes | both | | completed | yes | customer | | cancelled | yes | both | | refunded | yes | both | | processing | no | customer | | any other status | no | customer |

recipients is one of customer, admin, both or none. Each event also takes template for the customer copy and adminTemplate for the admin copy. true and false are accepted as shorthand:

orderEmailsPlugin({
  adminEmail: ['[email protected]', '[email protected]'],
  events: {
    cancelled: false,
    completed: { recipients: 'both' },
    refunded: { template: { subject: 'Refund for {{reference}}', text: '{{amountFormatted}}' } },
  },
})

A value that cannot be used is replaced by its default rather than applied. An unrecognised recipients falls back to the default for that event, a negative currencyDecimals falls back to 2, and an empty slug or field name falls back to its documented name.

Templates

A template is either a set of strings carrying {{placeholder}} tokens, or a function receiving the whole context:

import { defaultTemplates, orderEmailsPlugin } from 'payload-order-emails'

orderEmailsPlugin({
  events: {
    created: {
      adminTemplate: defaultTemplates.created.admin,
      template: (context) => ({
        subject: `Order ${context.reference}`,
        text: `Total ${context.amountFormatted}`,
        html: `<p>Total ${context.amountFormatted}</p>`,
      }),
    },
  },
})

The defaults are plain text and carry no shop name, no markup and no wording that assumes one. html is sent only when a template supplies it.

Placeholders

| Token | Value | | --- | --- | | {{reference}} | The order number when there is one, otherwise the id | | {{id}} | Order document id | | {{orderNumber}} | Value of orderNumberField, empty when the order has none | | {{status}} | Status after the change | | {{previousStatus}} | Status before the change, empty on creation | | {{customerEmail}} | Address the customer copy is sent to | | {{amount}} | Amount as stored, in the minor unit | | {{amountFormatted}} | Amount as 25.00 EUR | | {{currency}} | Uppercase currency code | | {{event}} | Name of the event | | {{recipient}} | customer or admin |

A token with no matching value is left in place, so a mistyped name shows itself instead of rendering blank. A value the order does not carry renders as an empty string.

If payload-order-numbers is installed, its orderNumber is picked up automatically and {{reference}} uses it. That package is not a dependency, and nothing here requires it.

What it adds to your database

Nothing. No collection, no field, no index, no migration. The plugin appends one afterChange hook to your orders collection and reads the customer record when it needs an address.

Honest limits

Sending is awaited inside the request. Payload awaits afterChange hooks, so a slow SMTP server slows the write that triggered it. Detaching the send would drop messages when the process exits, which is worse. Use an adapter that hands off quickly, or a provider API rather than raw SMTP.

A failed send is not retried. Every failure is caught, logged through payload.logger.error, and the order saves regardless. There is no queue and no second attempt. Losing a receipt is worse than losing the sale, but a delivery guarantee is a different piece of work.

Delivery is only as good as your adapter. This package composes a message and hands it to payload.sendEmail. Whether it arrives, and whether it lands in a spam folder, is your adapter, your domain and your SPF, DKIM and DMARC records. With no adapter configured, Payload logs the attempt and sends nothing.

Only status changes are detected. A change to any other field sends nothing, by design. A status changed directly in the database, outside Payload, runs no hook and sends nothing.

Nothing records what was sent. There is no send log on the order, so an order updated twice to the same status in two separate writes sends once, but an order moved completed then processing then completed sends twice. If you need transition history, that belongs in a package about transitions.

The amount format is fixed. {{amountFormatted}} is always the decimal value, a space, and the currency code. It is not localised and uses no Intl formatting. A function template receives the raw amount and currency and can format them however you like.

License

MIT. Copyright George Vasiliades, https://github.com/Poseidonas