Skip to content

Powered by Grav CMS + Helios Theme

Emails

This page lists the emails KahunaCart sends and shows you how to enable, brand, customize and test them. It is for the store administrator.

Before you begin

  • Install and configure the Email plugin. Without it every email job throws Email plugin is not available and lands in the failed bucket.
  • Put Grav's scheduler in cron. Every email is queued rather than sent inline, and bin/plugin kahunacart work does the sending. See Troubleshooting.

The emails KahunaCart sends

KahunaCart sends sixteen emails. Every one is queued as a job, so nothing a customer or an administrator does waits on a mail server. The Notifications screen lists all sixteen, with the store copy beside them, each with its own on and off switch.

Email Kind Job type Sent when Templates
Order confirmation confirmation order.confirmation_email An order completes order-confirmation.*
Payment received payment-received order.payment_received_email An order awaiting payment becomes paid payment-received.*
Pay link pay-link order.pay_link_email A merchant or an add-on asks for it pay-link.*
Balance request balance-request order.balance_request_email A merchant presses Collect balance on a part-paid order, or the balance's reminder date arrives balance-request.*
Order link order-link order.link_email A customer presses the button on the expired-link page order-link.*
Shipped shipped order.shipped_email A shipment is recorded shipped.*
Refund refund order.refund_email A provider confirms a refund refund.*
Refund requested refund-requested order.refund_requested_email A customer asks for a refund from their receipt. We have your request, quoting the reason and their words back. refund-requested.*
Payment failed payment-failed order.payment_failed_email A payment attempt is refused: a card declined, a redirect that comes back failed, a webhook reporting it later. Nothing was charged, here is a button to pay again. payment-failed.*
Note to the customer customer-note order.customer_note_email A merchant writes a note and ticks Also email this customer-note.*
Pre-order released preorder-released order.preorder_released_email The worker, once per order a pre-order kept waiting, when the product's release date passes. It's out: your download is ready, with the released product's files and their links. Sent once, however many times the worker runs. It waits for the store's site address, like the sign-in link preorder-released.*
Quote quote order.quote_email A merchant offers an order as a quote, or chases one already offered quote.*
Gift card gift-card order.gift_card_email An order containing a gift card completes. One message per card gift-card.*
Abandoned cart — cart.abandoned_sweep A cart sits untouched past cart.abandoned_hours abandoned-cart.*
Abandoned-cart reminder — cart.abandoned_reminder_sweep A cart mailed recovery.reminder_hours ago still has not come back. Off by default abandoned-cart-reminder.*
Shared cart — cart.share_email A merchant shares a cart, or a customer asks for a fresh restore link cart-share.*
Set password — customer.set_password_email A customer opts into an account at checkout, or a merchant sends an invitation set-password.*
Sign-in link — customer.magic_link_email A customer asks for one at {base}/signin, or a merchant sends one from their record magic-link.*
Review request — reviews.request_sweep An order completed reviews.request_after_days ago. Off by default review-request.*

The kind is the short public name for an order email. The Send email… menu lists kinds, the resend route carries one in its path, and the email.queued and email.sent history entries record one. The six messages with no kind are not about one order's money: three are about a cart, two about a customer record, and one about the products on an order. The order-link email has a kind so its email.sent entry can say what left, but it is not on the menu and the resend route refuses it.

Abandoned-cart mail, its one reminder and review requests are all off by default. The set-password email is sent only when store accounts are enabled, and the sign-in link only while customers.magic_link_enabled is on. See Cart recovery.

The order confirmation is also BCC'd to the store when emails.store_copy_to is set. The other emails are not copied to the store.

Order links are signed and expire. A customer coming back to a receipt a month later gets a page offering to email them a fresh one, and pressing that button queues this message.

It goes to the address on the order, never to one typed into the form. The page answers identically whether or not the order hash is real, and it is limited to three requests an hour per address and per order. See Hardening → Rate limits.

Every link in the message is minted at send time, so the clock on them starts when it arrives. It carries the receipt, the invoice, and the pay page while there is still something to pay. See Hardening → Order links expire.

When the payment-received email is sent

An instant-capture provider completes an order that is already paid, so no payment-received email is sent. The confirmation has already said everything this email would.

