Skip to content

Implement SendEmailFromTemplate - #331

Open
Bircck wants to merge 14 commits into
masterfrom
worktree-send-email-from-template
Open

Bircck wants to merge 14 commits into
masterfrom
worktree-send-email-from-template

Conversation

@Bircck

@Bircck Bircck commented Jun 2, 2026

Copy link
Copy Markdown

Adds SendEmailFromTemplate. The e-mail is built from the template, then created and sent through the existing Create and SendEmail handlers, 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. EmailTemplateRenderer reproduces 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.

Behaviour Dataverse Mock
Created e-mail Completed / Pending Send, regarding set same
Body wrapped in an HTML envelope same envelope, byte for byte
Subject template replaces the caller's subject same
Missing from sends from the caller same
Template regarding a systemuser merges the sender, not the regarding user; regardingobjectid left empty same
Plain-text subject or body fails with an XmlException same message
Lookup / option set / boolean / integer merge {GUID} uppercase / raw value / 1 or 0 / as is same
Template type mismatch checked before the regarding record is looked up same message and order
Guard messages Template id should be set., Object id should be set., Required field '…' is missing, Expected non-empty string. same
Missing template or regarding record Does Not Exist mock's usual wording

Known differences

  • Dates, money and floating point depend on the user's format settings, time zone and the currency, which the mock does not model. Dates use Dataverse's 1033 default (1/2/2026 3:04 AM) on the stored value, money is N2 without the currency symbol, doubles are not precision-padded.
  • Markup inside the envelope is left as the stylesheet produced it. Dataverse re-serialises the body (lower-cased tags, line breaks around block elements, wrapped long lines, spaces in merged dates turned into &nbsp;). Subjects are not re-serialised, and the mock's subject matches.
  • Tracking token on the subject is org-configurable and not reproduced.
  • Sender vs. caller could not be told apart: the test org has no second user allowed to send on behalf of others. The mock merges the caller.

Related, out of scope

Dataverse returns templatetypecode as the logical name ("contact"), but the test metadata gives the attribute an option set, so DbRow only accepts the integer object type code and silently drops a string. The handler accepts OptionSetValue, int and 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, net462 builds 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 EmailTemplateRenderer plus its test.

https://claude.ai/code/session_014gKqFnQsbap92wRFrZu7u3

@Bircck
Bircck force-pushed the worktree-send-email-from-template branch from 86b7e1e to f0dbd26 Compare September 2, 2026 07:29
@Bircck
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.
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 &nbsp; 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant