Track delivery, opens and clicks on every transactional email a Vendure
server sends. Wraps @vendure/email-plugin, persists every send + open
Maintained by Wayne Garrison.
14-day free trial (card required, start it from the admin — nothing charged until day 15), then £6.95/month, or £139 one-off lifetime at elite.charity/licence/buy/vendure-plugin-email-tracking. Start the trial (or buy) from the plugin's admin page — checkout opens in a new tab and the key installs itself.
Then add the UI extensions to your compileUiExtensions config:
Since 0.14.2: the bounce webhook needs
bounceWebhookSecret(orallowUnauthenticatedBounceWebhook: trueon a private network); rows are pruned after 365 days by default (retention); the visitor IP isreq.ipunless you list proxy headers intrustedIpHeaders; unsigned click links only redirect to your own hosts — setsigningSecretfor full tracking.
Since 0.14.3: links are always signed — without
signingSecreta random per-install secret is generated at boot and kept inhulo_licence_store(keyvendure-plugin-email-tracking:signing-secret, shared by server and worker). SetsigningSecretyourself to keep links in already-sent emails valid across reinstalls and database restores. Emails sent before any secret existed keep working (pixel served, clicks to your own hosts only). Mail sent through the Vendure email pipeline now records the SMTP Message-ID and reply, so the bounce webhook can match it.
Every email produced by @vendure/email-plugin (order confirmation,
password reset, OTP, invoice, etc.) is logged automatically — no
per-handler wiring. Inject EmailTrackingService for ad-hoc sends from
your own plugin code and get the same engagement tracking.
/email-track/open/:token.gif — the token is
<id>.<hmac> (signed with signingSecret, or the generated per-install
secret) and forged ids are rejected./email-track/click/:token?u=… — skips
mailto:, tel:, #-anchors, and unsubscribe/opt-out links.
Optional clickRedirectAllowedDomains denies open-redirector abuse.
Per-link tokens minted with EmailLinkService.issueLinkToken() (64-hex,
?u=…&s=<hmac>) are honoured too: the row must exist and be unexpired,
and both the signature and the stored destination hash must match u,
so a token cannot be pointed at another URL. Sensitive links are recorded
by their redacted form.TrackingEmailSender records nodemailer's Message-ID (without angle
brackets) and SMTP reply on the row, and sets sent / deferred /
failed from the reply code.EmailSuppression entity. Hard bounces and complaints
auto-populate; sendTracked() refuses suppressed recipients and
writes status='suppressed' rather than calling SMTP.GET/POST/DELETE /email-track/suppression.GET /email-track/log/stats/by-template?fromDays=30 returns open
rate, click rate and click-to-open per email type.POST /email-track/bounce with { messageId, status, reason }.X-Signature (HMAC-SHA256) against bounceWebhookSecret or a
configured signingSecret (a generated one is never used here, since
the sender could not know it). Postmaster integrations stay loose-coupled.GET /email-track/log/export.csv?status=sent&from=2026-05-01 — same
filters as the list endpoint, up to 50 000 rows.hashIpsInHistory: true (default) stores SHA-256 hashed IPs.The sweeper runs once a day on the worker only, deletes in batches of
5 000 rows (oldest id first, with a short pause between batches so the
sender is never stalled by one long DELETE), prunes email_link rows
past their expiresAt or older than days, is .unref()'d so it never
blocks shutdown, and fails soft on DB hiccups.
Weekly report. Every Monday at 08:00 (server-local time) the worker emails a short report for the previous seven days: emails tracked, opens recorded, link clicks, bounces and complaints caught, and the open rate — each with the change against the week before — followed by up to three next steps taken from the set-up checklist and a link to the Email Log page. Trial and free-tier installs also see what a licence switches on. The report is sent once per install (safe with several workers).
GET /email-track/digest/settings,
POST /email-track/digest/settings { enabled?, recipient? },
POST /email-track/digest/send-test { to? }, GET /email-track/digest/preview.SMTP_SERVER (or SMTP_HOST), SMTP_PORT, SMTP_USER,
SMTP_PASSWORD (or SMTP_PASS) and SMTP_FROM environment variables
through nodemailer (already a peer dependency). With no SMTP host set
the report is simply not sent; every failure is logged, never thrown.hulo_plugin_prefs table (one row per
plugin and key), created at boot if missing.Set-up checklist. The first card on the Email Log page
(GET /email-track/setup) runs four real checks: the tracking sender is
wrapped (a log row in the last 30 days), publicBaseUrl points at a public
host, a bounce webhook secret is configured, and at least one open or
click has been recorded. Each row has a hint and a docs link; Hide
puts it away in that browser once everything is done, and it comes back if
a check fails again. The undone items are the weekly report's tips.
Dashboard widget. Dashboard → Add widget → Email Tracking shows
emails tracked, opens and clicks for the last 7 days (GET /email-track/widget),
the licence notice when a trial or key is about to end, and an
"Open Email Tracking" link. Needs the ReadCustomer permission, like the
page.
Usage stats. The same five figures for the last 30 days
(emailsTracked, emailOpens, emailClicks, emailsBounced,
openRatePct — counts only, never addresses) go with the evaluation ping
and the daily heartbeat so renewal and trial emails can say what the
plugin did. HULO_HEARTBEAT_DISABLED=true stops the heartbeat as before.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /email-track/open/:token.gif | public | tracking pixel |
GET | /email-track/click/:token?u=… | public | click redirector |
POST | /email-track/bounce | HMAC | bounce / complaint webhook |
GET | /email-track/log | admin | paginated list with filters |
GET | /email-track/log/summary | admin | status totals tile |
GET | /email-track/log/:id | admin | full detail incl. opens + clicks |
GET | /email-track/log/stats/by-template | admin | per-template aggregates |
GET | /email-track/log/export.csv | admin | CSV export |
GET | /email-track/suppression | admin | list suppressions |
POST | /email-track/suppression | admin | add a suppression |
DELETE | /email-track/suppression/:recipient | admin | lift a suppression |
GET | /email-track/status | admin | version + licence + update status |
GET | /email-track/licence/status | admin | licence/evaluation state, lifecycle notice, premium features, recent stats |
POST | /email-track/eval/remind-me | admin | { email } — trial-ending reminder opt-in |
GET | /email-track/setup | admin | set-up checklist |
GET | /email-track/widget | admin | dashboard widget figures (7 days) |
GET | /email-track/digest/settings | admin | weekly report settings |
POST | /email-track/digest/settings | admin | { enabled?, recipient? } |
POST | /email-track/digest/send-test | admin | { to? } — send this week's report now |
GET | /email-track/digest/preview | admin | this week's report as HTML |
Re-send every active key on file at elite.charity/licence/forgot.
User manual + screenshots: huloglobal.com/vendure-plugins/email-tracking/docs/
Commercial. Buy at elite.charity/licence/buy/vendure-plugin-email-tracking.
All notable changes to @huloglobal/vendure-plugin-email-tracking are
documented here. The format follows
Keep a Changelog and this project
adheres to semantic versioning.
Retention pass: the plugin now tells the merchant what it did for them and
walks them through finishing the set-up. One new table (hulo_plugin_prefs,
shared by every HULO plugin; created fail-soft at boot). Requires
@huloglobal/vendure-licence-sdk 0.15.0.
SMTP_SERVER/SMTP_HOST, SMTP_PORT, SMTP_USER,
SMTP_PASSWORD/SMTP_PASS and SMTP_FROM environment (the plugin has no
mailer of its own); without SMTP nothing is sent and nothing breaks. The
recipient defaults to the licence's customer email, else the address given
for the trial reminder. Admin endpoints under /email-track/digest/:
GET settings, POST settings ({enabled?, recipient?}), POST send-test ({to?}) and GET preview (HTML). The Email Log page has a
"Weekly report" card (toggle, recipient, last/next send, Send me a test,
Preview).GET /email-track/setup: sender wrapped (a log row in the last 30 days),
publicBaseUrl set to a public host, bounce webhook secret configured,
first open or click recorded. The undone items become the weekly report's
tips. "Hide" (per browser) once everything is done; the card returns if
an item comes undone.GET /email-track/widget.emailsTracked, emailOpens,
emailClicks, emailsBounced and openRatePct for the last 30 days
(numbers only, no addresses) go with the evaluation ping and, from this
version, with the daily heartbeat too, so renewal mail can say what the
plugin did. HULO_HEARTBEAT_DISABLED=true still stops the heartbeat.GET /email-track/licence/status now carries
notice (trial ending / key expiring / evaluation ended),
premiumFeatures and recentStats (top three non-zero 30-day figures).
The licensed banner shows "renews automatically — current key valid
until POST /email-track/eval/remind-me
{email}, forwarded to the licence server and kept as the report's
default recipient), the premium feature list and a "So far: …" line; the
free banner shows the same with "During your evaluation: …" once the
evaluation has run.premium-feature, licence-required or licence_required) renders one
message in the admin: "Follow-up to the 0.14.2 hardening pass. No new tables or columns.
signingSecret a random
per-install secret is generated once at boot and stored in
hulo_licence_store (key vendure-plugin-email-tracking:signing-secret;
first writer wins so server and worker sign and verify alike), then
applied as if configured. Bare-id links in already-sent emails behave
exactly as in 0.14.2 (pixel served, clicks to own hosts only). A boot
warning explains that setting signingSecret keeps links valid across
reinstalls. A generated secret is never used to authenticate the bounce
webhook./email-track/click/:token accepts the
64-hex tokens minted by EmailLinkService.issueLinkToken() (?u=…&s=…):
the row must exist and be unexpired, and both the HMAC and the stored
destination hash must match, so the token binds its URL. Sensitive links
are recorded by their redacted form. EmailLinkService signs with the
env override, else the plugin's signingSecret.src/pg-corpus.test.ts, runs when HULO_PG_URL
is set): every raw SQL statement in src/ is translated by the dialect
adapter and executed against PostgreSQL 17 with TypeORM-quoted stand-ins.TrackingEmailSender
delegated to NodemailerEmailSender, which discards nodemailer's result,
so rows for order confirmations, OTPs and password resets never had a
smtpMessageId/smtpResponse. A CapturingSender subclass keeps the
result per send; the row now gets the Message-ID (stored without angle
brackets, also for sendTracked) and the SMTP reply, and its status is
set from the reply code like sendTracked does.email_log in batches of 5 000 (oldest id first, short pause
between batches) instead of one unbounded statement, applies maxRows
the same way, and prunes email_link rows past expiresAt or older
than days.dist.Reliability and security pass — no new features. Three indexes on
email_log and one on smtpMessageId are declared (run your migrations).
datetime, which
TypeORM rejects on Postgres at start-up; they are now portable Date
columns. Raw SQL quotes every camelCase column and uses SUM(CASE …)
instead of SUM(boolean), so the log, summaries, exports and the
GraphQL list work on Postgres.huloEmailLogs selected a lastClickedAt column that does
not exist and failed on every database.publicBaseUrl, trackedHosts,
clickRedirectAllowedDomains); signed links are unchanged. Bare ids
must be plain decimals (a 64-hex link token no longer resolves to row 7).allowUnauthenticatedBounceWebhook: true; it is rate limited;
Message-IDs match with or without angle brackets; soft/transient
bounces (bounceType: 'soft') are recorded as deferred and never
suppress the address; suppression inserts are race-safe.UPDATE … + 1 statements
(concurrent pixel fetches from Gmail's proxy and Apple MPP used to lose
increments); the pixel is no longer display:none (Outlook skips hidden
images) and no longer carries a same-origin resource policy.href contains & are decoded before being wrapped,
so click-through query strings survive.=, +, -, @ are quoted
so they cannot run as spreadsheet formulas; suppression reasons are
checked against the known set.@, the restart poll is cancelled on destroy.retention defaults to { days: 365 } (was: keep everything).trustedIpHeaders (proxy headers that may override
req.ip; default none) and allowUnauthenticatedBounceWebhook.id DESC (same order, uses the primary key).hulo-global-logo.svg is now the HG wordmark on the HULO black rounded square (the same mark as the huloglobal.com header), with explicit -light and -dark variants alongside the auto-switching default..env edit, no restart. Renewed subscription keys are picked up automatically too. New admin endpoints licence/purchase-link and licence/claim-status.@huloglobal/vendure-licence-sdk ^0.12.0.dbConnectionOptions use.
Verified against PostgreSQL 17. MySQL/MariaDB installs are unaffected
(byte-identical passthrough).customerId lookup in TrackingEmailSender. If
the subject doesn't contain an order code (password reset, OTP,
email verification, account welcome, etc.) the sender now does a
case-insensitive Customer.emailAddress lookup so the row still
links to the right customer. Combined with 0.8.1's order-code
extraction, this means the per-customer Emails view now surfaces
every send the customer ever received — order-related and
non-order-related.TrackingEmailSender now populates orderCode, orderId and
customerId on every EmailLog row it creates. Before this fix, only
emails sent by our own service code (which passed those ids in
explicitly) had them set — the Vendure email-plugin's built-in
order-confirmation, invoice, password-reset etc. handlers do not
hand order/customer entities through to the sender, so those rows
saved with all three ids as NULL. That broke the per-order and
per-customer Emails buttons on the admin (they filter by
orderCode / customerId), showing an empty list even though the
emails were sent.
The sender now extracts the order code from the email subject via a
#<code> regex (Vendure's default order-related templates render
it there — e.g. "Order confirmation for #S2BZ54TEK91HUUBA"), then
looks up the corresponding Order row to backfill orderId and
customerId. Best-effort: unmatched subjects fall back to the
previous behaviour (envelope-only row).
warnIfIncompatibleVendure(). Logs a non-fatal warning when the runtime
@vendure/core version is outside the tested range. Silent when inside;
fail-open on unparseable versions.@vendure/core tightened to >=3.5.0 <4.0.0 — Vendure 3.5,
3.6 and 3.7 are all covered. Anything under 3.5 has never been tested
by us; anything from 4.0 upwards is deferred until we've seen the
changelog.@huloglobal/vendure-licence-sdk@^0.6.0.EmailLink entity — per-link metadata for tracked transactional
emails. Each clickable link in each email gets its own random 32-byte
token. Records the link's type, human label, visible text, position
index, template section (main_cta, footer, etc.), destination host,
path, and a SHA-256 hash of the full destination URL.EmailLinkService — issues per-link tokens, verifies HMAC
signatures on click, looks up link metadata at redirect time. Graceful
degradation: if the host doesn't have the migration yet, the redirect
still works via signature verification alone; the click event just
won't carry server-side link metadata.isSensitive: true on password-reset,
invoice-access, or licence-delivery URLs. The raw destination never
lands on an admin-visible event row; only the redacted form and hash.@huloglobal/vendure-licence-sdk@^0.5.0 for the shared
classifyEmailEvent() classifier.huloEmailLogs, huloEmailLog, huloEmailStatsByTemplate (paid),
huloEmailSuppressions, huloAddEmailSuppression,
huloRemoveEmailSuppression.npm view --json <pkg> dist.attestations.display: block on mobile tables that broke
the row / cell alignment. Table now scrolls horizontally inside its
card at narrow widths.signingSecret is configured, the URLs
embed an HMAC tag and forged ids are rejected.clickRedirectAllowedDomains).POST /email-track/bounce webhook
(bounceWebhookSecret).hashIpsInHistory, default
true; ipSalt setting)./open + /click.options.retention.@huloglobal/vendure-licence-sdk@^0.2.0.UpdateChecker integration via the licence-sdk — /email-track/status
endpoint returns version + update info, admin UI shows a banner when
a new version is available.EmailSuppression entity. Hard bounces
and complaints auto-add to the table; sendTracked() refuses
recipients on the list and writes status='suppressed'. CRUD
endpoints (GET /email-track/suppression,
POST /email-track/suppression,
DELETE /email-track/suppression/:recipient).GET /email-track/log/stats/by-template
returns open rate, click rate and click-to-open per email type.GET /email-track/log/export.csv mirrors the list
endpoint's filters (max 50 000 rows).opensJson) alongside the existing click
history. Capped to the last 50 opens per email — older opens still
contribute to openCount. Surfaced as opens: [] on
GET /email-track/log/:id.EmailTrackingPlugin — wraps @vendure/email-plugin and persists every
send to the email_log table.TrackingEmailSender — drop-in EmailSender replacement that wraps
the default Nodemailer sender and injects an open-tracking pixel and a
click redirector into the outgoing HTML.EmailTrackingService — exposed for custom controllers that send
transactional email outside the email-plugin pipeline./email-track/open/:id.gif (1×1 pixel),
/email-track/click/:id?u=<encoded> (302 redirect), and
/email-track/bounce (webhook hook for DSN parsers)./email-track/log (paginated list with filters),
/email-track/log/summary and /email-track/log/:id.@huloglobal/vendure-licence-sdk with
revocation polling against the HULO licence server.