The email covers the offline case: a bank transfer completes the order with the payment pending, a merchant marks it paid days later, and this email tells the customer the money arrived. A redirect provider whose webhook lands after the customer leaves produces one too.

When the refund email is sent

The refund email is queued when the provider confirms a refund, never when one is submitted. A refund still awaiting confirmation sends nothing. See Orders → Pending refunds.

The amount and the refund's own transaction id travel in the job payload, because an order can carry several refunds. The transaction id lets the message list the lines and the postage a per-line refund covered, and makes a resend repeat that same refund.

Issuing a refund with Notify customer unticked sends nothing, and the refund.succeeded history entry records quiet instead.

When the payment-failed email is sent

A failed payment attempt tells the customer as well as you. The message says nothing was charged, quotes the reason the provider gave when there is one safe to show, and carries one button to pay again. It is deduped per order over six hours, so a card retried three times in a minute is one email. What the worker finds when it picks the job up decides the rest: an order that has been paid in the meantime gets nothing at all, because the receipt already told them; an order that can no longer be paid gets the shop link instead of a button; and a payment that failed during checkout, while the order was still a cart, gets a pay link with no deadline on it, because a deadline stamped on a basket would lock the shopper out of their own cart once it passed.

The shipped email is per parcel

One message per shipment, not one per order. Two boxes going out a week apart are two emails.

Each message carries the carrier, the tracking number, a link to the carrier's tracking page, the lines in that parcel, and, when the order is only part shipped, what is still to come.

The job payload is ['order_id' => $id, 'shipment_id' => $shipmentId]. A payload with no shipment_id still renders: it falls back to every physical line on the order with no carrier and no tracking.

A note to the customer

A merchant writing a note on an order can tick Also email this to the customer. That queues this message immediately, carrying the note, and puts the note on the customer's receipt and in their account. See Orders → Order notes.

The note travels in the job payload, because an order accumulates notes and the message is about one of them. It is escaped and its line breaks kept, so anything pasted into the box arrives as text rather than markup.

Important

Sending is not recalling. Once the job runs the customer has the note, which is why deleting a note to the customer is refused.

The gift-card email is the only copy of the code

A store keeps a SHA-256 of a gift card's code and its last four characters. Nothing in KahunaCart can print a code back after issuing it. The code exists in this job's payload and in the buyer's inbox, and nowhere else.

  • The handler removes the code from the job payload as soon as the message has left. A completed job keeps its row so the queue can say what ran, but the row no longer holds the code.
  • The code is never written into the order's history. Every other order email stores its payload on the email.queued entry so it can be resent; the code is stripped out of what is stored.
  • A gift-card email cannot be resent. gift-card is absent from the Send email… menu and from the resend route.

One message per card. A customer who bought three cards for three people gets three emails, each with one code in it, so they can forward each one.

A customer who lost the email is helped by voiding their card and issuing a new one. See Store credit → Where the code lives.

A pay link says "this whole order is waiting for payment", which a customer who has already paid a deposit reads as the store having lost it. The balance request says what the order comes to, what they have already paid, any refund on its own Refunded to you line, and what is left, and asks for the remainder. See Deposits → Ask for the balance.

The link in both messages is the same signed pay link. It charges whatever is outstanding rather than the order total, so there is one payment route for a deposit and for a balance. What the balance request adds is the wording and the arithmetic.

Those three figures are read when the message renders rather than frozen into the job. A balance chased twice a week apart, with a part payment in between, asks for what is outstanding now.

A pay link says "this is waiting for payment". A quote says "here is what it would cost, and you have until the 14th to decide", and it points at the customer's order page where the Accept and Decline buttons are. Never send the pay-link email for a quote: it asks the customer to pay for something they have not agreed to.

Like the pay link, it is sent because somebody asked, and asking twice sends it twice. Sending it again does not move the quote's deadline. See Quotes.

The review request

Set reviews.request_after_days to a number of days and each customer is asked, that long after their order completed, how they got on with what they bought. It is 0 (off) by default.

One request per order, ever. An order qualifies when it is a placed, paid sale (checked by status flags, never by status names) that completed longer ago than the window, still has an address to write to, has not been anonymised, and holds at least one product nobody on it has already reviewed. The message names only those products, each linked to its own page. An order whose products have all been reviewed is stamped as asked without a message going out.

