payload-order-emails
v1.0.1
Published
Transactional email on Payload order creation and status changes, with overridable templates and customer or admin recipients.
Maintainers
Readme
payload-order-emails
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-ecommerceand 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-emailsimport { 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
