Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
### 1.20.2 - UNRELEASED
* Add: `SendEmailFromTemplate` request support, rendering the template's XSLT subject/body against the regarding record and the sending user (#331)

### 1.20.1 - 1 September 2026
* Fix: Match alternate keys by value, not reference (#350)

Expand Down
1 change: 1 addition & 0 deletions src/XrmMockup365/Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ private void InitializeDB()
new InitializeFileBlocksDownloadRequestHandler(this, db, metadata, security),
new DownloadBlockRequestHandler(this, db, metadata, security),
new InstantiateTemplateRequestHandler(this, db, metadata, security),
new SendEmailFromTemplateRequestHandler(this, db, metadata, security),
new CreateMultipleRequestHandler(this, db, metadata, security),
new UpdateMultipleRequestHandler(this, db, metadata, security),
new DeleteMultipleRequestHandler(this, db, metadata, security),
Expand Down
133 changes: 133 additions & 0 deletions src/XrmMockup365/Internal/EmailTemplateRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using Microsoft.Xrm.Sdk;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.ServiceModel;
using System.Xml;
using System.Xml.Xsl;

namespace DG.Tools.XrmMockup
{
/// <summary>
/// Renders an e-mail template's subject or body. Dataverse stores these as XSLT stylesheets
/// that transform a <c>&lt;data&gt;</c> document built from the records the e-mail draws from.
/// A value that is not a stylesheet fails the send, as it does in Dataverse.
/// </summary>
internal static class EmailTemplateRenderer
{
/// <param name="entitiesByLogicalName">
/// Records the stylesheet may select from. Keys become element names, so a contact keyed
/// "contact" is addressed as <c>contact/lastname</c>.
/// </param>
public static string Render(string templateField, IReadOnlyDictionary<string, Entity> entitiesByLogicalName)
{
if (string.IsNullOrEmpty(templateField))
return templateField;

try
{
var transform = new XslCompiledTransform();
using (var stringReader = new StringReader(templateField))
using (var xsltReader = XmlReader.Create(stringReader))
{
// A template is untrusted data: no scripts, no document(), and a null resolver
// so xsl:import cannot pull a mock run onto the network.
transform.Load(xsltReader, XsltSettings.Default, null);
}

using (var writer = new StringWriter(CultureInfo.InvariantCulture))
{
transform.Transform(BuildDataDocument(entitiesByLogicalName), null, writer);
return writer.ToString();
}
}
catch (Exception e) when (e is XmlException || e is XsltException)
{
// Dataverse rejects a subject or body that is not XML, plain text included, with
// this message. Passing the text through would let a template that fails in
// Dataverse succeed in the mock. XslCompiledTransform wraps the parse error in an
// XsltException, so the inner exception is checked too.
var xmlError = (e as XmlException) ?? e.InnerException as XmlException;
if (xmlError != null)
throw new FaultException($"XmlException '{xmlError.Message}' \n xslXml is {templateField}");

// Dataverse only says "An unexpected error occurred." here; the cause is more useful.
throw new FaultException($"The e-mail template could not be rendered: {e.Message}");
}
}

/// <summary>Builds the document the stylesheet selects its merge values from.</summary>
private static XmlDocument BuildDataDocument(IReadOnlyDictionary<string, Entity> entitiesByLogicalName)
{
var document = new XmlDocument();
var dataElement = document.CreateElement("data");
document.AppendChild(dataElement);

if (entitiesByLogicalName == null)
return document;

foreach (var pair in entitiesByLogicalName)
{
if (pair.Value == null || string.IsNullOrEmpty(pair.Key))
continue;

var entityElement = document.CreateElement(pair.Key);
dataElement.AppendChild(entityElement);

foreach (var attribute in pair.Value.Attributes)
{
var text = AttributeToString(attribute.Value);

// An empty element and a missing one differ to the stylesheet: xsl:when treats
// a missing node as false and falls through to its xsl:otherwise default.
if (string.IsNullOrEmpty(text))
continue;

var attributeElement = document.CreateElement(attribute.Key);
attributeElement.InnerText = text;
entityElement.AppendChild(attributeElement);
}
}

return document;
}

/// <summary>
/// Flattens an attribute to the text the stylesheet will select. Lookup, option set, boolean
/// and integer formats were checked against a live org. Dataverse formats dates, money and
/// floating point numbers per the user's settings and the currency, which the mock does not
/// model; those use Dataverse's 1033 defaults or the invariant culture.
/// </summary>
private static string AttributeToString(object value)
{
switch (value)
{
case null:
return null;
case string s:
return s;
case EntityReference reference:
// The record id, not the display name.
return reference.Id.ToString("B").ToUpperInvariant();
case OptionSetValue optionSet:
// The raw value, not the option label.
return optionSet.Value.ToString(CultureInfo.InvariantCulture);
case bool boolean:
return boolean ? "1" : "0";
case DateTime dateTime:
// Dataverse's 1033 defaults, in the user's time zone. The mock formats the
// value as stored. (In a body the space arrives as &nbsp;, added by Dataverse's
// HTML re-serialisation rather than by the merge itself.)
return dateTime.ToString("M/d/yyyy h:mm tt", CultureInfo.InvariantCulture);
case Money money:
// Dataverse prefixes the currency symbol, e.g. "kr.1,234.50".
return money.Value.ToString("N2", CultureInfo.InvariantCulture);
case IFormattable formattable:
return formattable.ToString(null, CultureInfo.InvariantCulture);
default:
return value.ToString();
}
}
}
}
165 changes: 165 additions & 0 deletions src/XrmMockup365/Requests/SendEmailFromTemplateRequestHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using DG.Tools.XrmMockup.Database;
using DG.Tools.XrmMockup.Internal;
using Microsoft.Crm.Sdk.Messages;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Messages;
using Microsoft.Xrm.Sdk.Metadata;
using System;
using System.Collections.Generic;
using System.Linq;
using System.ServiceModel;

namespace DG.Tools.XrmMockup
{
internal class SendEmailFromTemplateRequestHandler : RequestHandler
{
public SendEmailFromTemplateRequestHandler(Core core, XrmDb db, MetadataSkeleton metadata, Security security) : base(core, db, metadata, security, "SendEmailFromTemplate") { }

// Dataverse fails the send rather than merging an unreadable record as blanks.
private Entity RetrieveOrThrow(EntityReference reference, EntityReference userRef)
{
var entity = db.GetEntityOrNull(reference)
?? throw new FaultException($"{reference.LogicalName} With Id = {reference.Id} Does Not Exist");

if (!security.HasPermission(entity, AccessRights.ReadAccess, userRef))
throw new FaultException($"Calling user with id '{userRef.Id}' does not have permission to read entity '{reference.LogicalName}'");

return entity;
}

// Depending on the attribute's metadata (see DbAttributeTypeMap), templatetypecode is stored
// as an OptionSetValue, an int or the logical name. Dataverse itself returns the logical name.
private int? GetTemplateTypeCode(Entity template)
{
switch (template.GetAttributeValue<object>("templatetypecode"))
{
case OptionSetValue optionSet:
return optionSet.Value;
case int typeCode:
return typeCode;
case string logicalName:
metadata.EntityMetadata.TryGetValue(logicalName, out var typeMetadata);
return typeMetadata?.ObjectTypeCode;
default:
return null;
}
}

// Dataverse rejects a regarding record whose type differs from the template's, and does so
// before it looks the regarding record up.
private void ValidateTemplateType(Entity template, string regardingType)
{
var templateTypeCode = GetTemplateTypeCode(template);
metadata.EntityMetadata.TryGetValue(regardingType, out var regardingMetadata);
var regardingTypeCode = regardingMetadata?.ObjectTypeCode;

if (templateTypeCode == null || regardingTypeCode == null)
return;

if (templateTypeCode != regardingTypeCode)
{
throw new FaultException(
$"Template type is incorrect for given objectType {regardingTypeCode} != {templateTypeCode} template.templatetypecode");
}
}

// Dataverse leaves regardingobjectid empty when the lookup cannot target the regarding
// type, as with a systemuser template.
private bool CanBeRegarding(string logicalName)
{
if (!metadata.EntityMetadata.TryGetValue("email", out var emailMetadata))
return true;

var regarding = emailMetadata.Attributes?
.OfType<LookupAttributeMetadata>()
.FirstOrDefault(a => a.LogicalName == "regardingobjectid");

return regarding?.Targets == null || regarding.Targets.Contains(logicalName);
}

private static bool HasSender(Entity email)
{
return email.GetAttributeValue<EntityCollection>("from")?.Entities.Count > 0;
}

// Dataverse returns the merged body wrapped in a minimal HTML document, with LF line
// breaks and a trailing newline, rather than as bare text. It also re-serialises the
// markup inside (lower-cased tags, line breaks around block elements), which the mock
// leaves as the stylesheet produced it.
private static string WrapInHtmlEnvelope(string body)
{
if (body == null)
return null;

return "<html>\n<head>\n" +
"<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">\n" +
"</head>\n<body>\n" + body + "\n</body>\n</html>\n";
}

internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef)
{
var request = MakeRequest<SendEmailFromTemplateRequest>(orgRequest);

// Messages and their order follow Dataverse.
if (request.TemplateId == Guid.Empty)
throw new FaultException("Template id should be set.");

if (request.Target == null)
throw new FaultException("Required field 'Target' is missing for RequestName='SendEmailFromTemplate'");

if (request.Target.LogicalName != "email")
throw new FaultException($"Cannot merge 2 Business entities of different types. Current Entity Type: {request.Target.LogicalName}, Entity To Merge Type: email");

if (request.RegardingId == Guid.Empty)
throw new FaultException("Object id should be set.");

if (request.RegardingType == null)
throw new FaultException("Required field 'RegardingType' is missing for RequestName='SendEmailFromTemplate'");

if (request.RegardingType.Length == 0)
throw new FaultException("Expected non-empty string.");

var template = RetrieveOrThrow(new EntityReference("template", request.TemplateId), userRef);
ValidateTemplateType(template, request.RegardingType);

var regardingRef = new EntityReference(request.RegardingType, request.RegardingId);
var regarding = RetrieveOrThrow(regardingRef, userRef);

// The stylesheet addresses the regarding record by its logical name and the sending
// user as "systemuser". When the regarding record is itself a systemuser, Dataverse
// still merges the sender, so the sender is added last and wins the key.
var entities = new Dictionary<string, Entity> { [request.RegardingType] = regarding };
var sender = db.GetEntityOrNull(userRef);
if (sender != null)
entities[sender.LogicalName] = sender;

// Dataverse works on its own copy and leaves the caller's Target untouched.
var email = request.Target.CloneEntity();

if (CanBeRegarding(request.RegardingType))
email["regardingobjectid"] = regardingRef;

// Dataverse sends from the caller when the e-mail names no sender.
if (!HasSender(email))
{
email["from"] = new EntityCollection(new List<Entity>
{
new Entity("activityparty") { ["partyid"] = userRef }
});
}

email["subject"] = EmailTemplateRenderer.Render(template.GetAttributeValue<string>("subject"), entities);
email["description"] = WrapInHtmlEnvelope(
EmailTemplateRenderer.Render(template.GetAttributeValue<string>("body"), entities));

// Going through Create and SendEmail keeps plugins, security and status consistent.
var emailId = ((CreateResponse)core.Execute(new CreateRequest { Target = email }, userRef)).id;
core.Execute(new SendEmailRequest { EmailId = emailId, IssueSend = true }, userRef);

return new SendEmailFromTemplateResponse
{
Results = new ParameterCollection { { "Id", emailId } }
};
}
}
}
Loading
Loading