Like the abandoned-cart reminder this is a sweep on the worker tick rather than a queued job per order. It caps itself at 25 orders per tick, so a backlog drains over successive runs, and it costs one indexed query when there is nothing to send. The stamp goes on after each send, so a failed send is retried.

Its variables are its own, because it is about several products rather than one order's money.

Variable What it is
order The order row.
products The products still unreviewed, each with its title, slug and a site-relative url to its page.
customer_name The address the order was placed under.
site_name site.title, or empty.
site_url The absolute root of the site, no trailing slash.

See Reviews and ratings.

The messages that come to you are on the same screen

The To filter at the top of the Notifications screen switches between Customers — everything on this page — and Store owner, which holds about twenty messages about the store rather than about an order: a sale, a refund, a payment that failed, a webhook being refused, a dispute, a stalled queue, a note on an order. They have the same switch, and instead of a template they pick channels: your inbox, a Slack room, Telegram, your phone.

The store copy of the confirmation is one of them, and the one with no channels to pick: it is the customer's own confirmation with the Send Order Copies To address in the Bcc, so its row is a switch and says where the copy goes. Email is a channel like any other for the rest of them, so a store-owner message you tick Email on goes through this same pipeline: the same From address, the same email history, the same retries. See Notifications.

Each email sends once

Each email is queued at a transition that is already guarded against repeating: CheckoutService::complete() for the confirmation, CheckoutService::markPaid() for the payment-received email, the call that wrote a shipment for the shipped email, a transaction ledger whose markSuccess() returns false for a row that already succeeded for the refund email, one message per note written, and a stamp in the cart's data_json after an abandoned-cart send. A redelivered webhook or a double-clicked admin button queues nothing the second time. A job that throws is retried with exponential backoff, so a failed send is tried again and a successful one is never repeated.

The pay-link, balance-request and order-link emails are the exceptions, because none of them is tied to a transition. Each goes out because somebody asked for it, and asking twice sends it twice. Sending a pay link also issues the link: an order with no deadline of its own gets one, orders.pay_link_days from now, so the address in the message and the deadline the page enforces agree. An order that already carries a deadline keeps it.

Templates

Each email is a pair of Twig templates, HTML and plain text. Both are sent, and the recipient's mail client picks one. Every HTML template extends a shared layout and fills its content block.

TXT
templates/emails/kahunacart-email-layout.html.twig   the card every HTML email is drawn in
templates/emails/order-confirmation.html.twig    order-confirmation.txt.twig
templates/emails/payment-received.html.twig      payment-received.txt.twig
templates/emails/pay-link.html.twig              pay-link.txt.twig
templates/emails/balance-request.html.twig       balance-request.txt.twig
templates/emails/order-link.html.twig            order-link.txt.twig
templates/emails/shipped.html.twig               shipped.txt.twig
templates/emails/refund.html.twig                refund.txt.twig
templates/emails/customer-note.html.twig         customer-note.txt.twig
templates/emails/preorder-released.html.twig     preorder-released.txt.twig
templates/emails/quote.html.twig                 quote.txt.twig
templates/emails/gift-card.html.twig             gift-card.txt.twig
templates/emails/abandoned-cart.html.twig        abandoned-cart.txt.twig
templates/emails/abandoned-cart-reminder.html.twig  abandoned-cart-reminder.txt.twig
templates/emails/cart-share.html.twig            cart-share.txt.twig
templates/emails/set-password.html.twig          set-password.txt.twig
templates/emails/magic-link.html.twig            magic-link.txt.twig
templates/emails/review-request.html.twig        review-request.txt.twig

On a multilanguage site each order email renders in the language the order was placed in: subject line, both templates and the money formatting. The set-password email is the exception, because a customer record carries no language. See Multilanguage.

Point a message at a different template

The Notifications screen has a template picker on every message. Point Order confirmation at a pair of your own and that is the pair that renders.

Important

Before this release that picker did nothing: it stored your choice and the job ignored it. A choice you stored months ago takes effect as soon as you update.

Pointing a message at a template that has since been deleted falls back to the shipped pair rather than failing to send.

What a template is handed

A template gets the variables the sender put there, plus whatever the notification's own load_data step adds.

