Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/XrmMockup365/Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,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
124 changes: 124 additions & 0 deletions src/XrmMockup365/Internal/EmailTemplateRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using Microsoft.Xrm.Sdk;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Xml;
using System.Xml.Xsl;

namespace DG.Tools.XrmMockup
{
/// <summary>
/// Renders Dynamics e-mail template content. In Dataverse a template's <c>subject</c> and
/// <c>body</c> attributes are XSLT stylesheets (method="text") that transform a
/// <c>&lt;data&gt;</c> document built from the records the e-mail draws from (the regarding
/// record and the sending user). This reproduces that mechanism so the merged text matches
/// what the platform produces.
/// </summary>
internal static class EmailTemplateRenderer
{
/// <summary>
/// Renders a single template field (subject or body).
/// </summary>
/// <param name="templateField">The raw template attribute value (an XSLT stylesheet in Dataverse).</param>
/// <param name="entitiesByLogicalName">
/// The records available to the template, keyed by logical name. Each becomes a child of
/// <c>&lt;data&gt;</c> (e.g. <c>&lt;contact&gt;</c>, <c>&lt;systemuser&gt;</c>) with one
/// element per populated attribute.
/// </param>
/// <returns>
/// The merged text. If the field is not an XSLT stylesheet, or the transform fails, the
/// raw value is returned unchanged so plain-text templates still work.
/// </returns>
public static string Render(string templateField, IReadOnlyDictionary<string, Entity> entitiesByLogicalName)
{
if (string.IsNullOrWhiteSpace(templateField))
return templateField;

// Real Dataverse templates are XSLT. Anything else is treated as literal text.
if (templateField.IndexOf("xsl:stylesheet", StringComparison.OrdinalIgnoreCase) < 0)
return templateField;

try
{
var transform = new XslCompiledTransform();
using (var stringReader = new StringReader(templateField))
using (var xsltReader = XmlReader.Create(stringReader))
{
// Default XsltSettings: scripts and the document() function are disabled.
transform.Load(xsltReader);
}

var dataDocument = BuildDataDocument(entitiesByLogicalName);

using (var writer = new StringWriter(CultureInfo.InvariantCulture))
{
transform.Transform(dataDocument, null, writer);
return writer.ToString();
}
}
catch (Exception e) when (e is XmlException || e is XsltException)
{
// Not valid XSLT - fall back to the raw value rather than failing the send.
return templateField;
}
}

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);
if (string.IsNullOrEmpty(text))
continue;

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

return document;
}

private static string AttributeToString(object value)
{
switch (value)
{
case null:
return null;
case string s:
return s;
case EntityReference reference:
return reference.Name;
case OptionSetValue optionSet:
return optionSet.Value.ToString(CultureInfo.InvariantCulture);
case Money money:
return money.Value.ToString(CultureInfo.InvariantCulture);
case bool boolean:
return boolean ? "True" : "False";
case DateTime dateTime:
return dateTime.ToString(CultureInfo.InvariantCulture);
case IFormattable formattable:
return formattable.ToString(null, CultureInfo.InvariantCulture);
default:
return value.ToString();
}
}
}
}
89 changes: 89 additions & 0 deletions src/XrmMockup365/Requests/SendEmailFromTemplateRequestHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using DG.Tools.XrmMockup.Database;
using Microsoft.Crm.Sdk.Messages;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Messages;
using System;
using System.Collections.Generic;
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 throws when a referenced record does not exist; mirror that rather than
// silently sending an empty/unmerged e-mail.
private Entity RetrieveOrThrow(EntityReference reference)
{
return db.GetEntityOrNull(reference)
?? throw new FaultException($"{reference.LogicalName} With Id = {reference.Id} Does Not Exist");
}

// A template is bound to an entity type via templatetypecode (an object type code).
// Dataverse rejects a regarding record of a different type.
private void ValidateTemplateType(Entity template, string regardingType)
{
var templateTypeCode = (template.GetAttributeValue<OptionSetValue>("templatetypecode"))?.Value
?? template.GetAttributeValue<int?>("templatetypecode");
metadata.EntityMetadata.TryGetValue(regardingType, out var regardingMetadata);
var regardingTypeCode = regardingMetadata?.ObjectTypeCode;

if (templateTypeCode.HasValue && regardingTypeCode.HasValue &&
templateTypeCode.Value != regardingTypeCode.Value)
{
throw new FaultException(
$"The template type does not match the regarding object type '{regardingType}'.");
}
}

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

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

if (request.Target == null)
throw new FaultException("Target email is missing.");

if (request.Target.LogicalName != "email")
throw new FaultException("Target must be an email entity.");

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

if (string.IsNullOrEmpty(request.RegardingType))
throw new FaultException("Regarding type should be set.");

var template = RetrieveOrThrow(new EntityReference("template", request.TemplateId));
var regardingRef = new EntityReference(request.RegardingType, request.RegardingId);
var regarding = RetrieveOrThrow(regardingRef);

ValidateTemplateType(template, request.RegardingType);

// The template's subject/body are XSLT stylesheets rendered against the regarding
// record and the sending user. Verified against a live org: the merged values (and
// XSLT whitespace handling) match the platform.
var entities = new Dictionary<string, Entity> { [request.RegardingType] = regarding };
var sender = db.GetEntityOrNull(userRef);
if (sender != null)
entities[sender.LogicalName] = sender;

var email = request.Target;
email["regardingobjectid"] = regardingRef;
email["subject"] = EmailTemplateRenderer.Render(template.GetAttributeValue<string>("subject"), entities);
email["description"] = EmailTemplateRenderer.Render(template.GetAttributeValue<string>("body"), entities);

// Delegate to the existing Create and SendEmail handlers so plugins, security
// and status transitions are applied consistently.
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