medusa-plugin-postmark
Notifications plugin for Medusa ecommerce server that sends transactional emails via PostMark.
Features
- Uses the email templating features built into Postmark
- You can import/use tools like stripo.email
- The plugin is in active development. If you have any feature requests, please open an issue.
- Create PDF invoices and credit notes and attach them to the email
- Send out upsell emails to customers that have recently placed an order with certain collections
- Send out automated abandoned cart emails to customers that have abandoned their cart (based on last updated date of cart)
Configuration
Enable in your medusa-config.js file similar to other plugins:
More events? (work in progress within the plugin!) See here
const plugins = [
// ... other plugins
{
resolve: `medusa-plugin-postmark`,
options: {
server_api: process.env.POSTMARK_SERVER_API,
from: process.env.POSTMARK_FROM,
bcc: process.env.POSTMARK_BCC || null,
pdf: {
enabled: process.env.POSTMARK_PDF_ENABLED || false,
settings: {
font: process.env.POSTMARK_PDF_FONT || 'Helvetica',
// [{file: 'yourfont.ttf', name: 'yourfont'},{file: 'yourfont-bold.ttf', name: 'yourfontbold'}]
format: process.env.POSTMARK_PDF_FORMAT || 'A4',
// see supported formats here: https://pdfkit.org/docs/paper_sizes.html
margin: {
top: process.env.POSTMARK_PDF_MARGIN_TOP || '50',
right: process.env.POSTMARK_PDF_MARGIN_RIGHT || '50',
bottom: process.env.POSTMARK_PDF_MARGIN_BOTTOM || '50',
left: process.env.POSTMARK_PDF_MARGIN_LEFT || '50'
},
empty: "" // what to show if variable can't be found. Defaults to __UNDEFINED__
},
header: {
enabled: process.env.POSTMARK_PDF_HEADER_ENABLED || false,
content: process.env.POSTMARK_PDF_HEADER_CONTENT || null,
// loads empty header if null, otherwise loads the file from `POSTMARK_PDF_HEADER_CONTENT`
height: process.env.POSTMARK_PDF_HEADER_HEIGHT || '50'
},
footer: {
enabled: process.env.POSTMARK_PDF_FOOTER_ENABLED || false,
content: process.env.POSTMARK_PDF_FOOTER_CONTENT || null,
// loads empty footer if null, otherwise loads the file from `POSTMARK_PDF_FOOTER_CONTENT`
},
templates: {
invoice: process.env.POSTMARK_PDF_INVOICE_TEMPLATE || null,
credit_note: process.env.POSTMARK_PDF_CREDIT_NOTE_TEMPLATE || null,
return_invoice: process.env.POSTMARK_PDF_RETURN_INVOICE_TEMPLATE || null
}
},
events: {
order: {
placed: process.env.POSTMARK_ORDER_PLACED || null,
canceled: process.env.POSTMARK_ORDER_CANCELED || null,
shipment_created: process.env.POSTMARK_ORDER_SHIPMENT_CREATED || null,
},
customer: {
created: process.env.POSTMARK_CUSTOMER_CREATED || null,
password_reset: process.env.POSTMARK_CUSTOMER_PASSWORD_RESET || null,
},
user: {
created: process.env.POSTMARK_USER_CREATED || null,
password_reset: process.env.POSTMARK_USER_PASSWORD_RESET || null,
},
auth: {
password_reset: process.env.POSTMARK_AUTH_PASSWORD_RESET || null,
verify_account: process.env.POSTMARK_AUTH_VERIFY_ACCOUNT || null,
},
activity: {
inactive_user: process.env.POSTMARK_ACTIVITY_INACTIVE_USER || null,
inactive_customer: process.env.POSTMARK_ACTIVITY_INACTIVE_CUSTOMER || null,
}
},
upsell: {
enabled: process.env.POSTMARK_UPSELL_ENABLED || false,
template: process.env.POSTMARK_UPSELL_TEMPLATE || null, // if you supply multiple templates (comma seperated), the plugin will pick one at random
delay: process.env.POSTMARK_UPSELL_DELAY || 9, // delay in days
valid: process.env.POSTMARK_UPSELL_VALID || 30, // valid in days
collection: process.env.POSTMARK_UPSELL_COLLECTION || null,
},
abandoned_cart: {
enabled: process.env.POSTMARK_ABANDONED_CART_ENABLED || false,
first: {
delay: process.env.POSTMARK_ABANDONED_CART_FIRST_DELAY || 1, // delay in hours
template: process.env.POSTMARK_ABANDONED_CART_FIRST_TEMPLATE || null, // if you supply multiple templates (comma seperated), the plugin will pick one at random
},
second: {
delay: process.env.POSTMARK_ABANDONED_CART_SECOND_DELAY || 24, // delay in hours
template: process.env.POSTMARK_ABANDONED_CART_SECOND_TEMPLATE || null, // if you supply multiple templates (comma seperated), the plugin will pick one at random
},
third: {
delay: process.env.POSTMARK_ABANDONED_CART_THIRD_DELAY || 48, // delay in hours
template: process.env.POSTMARK_ABANDONED_CART_THIRD_TEMPLATE || null, // if you supply multiple templates (comma seperated), the plugin will pick one at random
},
},
default_data: {
// ... default data to be passed to the email template
product_url: process.env.POSTMARK_PRODUCT_URL || '',
product_name: process.env.POSTMARK_PRODUCT_NAME || '',
company_name: process.env.POSTMARK_COMPANY_NAME || '',
company_address: process.env.POSTMARK_COMPANY_ADDRESS || '',
}
}
}
]
Events
Events can be specified statically, as they are in the above example, or they can be specified dynamically using an async
function.
For example, if you have an entity tracking email templates created in Postmark, you may use something like this:
// get-event-mappings.js
export async function getEventMappings() {
const emailTemplateRepo = getRepository(EmailTemplate);
const templates = await emailTemplateRepo.find();
const mappings = {};
for (const template of templates) {
if (template.event) {
mappings[template.event] = template.id;
}
}
return mappings;
}
import { getEventMappings } from './get-event-mappings';
{
// ...
events: getEventMappings,
}
Templates
The plugin uses the Postmark template system for emails. For attachments the plugin relies on the pdfkit library.
In your JSON templates you can use several types (and variables):
image
for (local) imagestext
for simple words, (long) sentences, paragraphs and linksmoveDown
for moving the cursor down one linehr
for a horizontal linetableRow
for a table(-like) rowitemLoop
for looping over items in an orderitemLoopEnd
for ending the item loop
Example:
[
{
"type": "image",
"image": "image.png",
"x": 100,
"y": 100,
"fit": [200, 50]
},
{
"type": "text",
"text": "This is a text",
"size": 20
},
{
"type": "moveDown",
"lines": 2
},
{
"type": "hr"
},
{
"type": "moveDown",
"lines": 2
},
{
"type": "text",
"text": "Another text"
}
]
image
Images are stored in /src/images/
and can be used in the template like this:
{
"type": "image",
"image": "image.png",
"x": 100,
"y": 100,
"fit": [200, 50]
}
fit
has multiple options, see here for more info.
Optional:
align
horizontally align the image, the possible values areleft
,center
, orright
valign
vertically align the image, the possible values aretop
,center
, orbottom
text
Text can be used for words, sentences, paragraphs and links.
{
"type": "text",
"text": "This is a text"
}
If you use moveDown
correct you won't need to use x
and y
for the text.
Optional:
These options can be used to style the text or to position it.
x
the x position of the texty
the y position of the textfont
the font of the textsize
the font size of the textcolor
the color of the text (Hex codes#ff0000
)width
the width of the textalign
the alignment of the text, the possible values areleft
,center
,right
, orjustify
.
For more styling options, see here for more info.
moveDown
This is used to move the cursor down one or more line(s).
{
"type": "moveDown",
"lines": 1
}
hr
This is used to draw a horizontal line.
{
"type": "hr"
}
Optional:
color
the color of the line (Hex codes#ff0000
)width
the width of the line if you don't want it to be the full width of the pageheight
the height of the line element, including paddingy
the y position of the line if you can not rely on the cursor (affected bymoveDown
)
tableRow
This is used to draw a table row.
{
"type": "tableRow",
"columns": [
{
"text": "Column 1",
"width": 200
},
{
"text": "Column 2",
"width": 150
}
]
}
Optional:
You can use the same options as for text
to style the text in the table row. If you want a special column styled, you can add the options to the column object.
itemLoop
This is used to start the loop of items in an order.
To access item variables, use the item
object, for example {{ item.title }}
.
{
"type": "itemLoop"
}
itemLoopEnd
This is used to end the loop of items in an order.
{
"type": "itemLoopEnd"
}
Variables
In the template you can use variables. These are replaced by the plugin with the correct value.
To use a variable, use the following syntax: {{ variable_name }}
, for example {{ order.customer.first_name }}
.
Order item variables are available inside the itemLoop
and itemLoopEnd
elements, for example {{ item.title }}
.
If you want to include (simple) if statements, use the following syntax: {{ if variable_name }}...{{ endif }}
, or as a negative {{ if not variable_name }}...{{ endif }}
.
Possible variables depend on your notification system.
You can use the options
object and every template has his own data
object.
Depending on the plugin you use, (almost) every plugin that supports attachments based on medusa-plugin-sendgrid
has the same variable order
after the options
variable which holds all the plugin variables.
More information on the possible values that order
can have can be found here.
Variable functions
At the moment the only variable you can use functions with is dates and currency.
- Dates are formatted using the
toLocaleDateString
function and can be used like this:{{ order.placed_at | date('en-US',{'year': 'numeric', 'month': 'long', 'day': 'numeric'}) }}
. - Currency is formatted using the
new Intl.NumberFormat()
function and can be used like this:{{ order.total_price | currency('en-US') }}
. - Country can be formatted from ISO to the full country name and can be used like this:
{{ order.shipping_address.country_code | country }}
.
Please make sure that the options are wrapped in single quotes.
Localisation
Want separate templates for different languages?
Alter medusa-config.js plugin options:
// medusa config including the postmark plugin
events: {
order: {
placed: { nl: 1234, en: 1235 },
// rest of the events...
The api key and templates are pulled from env variables.
POSTMARK_SERVER_API=""
POSTMARK_FROM=""
POSTMARK_ORDER_PLACED=1234
The POSTMARK_FROM
email address must be a verified sender in your Postmark account.
Default templates
We've created a few default templates (thanks to pdfkit invoice example) which can be altered to your needs:
header.json
[
{
"type": "text",
"text": "ACME Inc.",
"size": 20,
"color": "#444444"
},
{
"type": "text",
"text": "ACME Inc.",
"size": 10,
"color": "#444444",
"align": "right",
"x": 200
},
{
"type": "moveDown"
},
{
"type": "text",
"text": "123 Main Street",
"size": 10,
"color": "#444444",
"align": "right",
"x": 200
},
{
"type": "moveDown"
},
{
"type": "text",
"text": "New York, NY, 10025",
"size": 10,
"color": "#444444",
"align": "right",
"x": 200
}
]
createInvoice.json
[
{
"type": "text",
"text": "Invoice",
"size": 20,
"color": "#444444"
},
{
"type": "moveDown"
},
{
"type": "hr"
},
{
"type": "text",
"text": "Invoice Number:",
"size": 10
},
{
"type": "text",
"font": "Helvetica-Bold",
"text": "#{{ order.display_id }}",
"size": 10,
"x": 100
},
{
"type": "text",
"font": "Helvetica-Bold",
"text": "{{ order.shipping_address.first_name }} {{ order.shipping_address.last_name }}",
"size": 10,
"x": 300
},
{
"type": "moveDown"
},
{
"type": "text",
"font": "Helvetica",
"text": "Invoice Date:",
"size": 10
},
{
"type": "text",
"text": "{{ order.created_at | date('en-US',{'year': 'numeric', 'month': 'long', 'day': 'numeric'}) }}",
"size": 10,
"x": 100
},
{
"type": "text",
"text": "{{ order.shipping_address.address_1 }} {{ order.shipping_address.address_2 }}",
"size": 10,
"x": 300
},
{
"type": "moveDown"
},
{
"type": "text",
"text": "{{ order.shipping_address.postal_code }}, {{ order.shipping_address.city }}, {{ order.shipping_address.country_code }}",
"size": 10,
"x": 300
},
{
"type": "moveDown"
},
{
"type": "hr"
},
{
"type": "moveDown",
"lines": 1
},
{
"type": "tableRow",
"font": "Helvetica-Bold",
"columns": [
{
"text": "Item",
"width": 200
},
{
"text": "Quantity",
"width": 50
},
{
"text": "Price",
"width": 50
},
{
"text": "Total",
"width": 50
}
]
},
{
"type": "hr"
},
{
"type": "itemLoop"
},
{
"type": "tableRow",
"font": "Helvetica",
"columns": [
{
"text": "{{ item.title }}",
"width": 200
},
{
"text": "{{ item.quantity }}",
"width": 50
},
{
"text": "{{ item.unit_price | currency('en-US') }}",
"width": 50
},
{
"text": "{{ item.totals.total | currency('en-US') }}",
"width": 50
}
]
},
{
"type": "hr"
},
{
"type": "itemLoopEnd"
},
{
"type": "tableRow",
"columns": [
{
"text": "",
"width": 200
},
{
"text": "",
"width": 50
},
{
"text": "Subtotal",
"width": 50
},
{
"text": "{{ order.subtotal | currency('en-US') }}",
"width": 50
}
]
},
{
"type": "tableRow",
"columns": [
{
"text": "",
"width": 200
},
{
"text": "",
"width": 50
},
{
"text": "Shipping",
"width": 50
},
{
"text": "{{ order.shipping_total | currency('en-US') }}",
"width": 50
}
]
},
{
"type": "tableRow",
"columns": [
{
"text": "",
"width": 200
},
{
"text": "",
"width": 50
},
{
"text": "TAX",
"width": 50
},
{
"text": "{{ order.tax_total | currency('en-US') }}",
"width": 50
}
]
},
{
"type": "tableRow",
"font": "Helvetica-Bold",
"columns": [
{
"text": "",
"width": 200
},
{
"text": "",
"width": 50
},
{
"text": "Total",
"width": 50
},
{
"text": "{{ order.total | currency('en-US') }}",
"width": 50
}
]
}
]
footer.json
[
{
"type": "text",
"text": "Thank you for your business!",
"size": 10,
"color": "#444444",
"width": "full",
"align": "center"
}
]