The step belongs to the message rather than to the job that sends it, so a template that needs a new variable is one definition to change. A step's variables are merged under the sender's, so a step can add to what a template gets but can never replace order with something of its own.

If a step errors it loses its own variables and nothing else. The failure is logged and the email still goes out with what the sender gave it.

None of KahunaCart's own messages needs a step today. Add-on authors register one alongside their template. See Notifications.

Variables in the order emails

Variable What it is
order The order row, including number, hash, currency and the status columns
items Its line items, each with title, qty, unit_price_minor, is_digital
adjustments Its adjustments: discounts, shipping, tax
total The order total, already formatted
site_name site.title, or empty
site_url The absolute root of the site, no trailing slash
panels Panels other plugins registered for this order. Absent from the abandoned-cart and set-password emails

The refund email adds three more: amount (this refund, formatted), amount_minor, and refunded_total (everything confirmed on the order so far).

The shipped email adds three of its own.

Variable What it is
shipment The parcel: id, carrier, carrier_label, tracking_number, tracking_url, note, shipped_at. Null on a job with no shipment_id
shipped_items The lines in this parcel, each with title, sku and quantity
remaining_items What is still to come, each with title, sku and the quantity outstanding. Empty once the order is fulfilled

remaining_items is read when the message renders, not when the parcel is written, so an order whose remainder shipped while the job waited says so.

Variables in the set-password email

Variable What it is
customer_name The customer's name, or empty
customer_email The address the account is keyed to, which is also their username
set_password_path The site-relative reset URL. Prefix it with site_url
expires Unix timestamp at which the link stops working
site_name site.title, or empty
site_url The absolute root of the site, no trailing slash

Every order email is handed an order_url that already carries its signature and its expiry. The pay-link email gets pay_url the same way, and the order-link email gets both invoice_url and pay_url.

Important

A template override must read order_url rather than building /shop/order/{hash} by concatenation. A link built by hand carries no signature, and the customer lands on the expired-link page.

TWIG
{%- set site_root = site_url|default('') ?: base_url_absolute -%}
{%- set order_url = order_url|default(site_root ~ '/shop/order/' ~ order.hash) -%}
<a href="{{ order_url }}">View your order</a>

Links are built from the URL of the request that queued the job, captured into the payload as _site_url, because the worker process has no request behind it. Only an address the store has named is captured: system.custom_base_url, or the entry of security.site_urls the customer was on (the first entry otherwise). A request's host comes from a header the visitor can set, so it is never used on its own. The one exception is a signed-in admin on a store that has named nothing yet. See Hardening → Site address for emailed links.

Set security.site_urls, or system.custom_base_url to pin one address. An administrator opening the admin over HTTPS fills in security.site_urls for you while both are empty. custom_base_url wins over everything, which is what a store serving several hostnames wants when only one of them belongs in an email. Either one is also the answer for work started from the command line, where there is no request to capture: a scheduled abandoned-cart sweep, an admin script, a job queued again by hand.

A sign-in, set-password or cart link needs one of them. With no trusted address those messages are not sent, and the job's failure record says why. Every other email goes out with site-relative links. Set the address and retry the job from the Jobs screen.

Four fields change the palette and the logo across all sixteen emails, with no template edited.

  1. Open KahunaCart → Settings and the Notifications tab.
  2. Scroll to Customer Emails.
  3. Set Email Link Color, Email Button Color, Email Logo and Email Logo Width.
  4. Click Save.
  5. Clear the cache with bin/grav clear.

The same four keys in user/config/plugins/kahunacart.yaml:

YAML
emails:
  brand:
    color: '#14329e'                    # links and accents
    button_color: '#2450e0'             # call-to-action buttons
    logo: '/user/images/store-logo.png' # or an absolute URL
    logo_width: 140                     # px, clamped to 40–320
  • color paints links inside the card and the footer link. Blank falls back to #1a6f83.
  • button_color paints the buttons and the rule under the header band. Blank falls back to #ca5415.
  • logo goes above the store name in the header band. A path starting with / is turned into an absolute URL before the message is sent. An https://… or //… URL is used as given. Blank prints the site title on its own.
  • logo_width is the width the image is drawn at. Supply the file at twice that width so it stays sharp on a high-density screen.

Important

