Per-template branded email delivery for invoices, statements and orders with open/read tracking and bounce handling.

  • Current version: v0.1.1.0
  • AL prefix: BRAND
  • Object range: 80900-80999
  • Business Central: 26.0.0.0
  • Publisher: Amplio Solutions

15 — Branded Document Delivery & Open Tracking

A Business Central 26.x extension that sends per-template branded HTML emails for sales documents (invoices, credit memos, orders, quotes, statements) via SendGrid, embeds an open-tracking pixel, ingests SendGrid bounce/open events through a token-secured webhook, and logs every send on the Posted Sales Invoice as a FactBox.

  • Publisher: Amplio Solutions
  • Version: 0.1.1.0
  • Application / Platform: 26.0.0.0
  • Object range: 80900–80999 (AMP prefix)
  • Country: GB
  • Runtime: 13.0
  • Target: Cloud (works in OnPrem sandboxes too)

What this extension does

Business Central's standard Email module ships an Account+Connector model and a generic body composer. This extension does not replace either. Instead it:

  1. Adds a Setup table (singleton) holding the SendGrid API base URL, sender defaults, public tracking base URL, and toggles for tracking-pixel embedding and test-recipient redirection. Secrets (SendGrid API key, inbound webhook token) live in Isolated Storage — never in the table.
  2. Adds an Email Template table keyed by code, with a one-default-per-document-type rule, an HTML Blob body, optional sender override, and a placeholder Customer No. field reserved for v2 per-customer inheritance.
  3. Adds a SendGrid HttpClient wrapper codeunit that POSTs to /v3/mail/send with the rendered HTML body and a custom_args.tracking_id GUID, so the bounce/open events can be correlated back.
  4. Adds a Branded Email Sender codeunit that resolves the template, builds the customer + document merge dictionary, renders subject/body, optionally appends a 1×1 tracking pixel, calls the wrapper, and writes an entry into the Email Send Log with status (Pending → Sent / Failed → Delivered / Opened / Bounced / Spam).
  5. Adds a Webhook Handler codeunit that validates a shared bearer token (constant-time compare against Isolated Storage), parses a SendGrid event-webhook JSON array, and writes both an Email Tracking Event row and a status update on the corresponding Send Log row.
  6. Exposes a token-secured API page (AMP Webhook Endpoint, page 80940) with bound actions processSendGridEvents and recordPixelHit. The token is supplied as an action parameter (BC API pages cannot read HTTP request headers from AL).
  7. Subscribes to Codeunit 8888 "Email"."OnAfterSendEmail" to log all platform-driven sends alongside the SendGrid-driven ones. Body substitution for platform sends is deferred — the platform's pre-send hooks are internal in BC 26 and not subscribable from third-party extensions.
  8. Extends the Customer Card with a placeholder default-template field and a navigation action to the per-customer send log.
  9. Extends the Posted Sales Invoice with a delivery FactBox and a "Send Branded Email" action.

Object map

Range Kind Examples
80900–80903 Tables Email Setup, Email Template, Email Send Log, Email Tracking Event
80910–80912 Enums Email Document Type, Email Send Status, Email Tracking Event Type
80920–80925 Codeunits Email Template Mgt, SendGrid Wrapper, Branded Email Sender, Email Event Subscriber, Webhook Handler, Install
80930–80939 Pages Email Setup, Template List/Card, Send Log, Tracking Events, Delivery FactBox, Secret Input
80940 API Page Webhook Endpoint (/api/amp/brandedEmail/v1.0/...)
80950 TableExtension Customer (Default Email Template field)
80950–80951 PageExtensions Customer Card, Posted Sales Invoice
80990–80991 Permission Sets Full (AMP Branded Email) + read-only

Folder layout

15-Branded-Email-Delivery/
├── app.json
├── .editorconfig
├── .gitignore
├── README.md
├── .vscode/
│   └── launch.json
└── src/
    ├── Codeunits/
    ├── Enums/
    ├── PageExtensions/
    ├── Pages/
    ├── Permissions/
    ├── TableExtensions/
    └── Tables/

Dependencies

Pinned in app.json:

App Publisher Version
Base Application Microsoft 26.0.0.0
System Application Microsoft 26.0.0.0

The standard Email module (Codeunit 8888 "Email", Codeunit 8904 "Email Message") lives in System Application, so the dependency above covers it. No SendGrid AL dependency — the wrapper uses the BC HttpClient directly.

