HG Hulo Global

Email Tracking — User Manual

Overview

Email Tracking logs every transactional email your Vendure server sends. It wraps the @vendure/email-plugin sender so opens and clicks are tracked automatically, plus exposes a service for ad-hoc sends from your own plugin code.

Data captured per email:

  • Send: recipient, subject, type, related order / customer / invoice, the SMTP response, the SMTP messageId.
  • Opens: per-open history (last 50) with timestamp, IP, user-agent and parsed client (Gmail web, Outlook desktop, Apple Mail iOS …).
  • Clicks: per-click history (last 50) with timestamp, target URL, IP, user-agent.
  • Bounces / complaints: status update + auto-add to the suppression list.

Install

One package, one line of config, one migration. The fastest way is the bundled installer:

curl -sSL https://huloglobal.com/vendure-plugins/email-tracking/install.sh | bash

Or by hand:

# 1. Install yarn add @huloglobal/vendure-plugin-email-tracking # 2. Register in vendure-config.ts import {{ EmailTrackingPlugin, TrackingEmailSender }} from '@huloglobal/vendure-plugin-email-tracking'; export const config: VendureConfig = {{ plugins: [ EmailTrackingPlugin.init({{ publicBaseUrl: 'https://shop.example.com', licenceKey: process.env.HULO_LICENCE_KEY_EMAIL_TRACKING, }}), // also pass TrackingEmailSender to the @vendure/email-plugin: EmailPlugin.init({{ emailSender: new TrackingEmailSender(), // ... your existing email-plugin options ... }}), ], }}; # 3. Generate + run migration yarn migration:generate AddEmailTrackingTables yarn migration:run

Restart Vendure. The Email Log tab appears in the admin nav immediately.

Admin UI tour

The Email Log page is mounted at /admin/extensions/email-log (or via the side nav).

Email Log — list view Admin panel mockup. Status totals — All 1247 / Sent 1198 / Failed 12 / Deferred 8 / Bounced 14 / Opens 540 / Clicks 87 — Filters — Input: Filter recipient… — Input: Filter type… — Recent emails — #1247 29 May order-confirmation [email protected] Sent ●●● 12 opens, 3 clicks — #1246 29 May password-reset [email protected] Sent ● 1 open, 0 clicks — #1245 28 May invoice [email protected] Sent ●●●● 8 opens, 2 clicks Vendure Admin Email Log — list view Status totalsAll 1247 Sent 1198 Failed 12 Deferred 8 Bounced 14 Opens 540 Clicks 87 Filters Filter recipient… Filter type… Recent emails#1247 29 May order-confirmation [email protected] Sent ●●● 12 opens, 3 clicks#1246 29 May password-reset [email protected] Sent ● 1 open, 0 clicks#1245 28 May invoice [email protected] Sent ●●●● 8 opens, 2 clicks
List view — colour-coded status pills, per-row open/click counts, click any row for the timeline.

Click Details on any row to expand the timeline. You'll see:

  • Header block — From / To / BCC / subject / context
  • SMTP block — message-id, SMTP response, any error message
  • Open block — first/last open times, first-open IP and UA
  • Open history table — every open with time, IP, UA
  • Click history table — every click with time, target URL, IP, UA
Open and click history are capped at the most recent 50 entries per email. The lifetime totals (openCount / clickCount) keep counting beyond 50.

Suppression list

The plugin maintains an email_suppression table — recipients who should never be sent to. The table is populated automatically when the bounce webhook reports a hard bounce or a complaint, and you can add entries manually.

When sendTracked() is called for a suppressed recipient, the row is written to email_log with status='suppressed' and errorMessage='Recipient is on the suppression list' — but no SMTP call is made.

Adding entries manually

curl -X POST https://shop.example.com/email-track/suppression \ -H "Content-Type: application/json" \ -d '{{"recipient":"[email protected]","reason":"manual","note":"Asked to be removed via support"}}'

Lifting a suppression

curl -X DELETE https://shop.example.com/email-track/suppression/[email protected]

Listing the table

curl https://shop.example.com/email-track/suppression?take=200

Bounce webhook

Wire your postmaster integration (or scheduled DSN parser) to POST to /email-track/bounce:

{{ "messageId": "<[email protected]>", // the smtpMessageId from the original send "status": "bounced", // or 'complained' "reason": "550 5.1.1 The email account ..." }}

The matching email_log row is updated and the recipient is auto-added to the suppression list. The endpoint is unauthenticated — gate it behind a shared secret header if you expose it to the public internet.

Per-template analytics

For an aggregated view of how each email type performs, hit:

GET /email-track/log/stats/by-template?fromDays=30

Returns:

{{ "days": 30, "types": [ {{ "type": "order-confirmation", "sent": 1247, "opened": 540, "clicked": 87, "bounced": 14, "failed": 12, "suppressed": 3, "openRate": 0.433, "clickRate": 0.070, "ctor": 0.161 // click-to-open }}, ... ] }}

CSV export

The list endpoint has a CSV mirror. Same filters, same shape:

GET /email-track/log/export.csv?status=sent&from=2026-05-01

Up to 50 000 rows per call.

HTTP endpoints

MethodPathDescription
GET/email-track/open/:id.gifPublic pixel — logs an open then serves a 1×1 GIF
GET/email-track/click/:id?u=<url>Public click redirector — logs then 302s to the original URL
POST/email-track/bounceBounce / complaint webhook
GET/email-track/logAdmin: paginated log with filters
GET/email-track/log/summaryAdmin: status totals tile
GET/email-track/log/:idAdmin: full detail (incl. opens + clicks arrays)
GET/email-track/log/stats/by-templateAdmin: per-template aggregates
GET/email-track/log/export.csvAdmin: CSV export
GET/email-track/suppressionAdmin: list suppressions
POST/email-track/suppressionAdmin: add a suppression
DELETE/email-track/suppression/:recipientAdmin: lift a suppression

Troubleshooting

Opens aren't being recorded

Many email clients prefetch images through a proxy (Gmail's googleimageproxy, Outlook's SafeLinks). The plugin recognises common proxies and flags those opens as isBot: true on the open entry — they still count in openCount but you can filter them out in the UI.

Clicks redirect to a "blank target" 400

The redirector refuses URLs that don't start with http:// or https:// — including javascript:, data:, mailto:. The link rewriter already excludes mailto: and unsubscribe-style links from rewriting, so you shouldn't see this in normal use.

Suppression list isn't catching recent bounces

Make sure your bounce / DSN webhook is hitting /email-track/bounce with a messageId that matches what your SMTP transport returned at send time. Gmail returns the messageId in the 250 2.0.0 OK 1234567890 [email protected] response — the plugin stores it as smtpMessageId on the EmailLog row.