Changes take effect after a cache clear. The email layout reads these settings through Grav's cached configuration.

Nothing here is validated beyond having its whitespace trimmed. Send yourself a test order and look at the result.

Override an email template

A theme overrides an email template by name.

  1. Create a templates/emails/ directory in your theme.
  2. Copy the template you want to change out of user/plugins/kahunacart/templates/emails/ into it.
  3. Edit the copy.
  4. Repeat for the other half of the pair so the HTML and plain-text versions match.

The next send uses your version. The same goes for any of the other fifteen pairs.

To restyle every email at once, override templates/emails/kahunacart-email-layout.html.twig instead. The shipped templates keep extending it by name, so their content blocks come across unchanged. The layout exposes the blocks title, preheader, header, eyebrow, content, footer and styles.

Resend an order email

Every order email can be sent again from the order detail's Send email… menu.

  1. Open the order in Orders.
  2. Click Send email….
  3. Click the message you want to send again.

The message is re-rendered against the order as it stands now, so an order corrected since it was placed sends the corrected copy. It is re-queued with the payload the last message of that kind carried: a refund email resends for the refund it was about, a note resends with its note, and Shipped resends the last parcel's contents and tracking number.

Refund and note cannot be composed from the order row alone, so resending either before one was ever sent answers 422. Shipped can, and falls back to every physical line with no carrier named.

The resend is recorded as email.resent. See Orders → Resend an order email.

Suppress an order email

Notify customer on the order detail is ticked by default. There is one tick per order rather than one per dialog.

  1. Open the order in Orders.
  2. Untick Notify customer.
  3. Run the action.

The next mark-paid, shipment, ship-all-remaining, cancel or refund does everything it normally does except send the message. On the API, each of those endpoints takes notify: false.

Nothing is queued, so no email.queued entry claims a message that was never composed. The history entry records a quiet flag instead. See Orders → Change a status without telling the customer.

Turn on abandoned-cart reminders

  1. Set cart.abandoned_enabled to true.
  2. Set cart.abandoned_hours to the idle time a cart must reach. The default is 24.
  3. Optionally set emails.abandoned_subject to your own subject line. Leave it blank to use the translated default.

Reminders go out on the next worker tick. Each cart is mailed at most once.

Test that emails are sending

  1. Place a test order on the storefront.
  2. Run the worker by hand, then check the queue counts.
BASH
bin/plugin kahunacart work      # run the queue by hand
bin/plugin kahunacart status    # pending / failed / completed job counts

A failed job keeps its last_error. The most common cause of missing mail is a scheduler that was never installed in cron.

The order itself answers the same question. Every order email records email.queued when it goes on the queue and email.sent once the transport has taken it, both in the order's history with the kind and the address. An entry queued with no send beside it is a message that never left.

Translate the email strings

Every string in every template goes through |t. Subjects go through the language system too, with the order number as a positional argument so a translation can move it inside the sentence.

YAML
EMAIL_SHIPPED_SUBJECT: 'Order #%s is on its way'

Keys live under PLUGIN_KAHUNACART. in languages/en.yaml. The abandoned-cart subject can also be set in config, with emails.abandoned_subject. To write a whole language pack, see Translating KahunaCart.

Subjects are object templates. Every message with a kind has an email_subject.<code> slot whose default is the translated subject, so a store can prefix every subject with its own name without touching a language file. See Object templates.

Configuration reference

Key Default Effect
emails.brand.color empty Link and accent colour inside the card; empty uses #1a6f83
emails.brand.button_color empty Call-to-action button colour; empty uses #ca5415
emails.brand.logo empty Logo for the header band. Site-relative paths are made absolute; empty prints the site title
emails.brand.logo_width 140 Width the logo is drawn at, clamped to 40–320 px
emails.store_copy_to empty BCC address for order confirmations
emails.abandoned_subject empty Overrides the translated abandoned-cart subject
cart.abandoned_enabled false Turns abandoned-cart reminders on
cart.abandoned_hours 24 How long a cart must sit idle before it earns a reminder
reviews.request_after_days 0 Days after an order completes before its review request goes out. 0 is never
orders.quote_expiry_days 30 How long a quote is good for when you do not name a date yourself

The From address comes from the Email plugin's own from and from_name settings.