What's complete

  • AL scaffold compiles against BC 26 symbols (Base + System Application).
  • Setup table + page with two Isolated-Storage secrets (SendGrid API key, webhook token), set/clear actions, masked secret-input modal.
  • Email Template table + Card/List with merge-field-aware Subject (Text[250]) and HTML Blob body, "Default for Document Type" enforcement, and HTML import-from-file action.
  • Email Send Log table + page with status traffic-lighting (Favorable / Unfavorable / Standard).
  • Email Tracking Event table + list with event-type styling.
  • Email Document Type enum (Sales Invoice, Credit Memo, Quote, Order Confirmation, Statement, Reminder, Other) — extensible for v2.
  • Email Send Status + Tracking Event Type enums.
  • AMP Email Template Mgt codeunit: resolver (default-per-doc-type, MVP ignores per-customer), Subject + HTML body merge-field renderer, customer + document merge-field builders, tracking-pixel HTML builder.
  • AMP SendGrid Wrapper codeunit: HttpClient POST to SendGrid Mail Send v3, JSON payload with tracking_settings.open_tracking.enable=false (we use our own pixel) and click_tracking.enable=false (deferred to v2), custom_args.tracking_id round-trip, X-Message-Id capture, error capture into the Send Log.
  • AMP Branded Email Sender: end-to-end orchestration for SendForPostedSalesInvoice — resolve template, build merge fields, render, embed pixel, send, log result.
  • AMP Email Event Subscriber: OnAfterSendEmail subscriber on Codeunit 8888 (logging only).
  • AMP Webhook Handler: token validation (constant-time), SendGrid event-array parser, per-event mapping into the tracking-event table and status update on the corresponding Send Log row, pixel-hit recorder.
  • AMP Webhook Endpoint (page 80940, PageType=API): bound actions processSendGridEvents(token, payload) and recordPixelHit(trackingId, userAgent, ipAddress).
  • Customer Card extension: placeholder default-template field + navigation to per-customer send log + open-templates action.
  • Posted Sales Invoice extension: Delivery FactBox + "Send Branded Email" + "Branded Email Log" actions.
  • TableExtension on Customer (field 80950 "AMP Default Email Template", reserved for v2).
  • Two permission sets: full RIMD (AMP Branded Email) + read-only.
  • Install codeunit that ensures the Setup singleton row exists on first publish.

What Lee needs to ship

Below is the punch list for going from this MVP scaffold to a production-running deployment.

