Change Document — PO Email Notification v1.1.0.0
Date: March 16, 2026 Extension: PO - Email Notification Version: 1.1.0.0 (from 1.0.88.5) Publisher: Sataware Technologies
Background
Version 1.0.88.5 of the extension contained hardcoded Azure AD credentials (client ID and client secret) directly in the source code of AzureAdMailLookup.Codeunit.al. This was identified during a code review prior to deployment to production and blocked promotion of the extension.
This version (1.1.0.0) resolves the credential exposure and addresses a set of additional correctness issues identified in the same review: blocking email sends, a missing TableType = Temporary declaration, an unnecessary Commit() in a page trigger, and unhandled HTTP error responses. The changes were developed by Bestway USA's BC development team.
Summary of Changes
| # | Severity | Category | Description |
|---|---|---|---|
| 1 | Critical | Security | Hardcoded Azure AD client ID and secret removed from source |
| 2 | Critical | Correctness | TempEmailResults table missing TableType = Temporary |
| 3 | High | Correctness | Email.Send blocks posting transactions on failure; replaced with Email.Enqueue |
| 4 | High | Correctness | Commit() in page trigger breaks transactional integrity |
| 5 | High | Correctness | HTTP errors in GetAccessToken / SearchEmails were unhandled |
| 6 | Medium | Functionality | Recipient list split produced empty entries from trailing semicolons |
| 7 | Medium | Functionality | Notify_Arrived had no missing-recipient guard (unlike Notify_Shipped) |
| 8 | Medium | Security | OData $filter URL not encoded — & in display names would corrupt the URL |
| 9 | Low | Code quality | Hand-rolled ReplaceAll replaced by built-in Text.Replace() |
| 10 | Low | Code quality | Duplicate HTML table header markup extracted to shared procedure |
| 11 | Low | Code quality | Unused PONo parameter removed from BuildHtmlTableFromPostedWRLines_All |
| 12 | Low | Code quality | Unclosed <p> tag in BuildFooter |
| 13 | Medium | Observability | No telemetry on happy path — added 28 structured App Insights events covering setup, Graph API, and all three notification procedures |
Detailed Changes
1. Hardcoded Credentials Removed (Critical — Security)
Files: AzureAdMailLookup.Codeunit.al (modified), POEmailSetup.table.al (new), POEmailSetup.page.al (new)
Before: The Azure AD client ID and client secret were hardcoded string literals in the GetAccessToken procedure. Any user with read access to the repository or the compiled extension symbols could recover the credentials.
After: Credentials are stored in a new singleton setup table (PO Email Setup, table 80101). The client secret is held in BC's Isolated Storage (DataScope::Module) and accessed only via SecretText — it cannot be read back through any UI or OData endpoint. The GetAccessToken procedure validates that setup is complete before attempting authentication and raises descriptive errors if configuration is missing.
The GetAccessToken return type was also changed from Text to SecretText, and the bearer token is passed to HttpClient.DefaultRequestHeaders.Add via SecretStrSubstNo to prevent the token from appearing in diagnostics or logs.
Setup required post-deploy: Administrators must open the PO Email Setup page and enter the Graph API tenant ID, client ID, and client secret before the email lookup and notifications will function.
2. TempEmailResults Missing TableType = Temporary (Critical — Correctness)
File: TempEmailResults.table.al
Before: The Temp Email Results table had no TableType declaration. Despite being used exclusively as an in-memory buffer (with DeleteAll, Init, Insert, and no Commit), BC would attempt database operations on it, producing unexpected behavior under some transaction states.
After: TableType = Temporary added. The table now correctly exists only in memory for the duration of the calling session. The Commit() call that was in the page trigger to flush the table (change 4 below) is no longer needed.
3. Email.Send Replaced with Email.Enqueue (High — Correctness)
File: POEmailHelper.codeunit.al
Before: All three notification procedures called Email.Send, which executes synchronously in the same transaction as the triggering BC operation (PO release, WR creation, WR posting). If email sending failed — because the scenario was not configured, the SMTP server was unavailable, or a transient error occurred — BC would raise an error and roll back the Purchase Order or Warehouse Receipt operation.
After: A [TryFunction]-wrapped TryEnqueueEmail procedure calls Email.Enqueue instead. Enqueue adds the message to the BC email job queue and returns immediately. Failures are logged to Application Insights with descriptive event IDs (e.g., wr.shipped.enqueue.fail) but do not affect the posting transaction.
Trade-off: Email failures are no longer surfaced to the user in real time. Administrators must monitor App Insights for .enqueue.fail events to detect delivery problems. This is the correct trade-off for a notification system — it is unacceptable for a warehouse receipt to be blocked because an email address was misconfigured.
4. Commit() Removed from Page Trigger (High — Correctness)
File: PurchOrderExt.pageExt.al
Before: After calling MailLookup.SearchEmails(Text, TempResults), the page's lookup trigger called Commit(). This was added to ensure the temp table writes were visible, but it is incorrect — Commit() in a page trigger commits any open transaction in the current session, including unrelated pending changes on the calling page. This could silently commit a partially-edited Purchase Order.
After: Commit() removed. Since TempEmailResults is now correctly declared as TableType = Temporary (change 2), no database writes occur during the search and no commit is required.
5. Unhandled HTTP Errors in Token and Search (High — Correctness)
File: AzureAdMailLookup.Codeunit.al
Before: GetAccessToken and SearchEmails called Client.Post / Client.Get and immediately read the response body without checking whether the HTTP request succeeded. A connection failure or HTTP 4xx/5xx response would produce a misleading JSON parse error ("Invalid JSON response from...") rather than a clear diagnostic.
After: Both procedures now:
- Check the return value of
Client.Post/Client.Get(which isfalseon connection failure) and raise a specific error with telemetry. - Check
Response.IsSuccessStatusCode()before parsing the body and raise an error showing the HTTP status code.
6. Recipient List Trailing Semicolons (Medium — Functionality)
File: POEmailHelper.codeunit.al
Before: BuildRecipientListFromPO split the "Send E-mail To" field on ; and returned the result directly. A trailing semicolon (common when users select multiple addresses from the lookup) would produce an empty string as the last list entry, which some email clients reject.
After: Each split entry is trimmed and filtered — empty strings are excluded from the returned list.
7. Missing-Recipient Guard in Notify_Arrived (Medium — Functionality)
File: POEmailHelper.codeunit.al
Before: Notify_Arrived_OnWhseReceiptPosted had an early exit if "Send E-mail To" was blank, but did not check whether BuildRecipientListFromPO returned an empty list (which can happen if the field contains only whitespace or semicolons after change 6). The procedure would call EmailMessage.Create with an empty recipient list, producing a silent failure.
After: A recipient-count guard was added matching the existing pattern in Notify_Shipped_OnWhseReceiptCreated, with a Warning-level telemetry event (arrived.norecip) logged when no valid recipients are found.
8. OData URL Not Encoded (Medium — Security / Correctness)
File: AzureAdMailLookup.Codeunit.al
Before: The search text was embedded directly in the OData $filter query string after escaping single quotes. Characters that are reserved in URIs — specifically &, %, +, #, and space — would corrupt the URL, causing Graph API to return a 400 error or silently return unexpected results. A vendor named "A&W" would break the URL into two query string parameters.
After: A UrlEncodeSearchText procedure applies percent-encoding to the five most likely problem characters after OData-escaping the value. % is encoded first to prevent double-encoding.
9–12. Code Quality (Low)
| # | Change |
|---|---|
| 9 | ReplaceAll local procedure (hand-rolled O(n²) string replacement) replaced by Text.Replace() — built-in, cleaner, and performs the same function without the manual loop. |
| 10 | Duplicate <table> / <tr> header HTML extracted to TableHeaderHtml() — all three email templates now share one definition. |
| 11 | PONo: Code[20] parameter removed from BuildHtmlTableFromPostedWRLines_All — the parameter was never used in the filter; the table already filters by receipt header number. |
| 12 | BuildFooter unclosed <p> tag fixed — was <a href="...">...</a> with no </p>, now <a href="...">...</a></p>. |
13. Comprehensive Telemetry (Medium — Observability)
Files: AzureAdMailLookup.Codeunit.al, POEmailHelper.codeunit.al
Before: The original extension had no telemetry. The v1.1.0.0 review state added telemetry to HTTP error paths only (graph.token.connfail, graph.token.httpfail, graph.search.connfail, graph.search.httpfail). The happy path and all three notification procedures were opaque — there was no way to distinguish a correctly-configured extension from a misconfigured one without reproducing the error interactively.
After: 28 structured App Insights events cover the full lifecycle. The additions beyond HTTP error paths are:
Setup misconfiguration events (AzureAdMailLookup.Codeunit.al): Four events fire before each Error() call in GetAccessToken:
| Event | Severity | Condition |
|---|---|---|
setup.missing | Error | PO Email Setup record does not exist |
setup.clientid.missing | Warning | Graph Client ID field is blank |
setup.tenantid.missing | Warning | Graph Tenant ID field is blank |
setup.secret.missing | Warning | Client secret not saved in Isolated Storage |
These are the most likely failure mode immediately after deployment — an administrator deploys the extension but forgets to open the setup page. Without these events, App Insights would show no data at all, which is indistinguishable from "no one has used the extension yet."
Graph API latency tracking: StartTime := CurrentDateTime() is captured before Client.Get(). ElapsedMs := CurrentDateTime() - StartTime is computed after the HTTP call returns. DurationMs is included as a dimension on both graph.search.ok and graph.search.httpfail. This supports KQL latency queries to detect Graph API slowdowns before they affect users.
Zero-result warning (graph.search.noresults): Logged at Warning severity immediately after graph.search.ok when ValueArray.Count() = 0. This is distinct from a search error — the API call succeeded but returned no users. This most commonly indicates a misconfigured User.Read.All permission on the Azure AD app registration.
PO Created notification telemetry (POEmailHelper.codeunit.al): The original Notify_POCreated_OnRelease procedure called SendEmail with no telemetry of its own. All three events (po.created.norecip, po.created.sent, po.created.enqueue.fail) are now logged, matching the pattern established by the Shipped and Arrived procedures. po.created.sent includes RecipientCount and LineCount dimensions.
Enriched success events: wr.shipped.sent and arrived.sent now include RecipientCount and PO dimensions. arrived.nolines Warning added for posted receipts with no item lines (previously a silent exit).
Object Inventory
| Object | ID | Type | Status |
|---|---|---|---|
AzureAdMailLookup | 80100 | Codeunit | Modified |
Temp Email Results | 80100 | Table | Modified — TableType = Temporary added |
PO Email Setup | 80101 | Table | New |
Purch. Order Ext | 80101 | Page Extension | Modified — Commit() removed |
PO Email Setup | 80101 | Page | New |
PO Email Helper | 80103 | Codeunit | Modified |
PO Email Subscribers | 80104 | Codeunit | Modified — SetLoadFields added to WR line query |
PoEmailPermissionSet | 80100 | Permission Set | Modified — new table and page added |
File Structure
PO Email Notification/
├── app.json (version 1.1.0.0)
├── README.md
├── docs/
│ ├── CHANGELOG.md
│ └── CHANGE-v1.1.0.0.md (this file)
├── src/
│ ├── codeunits/
│ │ ├── AzureAdMailLookup.Codeunit.al
│ │ ├── POEmailHelper.codeunit.al
│ │ └── POEmailSubscribers.codeunit.al
│ ├── pages/
│ │ └── POEmailSetup.page.al (new)
│ ├── pageExtensions/
│ │ └── PurchOrderExt.pageExt.al
│ ├── permissionset/
│ │ └── PoEmailPermissionSet.permissionset.al
│ └── tables/
│ ├── POEmailSetup.table.al (new)
│ └── TempEmailResults.table.al
└── test/
├── conftest.py
├── pytest.ini
└── test_po_email.py
Deployment Notes
Pre-Deployment
- Confirm the "Custom Purchase Emails" email scenario is mapped to an active outbound account in the target environment.
- Note the current Azure AD app registration credentials (tenant ID, client ID, and secret) — you will need to enter them in the setup page after deployment.
- This version has no data migration requirements. The
PO Email Setuprecord is created automatically when the setup page is first opened.
Post-Deployment
- Navigate to PO Email Setup and enter the Graph API credentials.
- Click Save Secret to persist the client secret.
- Assign PoEmailPermissionSet to all users who need access to the email lookup or setup page.
- Test the email lookup on a Purchase Order to confirm connectivity.
- Monitor App Insights for any
graph.token.orgraph.search.error events during the first few days.
Rollback
This version is backward-compatible with the database schema from 1.0.88.5. The new PO Email Setup table is additive. Rolling back to 1.0.88.5 will leave the setup table in place but it will be unused. No data migration is required in either direction.
Known Limitations
- The recipient semicolon delimiter is fixed — comma-separated addresses are not supported.
- Graph API results are capped at 50 per search. If a name or email prefix matches more than 50 users, only the first 50 are returned.
- The Arrived notification fires on all purchase postings that create a receipt (including direct PO posting without a warehouse flow). This is by design and is documented in the subscriber code.
- Email failures are silent to end users. App Insights monitoring is required to detect enqueue failures.
Testing
See test/test_po_email.py for the automated test suite. Tests are organized into tiers:
- bc — extension deployment, PO API operations, security and code quality checks (requires BC sandbox credentials)
- telemetry — App Insights queries to verify email enqueue events (requires
az login)
The UAT test plan is in docs/UAT Test Plan - PO Email Notification v1.1.0.0.docx.