Conversation
Bircck
force-pushed
the
worktree-send-email-from-template
branch
from
September 2, 2026 07:29
86b7e1e to
f0dbd26
Compare
Bircck
marked this pull request as ready for review
September 2, 2026 07:29
Implements the SendEmailFromTemplate message by validating the request (TemplateId, Target email, RegardingId/RegardingType), setting the regarding object, and delegating to the existing Create and SendEmail handlers so security, plugins and email status transitions are applied consistently. Performs a best-effort template content merge (subject/body) when the 'template' entity is present in metadata; token substitution is not modelled. Guarding on metadata keeps it safe where 'template' was not generated.
A Dataverse template's subject/body are XSLT stylesheets transformed against a <data> document built from the records the e-mail draws from. EmailTemplateRenderer reproduces this: it builds <data> from the regarding record and the sending user and runs the stylesheet with XslCompiledTransform, matching the platform's merge behaviour. Plain-text or non-XSLT content is returned unchanged. The handler now loads the template and renders subject/body into the e-mail before creating and sending it. Tests: - TestEmailTemplateRenderer exercises the renderer with the actual XSLT of the built-in "Thank you for registering" contact template. - An end-to-end SendEmailFromTemplate test creates a real template record and asserts the rendered subject/body on the sent e-mail. This required adding the 'template' entity to the test fixture metadata (TemplateMetadata.xml).
- Add a generated-style early-bound Template proxy type so template records can be created strongly-typed in tests (XrmContext generates from a fixed reference org that lacks 'template', so this is added by hand following the same pattern). - Use it in the end-to-end SendEmailFromTemplate test. - Strengthen the renderer test to assert the exact "Dear MrSmith" output, verified against a live SendEmailFromTemplate call: real Dataverse strips the whitespace-only stylesheet node identically. - Document the two cosmetic decorations the platform adds that the mock intentionally does not reproduce (HTML body envelope, subject tracking token).
The early-bound Template type is now generated by Delegate XrmContext v3.0.1 directly from the live org (https://orgd3a12bfa.crm4.dynamics.com), scoped to the 'template' entity, rather than hand-written. v3.0.1 was required because the bundled XrmContext v1.8 only supports WS-Trust, which the OAuth/MFA org rejects. Adjusted to the existing XrmContext.cs style: dropped the v3 cosmetic [DisplayName]/[MaxLength]/[Range] annotations and their System.ComponentModel* usings (net462 has no DataAnnotations reference and the existing context omits them), and the unused Template_TemplateTypeCode enum (templatetypecode is an EntityName string). Attribute data is unchanged; full suite passes on net462 and net8.0.
Format the extracted single-entity metadata file with indentation and the same UTF-8 BOM + XML declaration as the main Metadata.xml, instead of one long line. No content change; full suite still passes on net462 and net8.0.
Throw FaultException when the template or regarding record does not exist and when the template type does not match the regarding object type, rather than silently sending unmerged mail. Existence and type validation are isolated in RetrieveOrThrow and ValidateTemplateType helpers so the core Execute flow stays a simple linear sequence. Add tests covering the template-not-found and type-mismatch paths.
The standalone Template.cs and TemplateMetadata.xml were placeholders for a regenerated early-bound context, which master now produces: `template` is in tests/appsettings.json for both the metadata and XrmContext pulls, so Metadata.xml carries the entity and Context/tables/Template.cs the early-bound type. Both stopgaps are removed. Adapt the tests to the current generator's naming (ActivityParty collections are `from`/`to`; state/status enums are `email_statecode` / `email_statuscode`), and add the RELEASE_NOTES entry. The handler and renderer are unchanged apart from a comment noting why templatetypecode is read as an integer: it is an EntityName attribute carrying an option set, which XrmMockup stores as the object type code rather than the logical name Dataverse exposes.
Bircck
force-pushed
the
worktree-send-email-from-template
branch
from
September 2, 2026 07:34
f0dbd26 to
20dc80d
Compare
Correctness fixes, each with a regression test:
* templatetypecode: the `GetAttributeValue<OptionSetValue>() ?? GetAttributeValue<int?>()`
chain could not work - the first call hard-casts, so it throws on a type
mismatch instead of returning null, making the int fallback unreachable.
A live org returns templatetypecode as the string "contact", which the old
code turned into an unhandled InvalidCastException. Now switches explicitly
on OptionSetValue, int and string.
* The sending user no longer overwrites the regarding record in the render
context. A template regarding a systemuser merged the caller instead of the
regarding user.
* A stylesheet that does not compile now faults instead of returning the raw
XSLT, which previously shipped markup into the sent e-mail. The old comment
justified the fallback with plain-text templates, but those never reach it -
they return at the xsl:stylesheet check above.
* Lookups merge as the record id in registry format ({GUID}, uppercase), which
is what Dataverse does. Previously EntityReference.Name was used, and that is
always null for entities read from XrmDb, so lookups merged as nothing at all.
* XSLT is loaded with an explicit null resolver so xsl:import/include cannot
reach the network and a mock run stays offline.
* The template and regarding record are now read with a ReadAccess check,
mirroring RetrieveRequestHandler.
Comments: added summaries to BuildDataDocument and AttributeToString, removed
the "verified against a live org" provenance notes from source (they belong in
the PR), and left the templatetypecode explanation in one place instead of
three. Release note shortened to match the rest of the file.
Verified: 682 tests pass, net462 builds clean, and the packed NuGet was
consumed by a standalone project running against metadata generated from a
live org.
Not addressed here: Dataverse wraps the merged body in an HTML envelope where
we return bare text. Deliberately left to a follow-up so this change stays
scoped.
Comments: cut the class and method docs down to what the code does not already say, dropped provenance notes that belong in the PR, and stopped re-explaining the <data> document at every level. Added one comment that was missing - the empty-value skip in BuildDataDocument looks like a trivial null guard but is what lets xsl:otherwise defaults fire. Tests: * ThrowsWhenRegardingMissing omitted both RegardingId and RegardingType, so the RegardingType guard was never executed. Split into two tests. * Every negative test now asserts the fault message. Seven guards all raise FaultException, so these tests previously passed regardless of which one fired - including the permission test, whose "template" substring also matched the record-not-found message. * MergesScalarAttributesInvariantly covers AttributeToString's option set, money, bool, DateTime, int and decimal cases, none of which were exercised. * UsesXsltDefaultsWhenAttributeIsEmpty covers the empty-value skip. * New cases: null Target, non-email Target, missing regarding record, template without a templatetypecode, and a null record dictionary. * The rendered body is asserted with one Assert.Equal on the exact string instead of three Contains that could not catch lost punctuation or leaked markup. Helpers stay local to the test class - that is the convention throughout this suite (see TestLeads.cs); there is no shared test-data location to move them to. 690 tests pass, net462 builds clean.
26 tests was disproportionate for one request handler. Down to 13 with no behaviour left uncovered: * The five argument guards shared one test. Each case still asserts its own message, so it still proves which guard fired. * The template-missing and regarding-missing cases share one test - both are the same RetrieveOrThrow path. * Lookup and scalar merge formatting share one test; they were two tests asserting the same thing, that our output matches the platform's. * Missing and empty attribute defaults share one test. * Dropped the plain-text and malformed-stylesheet tests at handler level - both duplicated the renderer unit tests - along with the static-subject, no-records and no-templatetypecode cases. 677 tests pass, net462 builds clean.
SendEmailFromTemplate wrote the merged body to email.description as bare text, where a live org returns it wrapped in a minimal HTML document with LF line breaks and a trailing newline. Shipping a new handler with a known-wrong output defeats the point of the mock, so it belongs here rather than in a follow-up. Not reproduced: Dataverse also appends a tracking token to the subject. That is org-configurable, so doing it unconditionally would be wrong.
Probing a live org showed several places where the handler diverged: - A template regarding a systemuser merges the sender, not the regarding user, and regardingobjectid stays empty because the lookup cannot target systemuser. - A plain-text subject or body fails with an XmlException instead of passing through. - Booleans merge as 1/0; dates use the user's date and time format joined by a non-breaking space. - Guard messages and the order of the template-type check now follow Dataverse. - A missing sender defaults to the caller. - The caller's Target entity is cloned rather than mutated. Tests updated to match, with comments stating which formats were verified live and which are approximations. Claude-Session: https://claude.ai/code/session_014gKqFnQsbap92wRFrZu7u3
A live probe of a merged subject shows Dataverse joins date and time with an ordinary space. The seen in bodies is added by Dataverse's HTML re-serialisation of the body, which the mock does not reproduce. Claude-Session: https://claude.ai/code/session_014gKqFnQsbap92wRFrZu7u3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
SendEmailFromTemplate. The e-mail is built from the template, then created and sent through the existingCreateandSendEmailhandlers, so security, plugins and status transitions behave like any other send.Why a renderer
Dataverse stores a template's subject and body as XSLT stylesheets, not plain text. Copying them verbatim would put raw markup in the e-mail and merge nothing.
EmailTemplateRendererreproduces the transform: the stylesheet runs against a<data>document built from the regarding record and the sending user.Verified against a live org
Every behaviour below was checked by running the same scenarios against a Dataverse environment.
Completed/Pending Send, regarding setfromsystemuserregardingobjectidleft emptyXmlException{GUID}uppercase / raw value /1or0/ as isTemplate id should be set.,Object id should be set.,Required field '…' is missing,Expected non-empty string.Does Not ExistKnown differences
1/2/2026 3:04 AM) on the stored value, money isN2without the currency symbol, doubles are not precision-padded. ). Subjects are not re-serialised, and the mock's subject matches.Related, out of scope
Dataverse returns
templatetypecodeas the logical name ("contact"), but the test metadata gives the attribute an option set, soDbRowonly accepts the integer object type code and silently drops a string. The handler acceptsOptionSetValue,intand the logical name; the tests set the attribute late-bound as an integer.15 tests across the handler and renderer, full suite green on
net10.0,net462builds clean.Reviewer note: plain-text (non-XSLT) templates now fail as they do in Dataverse rather than passing through. If the team would rather keep them lenient for test convenience, it is a small revert in
EmailTemplateRendererplus its test.https://claude.ai/code/session_014gKqFnQsbap92wRFrZu7u3