Verify before publish

  1. Symbol compile. From VS Code with the AL Language extension installed: AL: Download symbols against your sandbox, then AL: Package (or Ctrl+F5). The scaffold is intended to compile clean against BC 26 — if any object reports a missing symbol, capture the message before working around it (the most likely culprit is the Email/Email Message codeunit being moved between System Application versions).
  2. OnAfterSendEmail event signature. Confirm in your downloaded BC 26 symbols that Codeunit::Email's OnAfterSendEmail matches (MessageId: Guid; Status: Boolean) — Microsoft has tweaked this signature historically. If it differs, update AMPEmailEventSubscriber.Codeunit.al.
  3. launch.json startup object. The default startup object is currently page 22 (Customer List) on both launch profiles. If you want F5 to land directly on the AMP Email Setup page, change startupObjectId to 80930 in .vscode/launch.json (the file is editor-protected in this session so it wasn't auto-updated).
  4. Cloud Sandbox name. Edit environmentName in .vscode/launch.json (currently AmplioDev).

Configuration / data seeding

  1. SendGrid account. Provision a SendGrid account, verify a sender domain (so the Default From Email passes SendGrid's domain authentication), and create an API key with at least mail.send and tracking scopes.
  2. Open Setup, populate. Run page 80930 and set: Enabled = true, Default From Email, Default From Name, Public Tracking Base URL (HTTPS base of the BC API — typically https://api.businesscentral.dynamics.com/v2.0/<tenant>/<env>/). Then click Set SendGrid API Key and Set Webhook Token.
  3. Configure SendGrid Event Webhook. In SendGrid → Settings → Mail Settings → Event Webhook:
    • HTTP Post URL: <Public Tracking Base URL>api/amp/brandedEmail/v1.0/companies(<companyId>)/webhookEndpoints(<systemId>)/Microsoft.NAV.processSendGridEvents
      • <companyId> is the BC company GUID (visible on the Companies API).
      • <systemId> is the systemId of the AMP Email Setup row — fetch it with a GET against webhookEndpoints first.
    • Body template: SendGrid does not natively wrap the events array with a custom JSON envelope, but it does support a static "Custom Headers" field. However, BC API pages cannot read HTTP headers from AL. Workaround for production: put a small Azure Function / APIM policy in front that (a) reads the SendGrid signature header, (b) wraps the body as {"token":"<value>","payload":<events>}, then (c) forwards it to the BC bound action with the action's expected parameter shape. This shim is the right v1.1 work item.
    • Select events: delivered, open, bounce, dropped, spamreport, optionally processed and deferred.
  4. Tracking pixel route. The pixel URL embedded in outgoing emails points at the BC API endpoint, but BC API page actions return JSON, not 1×1 GIFs. Same Azure Function / APIM shim: GET on /track/pixel/<trackingId> → call recordPixelHit → return image/gif 1×1 transparent. Until that shim exists, either turn off Embed Tracking Pixel in Setup and rely on SendGrid's open events for opens (re-enable open-tracking in AMPSendGridWrapper.BuildPayload if you go this route), or deploy the shim. The recommended MVP path is "rely on SendGrid open events" because it's zero infrastructure.
  5. Seed at least one Email Template. Open the AMP Email Template list, create one keyed e.g. INV-DEFAULT, set Document Type = Sales Invoice, Default For Document Type = ✓, Enabled = ✓. Use Import HTML from File to load a designed HTML body, or paste inline. Available merge fields: %CustomerNo%, %CustomerName%, %CustomerContact%, %CustomerEmail%, %CompanyName%, %CompanyAddress%, %CompanyCity%, %CompanyPhone%, %CompanyEmail%, %DocumentNo%, %DocumentDate%, %DocumentAmount%, %DocumentCurrency%.
  6. Permission assignment. Assign AMP Branded Email to the AR / billing team and AMP Branded Email ReadOnly to viewers. Both ship as Assignable permission sets.

Deferred to v2 (intentionally out of MVP)

  • Per-customer template inheritance. Field Customer No. on the template + AMP Default Email Template on the Customer card are scaffolded but the resolver in ResolveTemplate() ignores them. The lookup signature already takes CustomerNo so the v2 change is local to the codeunit.
  • Click tracking. The SendGrid wrapper sends with click_tracking.enable=false. Re-enabling it requires a body-rewrite step at SendGrid's end and ingesting click events into the existing tracking-event table (the enum value already exists).
  • Body substitution for platform-sent emails. The MVP's OnAfterSendEmail subscriber only logs — it does not rewrite the body. A v2 option is to send via the standard Email module with a custom Email Connector that calls our SendGrid wrapper; that gives the standard "Send-with-attachment" UX plus the branded body in one path.
  • Statement / Reminder dispatch entry points. SendForPostedSalesInvoice is the only orchestration procedure today. Mirror procedures SendForCustomerStatement, SendForPostedSalesCrMemo, SendForReminder are the right v2 expansion (each uses the same template-resolve / merge-fields / wrapper plumbing).
  • Bounce-driven Customer flagging. When a customer bounces, the Send Log records it but the Customer Card doesn't reflect "email is undeliverable". A v2 enhancement is a flow-field count of recent bounces on the Customer.
  • Job Queue / retry. No automatic retry on Failed or Deferred. A v1.1 batch job that re-attempts entries with Status = Failed and HTTP Status Code in (429, 5xx) is the right next step.

Build & deploy

From VS Code with the AL Language extension installed:

  1. Open the 15-Branded-Email-Delivery folder.
  2. AL: Download symbols against your target sandbox.
  3. Press F5 with the BC: Cloud Sandbox profile selected (edit environmentName first), or BC: Local Sandbox (Docker) for OnPrem.
  4. The Customer List opens by default — search for "AMP Email Setup" or change startupObjectId to 80930 for direct landing on the setup page.

Smoke test path (manual)

  1. Publish the extension; open AMP Email Setup, set everything per Configuration step 6 above.
  2. Open AMP Email Templates, create INV-DEFAULT (Sales Invoice, Default = ✓), paste an HTML body containing %CustomerName% and %DocumentNo%.
  3. Set the Test Recipient Override in Setup to your own email (so no real customer is touched).
  4. Open any Posted Sales Invoice, click Branded Email → Send Branded Email.
  5. You should receive an email at the override address; the FactBox flips from empty → one row with Status Sent.
  6. After SendGrid processes the message (usually < 30 s), the webhook (once wired up) flips Status to Delivered. Open the message in a mail client; the open event flips Status to Opened and increments Open Count.
  7. Force a bounce by setting Test Recipient Override to bounce@simulator.amazonses.com (or SendGrid's equivalent invalid-mailbox simulator), repeat — Status flips to Bounced with a populated Bounce Reason.