useSend email notification provider for Medusa 2, with React email templates, attachments, retries and rate limiting
Send Medusa email notifications through useSend, the open-source email platform formerly called Unsend. Write your emails as React components, and the plugin renders and sends them.
| Plugin | Medusa |
|---|---|
| 2.x | 2.22 or newer |
| 1.1.x | 2.19 – 2.21 |
| 1.0.8 | 2.4 – 2.18 |
| 0.2.4 | 2.3.1 and older |
Node.js 22.22 or newer is required, the same as Medusa 2.22.
Or with Yarn:
Add your useSend credentials to :
Register the plugin and the notification provider in :
Add a template to (see Email templates) and send it from a subscriber or workflow (see Sending emails).
The provider path is also exported as a constant, if you prefer it over the string:
| Option | Type | Default | Description |
|---|---|---|---|
| required | useSend API key. Not needed with or . | ||
| required | Default sender, e.g. . | ||
| API URL. Set it to your instance's URL when you self-host useSend. | |||
| Folder the email templates are loaded from. | |||
| Attempts per email, including the first one. | |||
| Delay before the first retry in ms. | |||
| doubles the delay on every retry, adds it. | |||
| Maximum emails sent per minute. Further emails wait. | |||
| none | Locale used when a notification has none, see Locales. | ||
| Log emails instead of sending them. | |||
| none | Send every email to this Mailpit instead, see below. | ||
| none | Send every email to this address instead. | ||
| none | Signing secret that turns on delivery webhooks. | ||
| none | Default contact book for contacts. | ||
| none | Overrides of any option above per , see below. |
The options are validated at startup, and an invalid configuration stops the server with a message listing every problem.
takes , and keys. The overrides of the current are merged over the top-level options:
At startup the plugin loads every and file in the template folder. Each file's default export is a React component that receives the notification's as props:
The subject is taken from the first of these that is set:
A JSON file with the same name next to the template is optional:
Add a locale to the file name for a translated variant: is the Slovenian , and holds its metadata, on top of .
The locale is read from , or else , of the notification. The plugin uses the first variant that exists:
The admin page fills a template with sample data. Set it as a static on the component, the same as React Email does, or as in the metadata file:
A broken template doesn't keep the others from loading: the plugin logs which file failed and why.
Send an email through Medusa's notification module, with the template name and the data for its props:
Every email has an HTML and a plain-text part. The of the notification overrides the template: and replace what the template renders, and an email with and needs no template at all.
Pass options for one email in :
, and take an address or a list of addresses. Invalid fails the notification without sending it.
To send a template you edit in the useSend dashboard instead of a file, pass its id and variables. Nothing is rendered locally, and the subject comes from the useSend template unless is set:
The plugin's service is registered in the container as . Use it to register templates in code instead of files, or to check that a template exists:
Pass attachments in the field of . Each one needs a and base64-encoded :
useSend only accepts and , so other fields of Medusa's attachment (, , ) are ignored. Attachments in are still read, for older code.
Three options keep test emails away from real customers. All of them can be set per environment:
Mailpit catches the emails in a web inbox, where you can read the HTML and plain text and open the attachments. Run it next to your database, e.g. in :
Then set for local development. The plugin sends through Mailpit's HTTP API, so no useSend API key is needed, and the inbox is at :
Each email is tagged with its template name. A useSend dashboard template can't be rendered locally, so it arrives as text with the template id and variables.
Keep a local Mailpit bound to localhost, because it accepts email from anyone who can reach it. To use a Mailpit on a server, for example for staging, protect it with logins: for the inbox, and for a login that can only send. Then put the send login in the URL:
MAILPIT_URL=https://sender:s3cret%24pass@mailpit.example.comThe plugin sends it as a Basic auth header, and the admin page shows the URL without it. Percent-encode special characters in the login: Medusa's expands , so write as .
The plugin turns useSend webhook calls into Medusa events, so you can react to bounces, complaints, opens and clicks.
Every call is verified and emitted as , e.g. . The event names are exported as :
The event data holds , , , (the Medusa notification that sent the email, when there is one), , and the raw useSend . useSend can deliver a call more than once, so use to skip duplicates.
Add the people who sign up for your newsletter to a useSend contact book, so you can send them campaigns from useSend. The plugin doesn't sync customers on its own, because Medusa has no newsletter consent; call it from your own sign-up flow:
creates the contact, or updates the one with the same email, in the contact book of the option, or the one passed as the second argument. removes one.
Settings → useSend in the Medusa admin lists the loaded templates and shows whether the plugin is live, in dry run or redirecting. For each template you can edit the sample data, pick a locale, preview the HTML and plain text, and send a test to yourself. Test emails go through the notification module, so they show up in the notification log and follow and .
Unsend was renamed to useSend, and this plugin was renamed with it in 2.0.0. Your templates and options don't change.
Swap the package:
Rename in your code and :
| 1.x () | 2.x () |
|---|---|
| , , | , , |
Optionally rename the provider from to . Medusa stores the provider id on each sent notification, so keep if you rely on filtering past notifications by provider.
Optionally rename your environment variables to . The plugin only reads the values you pass in its options.
useSend can run on your own server; it needs PostgreSQL, Redis and AWS SES. Follow the self-hosting guide, then set to your instance, e.g. .
See CHANGELOG.md and the GitHub releases.
Bug reports, documentation fixes and pull requests are welcome. See CONTRIBUTING.md for the development setup and the code of conduct.
Please report vulnerabilities privately, as described in SECURITY.md.
MIT © Rok Mohar
npm install @rokmohar/medusa-plugin-usesendyarn add @rokmohar/medusa-plugin-usesend1USESEND_API_KEY=us_...2USESEND_FROM=Shop <no-reply@example.com>3# Only for self-hosted useSend; useSend Cloud is the default4USESEND_URL=1import { defineConfig, loadEnv } from '@medusajs/framework/utils'2import type { UseSendEmailOptions } from '@rokmohar/medusa-plugin-usesend'3
4loadEnv(process.env.NODE_ENV || 'development', process.cwd())5
6module.exports = defineConfig({7 // ... other config8 plugins: [9 {10 resolve: '@rokmohar/medusa-plugin-usesend',11 options: {12 url: process.env.USESEND_URL,13 api_key: process.env.USESEND_API_KEY ?? '',14 from: process.env.USESEND_FROM ?? '',15 } satisfies UseSendEmailOptions,16 },17 ],18 modules: [19 {20 resolve: '@medusajs/medusa/notification',21 dependencies: ['usesend'],22 options: {23 providers: [24 {25 resolve: '@rokmohar/medusa-plugin-usesend/providers/notification',26 id: 'usesend',27 options: {28 channels: ['email'],29 },30 },31 ],32 },33 },34 ],35})1import { USESEND_PROVIDER_PATH } from '@rokmohar/medusa-plugin-usesend'2
3// ...4{5 resolve: USESEND_PROVIDER_PATH,6 id: 'usesend',7 options: { channels: ['email'] },8}1options: {2 api_key: process.env.USESEND_API_KEY ?? '',3 from: 'Shop <no-reply@example.com>',4 environment: {5 development: {6 from: 'Shop (dev) <dev@example.com>',7 rateLimit: { maxPerMinute: 10 },8 },9 production: {10 retry: { maxAttempts: 5, delay: 2000 },11 },12 },13}1// src/templates/emails/OrderPlaced.tsx2type Props = {3 display_id: number4 customer_name: string5}6
7const OrderPlaced = ({ display_id, customer_name }: Props) => {8 return (9 <div>10 <h1>Thanks for your order, {customer_name}!</h1>11 <p>Your order #{display_id} has been received.</p>12 </div>13 )14}15
16OrderPlaced.Subject = 'Your order has been received'17
18export default OrderPlaced1// src/templates/emails/OrderPlaced.json2{3 "version": "1.0.0",4 "subject": "Your order has been received",5 "description": "Sent to the customer after checkout",6 "tags": ["order", "customer"],7 "category": "orders"8}1OrderPlaced.PreviewProps = {2 display_id: 1001,3 customer_name: 'Ana',4}1// src/subscribers/order-placed.ts2import type { SubscriberArgs, SubscriberConfig } from '@medusajs/framework'3import { Modules } from '@medusajs/framework/utils'4
5export default async function orderPlacedHandler({ event: { data }, container }: SubscriberArgs<{ id: string }>) {6 const query = container.resolve('query')7 const notificationModuleService = container.resolve(Modules.NOTIFICATION)8
9 const {10 data: [order],11 } = await query.graph({12 entity: 'order',13 fields: ['display_id', 'email', 'shipping_address.first_name'],14 filters: { id: data.id },15 })16
17 await notificationModuleService.createNotifications({18 to: order.email,19 channel: 'email',20 template: 'order-placed',21 data: {22 display_id: order.display_id,23 customer_name: order.shipping_address?.first_name,24 },25 })26}27
28export const config: SubscriberConfig = {29 event: 'order.placed',30}1await notificationModuleService.createNotifications({2 to: order.email,3 channel: 'email',4 template: 'order-placed',5 data: { display_id: order.display_id },6 provider_data: {7 replyTo: 'support@example.com',8 bcc: 'orders@example.com',9 headers: { 'X-Entity-Ref-ID': order.id },10 scheduledAt: '2026-11-01T09:00:00Z',11 locale: 'sl',12 },13})1await notificationModuleService.createNotifications({2 to: customer.email,3 channel: 'email',4 template: 'welcome',5 provider_data: {6 templateId: 'tmpl_123',7 variables: { firstName: customer.first_name },8 },9})1import type { UseSendService } from '@rokmohar/medusa-plugin-usesend'2
3const usesend = container.resolve<UseSendService>('usesend')4
5usesend.addTemplate('welcome', {6 subject: 'Welcome!',7 content: { html: '<p>Welcome to our shop.</p>' },8})1await notificationModuleService.createNotifications({2 to: 'customer@example.com',3 channel: 'email',4 template: 'invoice',5 attachments: [6 {7 filename: 'invoice-42.pdf',8 content: pdfBuffer.toString('base64'),9 },10 ],11})1options: {2 api_key: process.env.USESEND_API_KEY ?? '',3 from: 'Shop <no-reply@example.com>',4 environment: {5 development: { dryRun: true },6 staging: { redirectTo: 'qa@example.com' },7 },8}1services:2 mailpit:3 image: axllent/mailpit:latest4 ports:5 - '127.0.0.1:8025:8025'1options: {2 api_key: process.env.USESEND_API_KEY,3 from: 'Shop <no-reply@example.com>',4 mailpitUrl: process.env.MAILPIT_URL, // http://localhost:8025 in your local .env5}1// src/subscribers/email-bounced.ts2import type { SubscriberArgs, SubscriberConfig } from '@medusajs/framework'3import { USESEND_WEBHOOK_EVENTS, type UseSendWebhookEventData } from '@rokmohar/medusa-plugin-usesend'4
5export default async function emailBounced({ event, container }: SubscriberArgs<UseSendWebhookEventData>) {6 const { notification_id, to, data } = event.data7
8 container.resolve('logger').warn(`Email ${notification_id} to ${to.join(', ')} bounced: ${JSON.stringify(data)}`)9}10
11export const config: SubscriberConfig = {12 event: USESEND_WEBHOOK_EVENTS.EMAIL_BOUNCED,13}1const usesend = container.resolve<UseSendService>('usesend')2
3await usesend.upsertContact({4 email: 'ana@example.com',5 firstName: 'Ana',6 subscribed: true,7 properties: { locale: 'sl' },8})1npm uninstall @rokmohar/medusa-plugin-unsend2npm install @rokmohar/medusa-plugin-usesend