diff --git a/README.md b/README.md index bd406cb..5e1e25f 100644 --- a/README.md +++ b/README.md @@ -24,34 +24,65 @@ Pre-release packages are available on my MyGet feed: ++ **7.1** + - Add support for dynamic content which CakeMail already supports when sending bulk emails. In other words, you can have `IF ... ELSEIF ... ELSE ... ENDIF` in your HTML content, text content and subject as well. + - Please keep in mind the following the rules established by CakeMail for dynamic content: + - `IF`, `ELSEIF`, `ELSE` and `ENDIF` must be upper case which means that ``[IF `myfield` = "myValue"]`` is valid but ``[if `myfield` = "myValue"]`` is not. + - square bracket is the delimeter which means that ``[IF `myfield` = "myValue"]`` is valid but ``{IF `myfield` = "myValue"}`` is not. + - the name of the data field must be surrounded by back ticks (not to be confused with single quotes) which means that ``[IF `firstname` = "Bob"]`` is valid but ``[IF 'firstname' = "Bob"]`` is not. + - you can only compare a field to a constant value which means that ``[IF `firstname` = "Bob"]`` is valid but ``[IF `firstname` = `nickname`]`` is not. + - the constant value must be surrounded with double quotes when it's a string which means that ``[IF `firstname` = "Bob"]`` is valid but ``[IF `firstname` = 'Bob']`` is not. + - the constant value must not be surrounded by any quotes is a numeric valu when it's a numeric value which means that ``[IF `age` >= 18]`` is valid. + - the data field must be on the left side of the comparison which means that ``[IF `gender` = \"Male\"]`` is valid but ``[IF \"Male\" = `gender`]`` is not. + - you can have multiple conditions seperated by `AND` or `OR` which means that ``IF `firstname` = "Bob" AND `lastname` = "Smith"]`` is valid. + - the acceptable operators when comparing a field to a string value: `<`, `<=`, `=`, `!=`, `>=`, `>`, `LIKE` and `NOT LIKE` + - the acceptable operators when comparing a field to a mumeric value: `<`, `<=`, `=`, `!=`, `>=` and `>` +```csharp +var subject = "Special sale ends today"; +var html = "[IF `gender` = \"Male\"]Men's clothing is on sale[ELSE]Women's clothing is on sale[ENDIF]"; +var text = "[IF `gender` = \"Male\"]Men's clothing is on sale[ELSE]Women's clothing is on sale[ENDIF]"; +var mergeData = new Dictionary +{ + { "gender", "Male" } +}; +var sent = await api.Relays.SendWithoutTrackingAsync(userKey, "recipient@example.com", subject, html, text, "you@yourcompany.com", "Your name", mergeData, null, null, clientId).ConfigureAwait(false); +``` + + **7.0** - - Add support for merge fields in html, text content and subject line when sending an email. CakeMail already support merge fields when sending bulk emails. + - Add support for merge fields in html, text content and subject line when sending an email. CakeMail already supports merge fields when sending bulk emails. + - Please keep in mind the following the rules for merge fields: + - square bracket is the delimeter which means that `[firstname]` is valid but `{firstname}` is not. + - you can specify a default value (sometimes called "fallback value") by adding a comma after the field name followed by the desired value. This default value is used when the value for the data field is undefined. For example: `Dear [firstname, friend]` will result in `Dear friend` if the firstname field is omitted or contains a null value for the current recipient. + - you can use `[TODAY]` to print the current date. Please note that `[NOW]` and `[DATE]` are also acceptable. + - when the merge field contains a numeric value or a datetime or when you use the `[TODAY]` merge field, you can specify a format string by adding the pipe character after the field name followed by the desired format string like so: `[TODAY | MMM d yyyy]`. + - Documentation for [datetime format string](https://msdn.microsoft.com/en-us/library/8kb3ddd4(v=vs.110).aspx) + - Documentation for [numeric format string](https://msdn.microsoft.com/en-us/library/0c899ak8(v=vs.110).aspx) ```csharp var subject = "Special sale ends today"; var html = "Dear [firstname, friend], our annual sale ends today [TODAY | MMMM d]."; var text = "[salutation] [lastname], our annual sale ends today [TODAY | MMMM d]."; var mergeData = new Dictionary { - { "salutation", "Mr." }, - { "firstname", "Bob" }, - { "lastname", "Smith" } + { "salutation", "Mr." }, + { "firstname", "Bob" }, + { "lastname", "Smith" } }; var sent = await api.Relays.SendWithoutTrackingAsync(userKey, "recipient@example.com", subject, html, text, "you@yourcompany.com", "Your name", mergeData, null, null, clientId).ConfigureAwait(false); ``` + **6.0** - - Fix bug when retrieving a Client record and the 'last_activity' field contains empty date - - Add support for .NET STANDARD 1.3 - - Replace RestSharp with PathosChild.Http.FluentClient - - Switch unit testing to xUnit - - Implement GitFlow and repeatable build process + - Fix bug when retrieving a Client record and the 'last_activity' field contains empty date + - Add support for .NET STANDARD 1.3 + - Replace RestSharp with PathosChild.Http.FluentClient + - Switch unit testing to xUnit + - Implement GitFlow and repeatable build process + **5.0** - - Upgraded to .NET 4.5.2 + - Upgraded to .NET 4.5.2 + **4.0** - - All methods are now async. - - You can pass a cancellation token when invoking an async method. + - All methods are now async. + - You can pass a cancellation token when invoking an async method. This means, for example, that the following v3.0 call: @@ -66,8 +97,8 @@ var count = await cakeMail.Campaigns.GetCountAsync(userKey, MailingStatus.Ongoin ``` + **3.0** - - Methods are now logically grouped in separate resources. For instance, all methods related to users are grouped in a resource called 'Users', all methods related to campaigns are grouped in a resource called 'Campaigns', and so on. - - Methods have been renamed to avoid repetition. For example, GetCampaignsCount has been renamed GetCount off of the new 'Campaigns' resource. + - Methods are now logically grouped in separate resources. For instance, all methods related to users are grouped in a resource called 'Users', all methods related to campaigns are grouped in a resource called 'Campaigns', and so on. + - Methods have been renamed to avoid repetition. For example, GetCampaignsCount has been renamed GetCount off of the new 'Campaigns' resource. This means, for example, that the following v2.0 call: @@ -83,16 +114,16 @@ var count = cakeMail.Campaigns.GetCount(userKey, MailingStatus.Ongoing); + **2.0** - - Unique identifiers changed to 'long' instead of 'int'. - - "Magic strings" replaced with enums. For example, instead of specifying sort direction with 'asc' and 'desc', you can now use SortDirection.Ascending and SortDirection.Descending. - - Fix bug in CreateTemplateCategory which prevents creating new categories - - Fix bug in DeleteTemplateCategory which causes an exception to be thrown despite the fact the category was successfuly deleted - - Fix bug in GetListMembers which causes exception: 'Json does not contain property members' - - Fix GetTriggerLinksLogs - - Added XML comments file for convenient intellisense in Visual Studio + - Unique identifiers changed to 'long' instead of 'int'. + - "Magic strings" replaced with enums. For example, instead of specifying sort direction with 'asc' and 'desc', you can now use SortDirection.Ascending and SortDirection.Descending. + - Fix bug in CreateTemplateCategory which prevents creating new categories + - Fix bug in DeleteTemplateCategory which causes an exception to be thrown despite the fact the category was successfuly deleted + - Fix bug in GetListMembers which causes exception: 'Json does not contain property members' + - Fix GetTriggerLinksLogs + - Added XML comments file for convenient intellisense in Visual Studio + **1.0** - - Initial release + - Initial release ## Installation @@ -161,14 +192,14 @@ You can add members to your list like so: await cakeMail.Lists.SubscribeAsync(userKey, listId, "bob_the_customer@hotmail.com", true, true, new[] { - new KeyValuePair("first_name", "Bob"), - new KeyValuePair("last_name", "Smith"), - new KeyValuePair("customer_since", DateTime.UtcNow) + new KeyValuePair("first_name", "Bob"), + new KeyValuePair("last_name", "Smith"), + new KeyValuePair("customer_since", DateTime.UtcNow) }); await cakeMail.Lists.SubscribeAsync(userKey, listId, "jane_the_prospect@hotmail.com", true, true, new[] { - new KeyValuePair("first_name", "Jane"), - new KeyValuePair("last_name", "Doe") + new KeyValuePair("first_name", "Jane"), + new KeyValuePair("last_name", "Doe") }); ``` @@ -177,23 +208,23 @@ or you can import a group of members: ```csharp var member1 = new ListMember() { - Email = "bob_the_customer@hotmail.com", - CustomFields = new Dictionary() - { - { "first_name", "Bob" }, - { "last_name", "Smith" }, - { "customer_since", DateTime.UtcNow } - } + Email = "bob_the_customer@hotmail.com", + CustomFields = new Dictionary() + { + { "first_name", "Bob" }, + { "last_name", "Smith" }, + { "customer_since", DateTime.UtcNow } + } }; var member2 = new ListMember() { - Email = "jane_the_prospect@hotmail.com", - CustomFields = new Dictionary() - { - { "first_name", "Jane" }, - { "last_name", "Doe" } - } + Email = "jane_the_prospect@hotmail.com", + CustomFields = new Dictionary() + { + { "first_name", "Jane" }, + { "last_name", "Doe" } + } }; var importResult = await cakeMail.Lists.ImportAsync(userKey, listId, new[] { member1, member2 }); diff --git a/Source/CakeMail.RestClient.UnitTests/Utilities/CakeMailContentParser.cs b/Source/CakeMail.RestClient.UnitTests/Utilities/CakeMailContentParser.cs new file mode 100644 index 0000000..b144470 --- /dev/null +++ b/Source/CakeMail.RestClient.UnitTests/Utilities/CakeMailContentParser.cs @@ -0,0 +1,537 @@ +using CakeMail.RestClient.Utilities; +using Shouldly; +using System; +using System.Collections.Generic; +using Xunit; + +namespace CakeMail.RestClient.UnitTests.Utilities +{ + public class CakeMailContentParserTests + { + [Fact] + public void Returns_empty_string_when_content_is_null() + { + // Arrange + var content = (string)null; + + // Act + var result = CakeMailContentParser.Parse(content, null); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public void Returns_empty_string_when_content_is_empty_string() + { + // Arrange + var content = string.Empty; + + // Act + var result = CakeMailContentParser.Parse(content, null); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public void Field_without_fallback_is_successfully_merged_when_data_provided() + { + // Arrange + var content = "Dear [firstname]"; + var data = new Dictionary + { + { "firstname", "Bob" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Dear Bob"); + } + + [Fact] + public void Field_without_fallback_is_merged_with_empty_string_when_data_is_omitted() + { + // Arrange + var content = "Dear [firstname]"; + var data = new Dictionary + { + { "lastname", "Smith" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Dear "); + } + + [Fact] + public void Fallback_is_merged_when_data_is_omitted() + { + // Arrange + var content = "Dear [firstname,friend]"; + var data = new Dictionary + { + { "lastname", "Smith" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Dear friend"); + } + + [Fact] + public void Fallback_is_merged_when_null_value_is_specified() + { + // Arrange + var content = "Dear [firstname,friend]"; + var data = new Dictionary + { + { "firstname", null } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Dear friend"); + } + + [Fact] + public void Datetime_with_format() + { + // Arrange + var content = "Thank you for being a customer since [customer_since | yyyy]"; + var data = new Dictionary + { + { "customer_since", new DateTime(2017, 4, 30, 4, 57, 0) } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Thank you for being a customer since 2017"); + } + + [Fact] + public void Short_with_format() + { + // Arrange + var content = "We drove [miles_driven|#,#] miles during this trip"; + var data = new Dictionary + { + { "miles_driven", (short)12345 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("We drove 12,345 miles during this trip"); + } + + [Fact] + public void Int_with_format() + { + // Arrange + var content = "We drove [miles_driven|#,#] miles during this trip"; + var data = new Dictionary + { + { "miles_driven", (int)12345 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("We drove 12,345 miles during this trip"); + } + + [Fact] + public void Long_with_format() + { + // Arrange + var content = "We drove [miles_driven|#,#] miles during this trip"; + var data = new Dictionary + { + { "miles_driven", (long)12345 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("We drove 12,345 miles during this trip"); + } + + [Fact] + public void Decimal_with_format() + { + // Arrange + var content = "We drove [miles_driven|0.00] miles today"; + var data = new Dictionary + { + { "miles_driven", (decimal)12.345 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("We drove 12.35 miles today"); + } + + [Fact] + public void Float_with_format() + { + // Arrange + var content = "We drove [miles_driven|0.00] miles today"; + var data = new Dictionary + { + { "miles_driven", (float)12.345 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("We drove 12.35 miles today"); + } + + [Fact] + public void Double_with_format() + { + // Arrange + var content = "We drove [miles_driven|0.00] miles today"; + var data = new Dictionary + { + { "miles_driven", (double)12.345 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("We drove 12.35 miles today"); + } + + [Fact] + public void DATE_without_format() + { + // Arrange + var content = "The current date is: [DATE]"; + + // Act + var result = CakeMailContentParser.Parse(content, null); + + // Assert + Assert.StartsWith("The current date is: ", content); + } + + [Fact] + public void NOW_without_format() + { + // Arrange + var content = "The current date is: [NOW]"; + + // Act + var result = CakeMailContentParser.Parse(content, null); + + // Assert + Assert.StartsWith("The current date is: ", content); + } + + [Fact] + public void TODAY_without_format() + { + // Arrange + var content = "The current date is: [TODAY]"; + + // Act + var result = CakeMailContentParser.Parse(content, null); + + // Assert + Assert.StartsWith("The current date is: ", content); + } + + [Fact] + public void DATE_with_format() + { + // Arrange + var content = "The current year is: [DATE | yyyy]"; + + // Act + var result = CakeMailContentParser.Parse(content, null); + + // Assert + result.ShouldBe($"The current year is: {DateTime.UtcNow.Year}"); + } + + [Fact] + public void NOW_with_format() + { + // Arrange + var content = "The current year is: [NOW|yyyy]"; + + // Act + var result = CakeMailContentParser.Parse(content, null); + + // Assert + result.ShouldBe($"The current year is: {DateTime.UtcNow.Year}"); + } + + [Fact] + public void TODAY_with_format() + { + // Arrange + var content = "The current year is: [TODAY |yyyy]"; + + // Act + var result = CakeMailContentParser.Parse(content, null); + + // Assert + result.ShouldBe($"The current year is: {DateTime.UtcNow.Year}"); + } + + [Fact] + public void IF_true() + { + // Arrange + var content = "[IF `firstname` = \"Bob\"]Yes[ELSE]No[ENDIF]"; + var data = new Dictionary + { + { "firstname", "Bob" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Yes"); + } + + [Fact] + public void IF_false_with_ELSE() + { + // Arrange + var content = "[IF `firstname` = \"Robert\"]Yes[ELSE]No[ENDIF]"; + var data = new Dictionary + { + { "firstname", "Bob" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("No"); + } + + [Fact] + public void IF_false_without_ELSE() + { + // Arrange + var content = "[IF `firstname` = \"Robert\"]Yes[ENDIF]"; + var data = new Dictionary + { + { "firstname", "Bob" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public void IF_false_with_ELSEIF_true() + { + // Arrange + var content = "[IF `firstname` = \"Robert\"]Yes - Robert[ELSEIF `firstname` = \"Bob\"]Yes - Bob[ELSE]No[ENDIF]"; + var data = new Dictionary + { + { "firstname", "Bob" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Yes - Bob"); + } + + [Fact] + public void IF_false_with_ELSEIF_false() + { + // Arrange + var content = "[IF `firstname` = \"Robert\"]Yes - Robert[ELSEIF `firstname` = \"Bob\"]Yes - Bob[ELSE]No[ENDIF]"; + var data = new Dictionary + { + { "firstname", "Jim" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("No"); + } + + [Fact] + public void Two_conditions_both_true() + { + // Arrange + var content = "[IF `firstname` = \"Jim\" AND `lastname` = \"Halpert\"]Yes[ELSE]No[ENDIF]"; + var data = new Dictionary + { + { "firstname", "Jim" }, + { "lastname", "Halpert" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Yes"); + } + + [Fact] + public void Two_conditions_one_true_one_false_AND() + { + // Arrange + var content = "[IF `firstname` = \"Jim\" AND `lastname` = \"Halpert\"]Yes[ELSE]No[ENDIF]"; + var data = new Dictionary + { + { "firstname", "John" }, + { "lastname", "Halpert" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("No"); + } + + [Fact] + public void Two_conditions_one_true_one_false_OR() + { + // Arrange + var content = "[IF `firstname` = \"Jim\" OR `lastname` = \"Halpert\"]Yes[ELSE]No[ENDIF]"; + var data = new Dictionary + { + { "firstname", "John" }, + { "lastname", "Halpert" } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Yes"); + } + + [Fact] + public void Numeric_true() + { + // Arrange + var content = "[IF `age` > \"18\"]Yes[ELSE]No[ENDIF]"; + var data = new Dictionary + { + { "age", 25 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Yes"); + } + + [Fact] + public void Numeric_CAKEMAIL_BUG() + { + // CakeMail only allows comparing merge fields with string values (e.g.:[ IF `age` >= "18"]content for adults[ELSE]content for minors[ENDIF]) + // which does not work properly in some cases. Let's say we want to display display content for adults aged 18 or over and different content + // for minors and let's say you have a recipient age 9. We will treat 9 as a string (per the CakeMail logic) and compare it to the string "18". + // Well... guess what: the character "9" is greater than the character "1" (which is the first character in the string "18") and therefore this + // person who is clearly underage would receive an email with content intended to adults. Conversely, if you have a recipient age 100, this + // person would receive an email with the content for minors because "0" (the second character in the string "100") is smaller than "8" (the + // second character in the string "18"). + + // Arrange + var content = "[IF `age` > \"18\"]Adult[ELSE]Minor[ENDIF]"; + var data = new Dictionary + { + { "age", 9 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Adult"); + } + + [Fact] + public void Numeric_CAKEMAIL_BUG_Fix_false() + { + // The CakeMail.RestClient allows comparing merge fields with numeric values (e.g.: [ IF `age` >= 18]content for adults[ELSE]content for minors[ENDIF]) + // which solves the problem I describe in the Numeric_CAKEMAIL_BUG unit test. + + // Arrange + var content = "[IF `age` >= 18]Adult[ELSE]Minor[ENDIF]"; + var data = new Dictionary + { + { "age", 9 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Minor"); + } + + [Fact] + public void Numeric_CAKEMAIL_BUG_FIX_true() + { + // Arrange + var content = "[IF `age` >= 18]Adult[ELSE]Minor[ENDIF]"; + var data = new Dictionary + { + { "age", 18 } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Adult"); + } + + [Fact] + public void Numeric_CAKEMAIL_double() + { + // Arrange + var content = "[IF `amount` == 2.0]Yes[ELSE]No[ENDIF]"; + var data = new Dictionary + { + { "amount", Math.Sqrt(2) * Math.Sqrt(2) } + }; + + // Act + var result = CakeMailContentParser.Parse(content, data); + + // Assert + result.ShouldBe("Yes"); + } + } +} diff --git a/Source/CakeMail.RestClient.UnitTests/Utilities/CakeMailMergeFieldsParser.cs b/Source/CakeMail.RestClient.UnitTests/Utilities/CakeMailMergeFieldsParser.cs deleted file mode 100644 index 17fc450..0000000 --- a/Source/CakeMail.RestClient.UnitTests/Utilities/CakeMailMergeFieldsParser.cs +++ /dev/null @@ -1,305 +0,0 @@ -using CakeMail.RestClient.Utilities; -using Newtonsoft.Json; -using Shouldly; -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using Xunit; - -namespace CakeMail.RestClient.UnitTests.Utilities -{ - public class CakeMailMergeFieldsParserTests - { - [Fact] - public void Returns_empty_string_when_content_is_null() - { - // Arrange - var content = (string)null; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, null); - - // Assert - result.ShouldBeEmpty(); - } - - [Fact] - public void Returns_empty_string_when_content_is_empty_string() - { - // Arrange - var content = string.Empty; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, null); - - // Assert - result.ShouldBeEmpty(); - } - - [Fact] - public void Field_without_fallback_is_successfully_merged_when_data_provided() - { - // Arrange - var content = "Dear [firstname]"; - var data = new Dictionary - { - { "firstname", "Bob" } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("Dear Bob"); - } - - [Fact] - public void Field_without_fallback_is_merged_with_empty_string_when_data_is_omitted() - { - // Arrange - var content = "Dear [firstname]"; - var data = new Dictionary - { - { "lastname", "Smith" } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("Dear "); - } - - [Fact] - public void Fallback_is_merged_when_data_is_omitted() - { - // Arrange - var content = "Dear [firstname,friend]"; - var data = new Dictionary - { - { "lastname", "Smith" } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("Dear friend"); - } - - [Fact] - public void Fallback_is_merged_when_null_value_is_specified() - { - // Arrange - var content = "Dear [firstname,friend]"; - var data = new Dictionary - { - { "firstname", null } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("Dear friend"); - } - - [Fact] - public void Datetime_with_format() - { - // Arrange - var content = "Thank you for being a customer since [customer_since | yyyy]"; - var data = new Dictionary - { - { "customer_since", new DateTime(2017, 4, 30, 4, 57, 0) } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("Thank you for being a customer since 2017"); - } - - [Fact] - public void Short_with_format() - { - // Arrange - var content = "We drove [miles_driven|#,#] miles during this trip"; - var data = new Dictionary - { - { "miles_driven", (short)12345 } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("We drove 12,345 miles during this trip"); - } - - [Fact] - public void Int_with_format() - { - // Arrange - var content = "We drove [miles_driven|#,#] miles during this trip"; - var data = new Dictionary - { - { "miles_driven", (int)12345 } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("We drove 12,345 miles during this trip"); - } - - [Fact] - public void Long_with_format() - { - // Arrange - var content = "We drove [miles_driven|#,#] miles during this trip"; - var data = new Dictionary - { - { "miles_driven", (long)12345 } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("We drove 12,345 miles during this trip"); - } - - [Fact] - public void Decimal_with_format() - { - // Arrange - var content = "We drove [miles_driven|0.00] miles today"; - var data = new Dictionary - { - { "miles_driven", (decimal)12.345 } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("We drove 12.35 miles today"); - } - - [Fact] - public void Float_with_format() - { - // Arrange - var content = "We drove [miles_driven|0.00] miles today"; - var data = new Dictionary - { - { "miles_driven", (float)12.345 } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("We drove 12.35 miles today"); - } - - [Fact] - public void Double_with_format() - { - // Arrange - var content = "We drove [miles_driven|0.00] miles today"; - var data = new Dictionary - { - { "miles_driven", (double)12.345 } - }; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, data); - - // Assert - result.ShouldBe("We drove 12.35 miles today"); - } - - [Fact] - public void DATE_without_format() - { - // Arrange - var content = "The current date is: [DATE]"; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, null); - - // Assert - Assert.StartsWith("The current date is: ", content); - } - - [Fact] - public void NOW_without_format() - { - // Arrange - var content = "The current date is: [NOW]"; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, null); - - // Assert - Assert.StartsWith("The current date is: ", content); - } - - [Fact] - public void TODAY_without_format() - { - // Arrange - var content = "The current date is: [TODAY]"; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, null); - - // Assert - Assert.StartsWith("The current date is: ", content); - } - - [Fact] - public void DATE_with_format() - { - // Arrange - var content = "The current year is: [DATE | yyyy]"; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, null); - - // Assert - result.ShouldBe($"The current year is: {DateTime.UtcNow.Year}"); - } - - [Fact] - public void NOW_with_format() - { - // Arrange - var content = "The current year is: [NOW|yyyy]"; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, null); - - // Assert - result.ShouldBe($"The current year is: {DateTime.UtcNow.Year}"); - } - - [Fact] - public void TODAY_with_format() - { - // Arrange - var content = "The current year is: [TODAY |yyyy]"; - - // Act - var result = CakeMailMergeFieldsParser.Parse(content, null); - - // Assert - result.ShouldBe($"The current year is: {DateTime.UtcNow.Year}"); - } - } -} diff --git a/Source/CakeMail.RestClient/Resources/Relays.cs b/Source/CakeMail.RestClient/Resources/Relays.cs index 81617f1..1a15afd 100644 --- a/Source/CakeMail.RestClient/Resources/Relays.cs +++ b/Source/CakeMail.RestClient/Resources/Relays.cs @@ -44,9 +44,9 @@ public Relays(IClient client) /// True if the email is sent public Task SendWithoutTrackingAsync(string userKey, string recipientEmailAddress, string subject, string html, string text, string senderEmail, string senderName = null, IDictionary mergeData = null, MessageEncoding? encoding = null, long? clientId = null, CancellationToken cancellationToken = default(CancellationToken)) { - subject = CakeMailMergeFieldsParser.Parse(subject, mergeData); - html = CakeMailMergeFieldsParser.Parse(html, mergeData); - text = CakeMailMergeFieldsParser.Parse(text, mergeData); + subject = CakeMailContentParser.Parse(subject, mergeData); + html = CakeMailContentParser.Parse(html, mergeData); + text = CakeMailContentParser.Parse(text, mergeData); var parameters = new List> { @@ -89,9 +89,9 @@ public Relays(IClient client) /// True if the email is sent public Task SendWithTrackingAsync(string userKey, long trackingId, string recipientEmailAddress, string subject, string html, string text, string senderEmail, string senderName = null, IDictionary mergeData = null, MessageEncoding? encoding = null, long? clientId = null, CancellationToken cancellationToken = default(CancellationToken)) { - subject = CakeMailMergeFieldsParser.Parse(subject, mergeData); - html = CakeMailMergeFieldsParser.Parse(html, mergeData); - text = CakeMailMergeFieldsParser.Parse(text, mergeData); + subject = CakeMailContentParser.Parse(subject, mergeData); + html = CakeMailContentParser.Parse(html, mergeData); + text = CakeMailContentParser.Parse(text, mergeData); var parameters = new List> { diff --git a/Source/CakeMail.RestClient/Utilities/CakeMailContentParser.cs b/Source/CakeMail.RestClient/Utilities/CakeMailContentParser.cs new file mode 100644 index 0000000..72547f0 --- /dev/null +++ b/Source/CakeMail.RestClient/Utilities/CakeMailContentParser.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace CakeMail.RestClient.Utilities +{ + public static class CakeMailContentParser + { + private const string DYNAMIC_CONTENT_START_TAG = @"\[IF (.*?)\]"; + private const string DYNAMIC_CONTENT_END_TAG = @"\[ENDIF\]"; + private const string DYNAMIC_CONTENT_ELSEIF_TAG = "\\[ELSEIF (.*?)\\]"; + private const string DYNAMIC_CONTENT_ELSE_TAG = "\\[ELSE\\]"; + private const string DYNAMIC_CONTENT_CONDITION_STRING = "`(.*?)` (<=|>=|<|>|!=|==|=) \"(.*?)\""; + private const string DYNAMIC_CONTENT_CONDITION_NUMERIC = "`(.*?)` (<=|>=|<|>|!=|==|=) (.*?)$"; + + private static readonly Regex _currentDateRegex = new Regex(@"\[(NOW|TODAY|DATE)\s*(?:\|\s*(.*?))?\]", RegexOptions.Compiled); + private static readonly Regex _mergeFieldsRegex = new Regex(@"\[(.*?)\s*(?:\|\s*(.*?))?\]", RegexOptions.Compiled); + + public static string Parse(string content, IDictionary data) + { + if (string.IsNullOrEmpty(content)) + { + return string.Empty; + } + + var mergedContent = ParseDynamicContent(content, data); + mergedContent = _currentDateRegex.Replace(mergedContent, (Match m) => CurrentDateMatchEval(m)); + mergedContent = _mergeFieldsRegex.Replace(mergedContent, (Match m) => MergeFieldMatchEval(m, data)); + return mergedContent; + } + + private static string CurrentDateMatchEval(Match m) + { + var format = (m.Groups.Count >= 3 ? m.Groups[2] : null)?.Value ?? string.Empty; + var dateAsString = DateTime.UtcNow.ToString(format); + + return dateAsString; + } + + private static string MergeFieldMatchEval(Match m, IDictionary data) + { + var group1 = m.Groups[1].Value.Split(','); + var group2 = m.Groups.Count >= 3 ? m.Groups[2] : null; + + var fieldName = group1[0].Trim(); + var fallbackValue = (group1.Length >= 2 ? group1[1] : string.Empty).Trim(); + var format = group2?.Value?.Trim() ?? string.Empty; + + var returnValue = FieldDataAsString(fieldName, data, format, fallbackValue); + return returnValue; + } + + private static string ParseDynamicContent(string content, IDictionary data) + { + if (string.IsNullOrEmpty(content)) return string.Empty; + + var matchStartCondition = Regex.Match(content, DYNAMIC_CONTENT_START_TAG); + var matchEndCondition = Regex.Match(content, DYNAMIC_CONTENT_END_TAG); + + // Make sure there is at least one "IF" statement + if (!matchStartCondition.Success) return content; + + // Make sure the number of "IF" match the number of "ENDIF" + if (matchStartCondition.Success != matchEndCondition.Success || matchStartCondition.Captures.Count != matchEndCondition.Captures.Count) + { + throw new Exception("Dynamic content does not seem valid. Typically this is caused by a \"IF\" condition missing the corresponding \"ENDIF\""); + } + + // Make sure the "ENDIF" does not preceed the "IF" + if (matchEndCondition.Index < matchStartCondition.Index + matchStartCondition.Length) return content; + + var condition = matchStartCondition.Groups[1].Value; + var conditionalContent = content.Substring(matchStartCondition.Index + matchStartCondition.Length, matchEndCondition.Index - matchStartCondition.Index - matchStartCondition.Length); + + var isTrue = EvaluateCondition(condition, data); + var parsedConditionalContent = ParseConditionalContent(isTrue, conditionalContent, data); + + var parsedContent = new StringBuilder(); + parsedContent.Append(content.Substring(0, matchStartCondition.Index)); + parsedContent.Append(parsedConditionalContent); + parsedContent.Append(content.Substring(matchEndCondition.Index + matchEndCondition.Length)); + + return ParseDynamicContent(parsedContent.ToString(), data); + } + + private static string ParseConditionalContent(bool condition, string content, IDictionary data) + { + if (string.IsNullOrEmpty(content)) return content; + + var matchCondition = Regex.Match(content, DYNAMIC_CONTENT_ELSEIF_TAG); + if (matchCondition.Success && condition) return content.Substring(0, matchCondition.Index); + + if (!matchCondition.Success) + { + matchCondition = Regex.Match(content, DYNAMIC_CONTENT_ELSE_TAG); + if (matchCondition.Success) + { + if (condition) return content.Substring(0, matchCondition.Index); + else return content.Substring(matchCondition.Index + matchCondition.Length); + } + else if (condition) + { + return content; + } + else + { + return string.Empty; + } + } + + var isTrue = EvaluateCondition(matchCondition.Groups[1].Value, data); + var conditionalContent = ParseConditionalContent(isTrue, content.Substring(matchCondition.Index + matchCondition.Length), data); + + return conditionalContent; + } + + private static bool EvaluateCondition(string condition, IDictionary data) + { + return EvaluateCondition(condition, " AND ", data); + } + + private static bool EvaluateCondition(string condition, string logicalOperator, IDictionary data) + { + if (string.IsNullOrEmpty(condition)) return false; + + var subConditions = Regex.Split(condition, logicalOperator); + if (subConditions == null || subConditions.Length == 0) + { + return false; + } + else if (subConditions.Length > 1) + { + foreach (var subCondition in subConditions) + { + var result = EvaluateCondition(subCondition, data); + if (logicalOperator == " AND " && !result) return false; + else if (logicalOperator == " OR " && result) return true; + } + + return logicalOperator == " AND "; + } + else if (logicalOperator == " AND ") + { + return EvaluateCondition(condition, " OR ", data); + } + else + { + return EvaluateSingleCondition(condition, data); + } + } + + private static bool EvaluateSingleCondition(string condition, IDictionary data) + { + var matches = Regex.Match(condition, DYNAMIC_CONTENT_CONDITION_STRING); + if (matches.Success) + { + switch (matches.Groups[2].Value) + { + case "=": + case "==": + return IsEqual(matches.Groups[1].Value, matches.Groups[3].Value, data); + case "!=": + return !IsEqual(matches.Groups[1].Value, matches.Groups[3].Value, data); + case "LIKE": + return IsLike(matches.Groups[1].Value, matches.Groups[3].Value, data); + case "NOT LIKE": + return !IsLike(matches.Groups[1].Value, matches.Groups[3].Value, data); + case "<=": + return IsSmaller(matches.Groups[1].Value, matches.Groups[3].Value, data) || IsEqual(matches.Groups[1].Value, matches.Groups[3].Value, data); + case ">=": + return IsGreater(matches.Groups[1].Value, matches.Groups[3].Value, data) || IsEqual(matches.Groups[1].Value, matches.Groups[3].Value, data); + case "<": + return IsSmaller(matches.Groups[1].Value, matches.Groups[3].Value, data); + case ">": + return IsGreater(matches.Groups[1].Value, matches.Groups[3].Value, data); + } + } + + matches = Regex.Match(condition, DYNAMIC_CONTENT_CONDITION_NUMERIC); + if (matches.Success) + { + switch (matches.Groups[2].Value) + { + case "=": + case "==": + return IsEqualNumeric(matches.Groups[1].Value, matches.Groups[3].Value, data); + case "!=": + return !IsEqualNumeric(matches.Groups[1].Value, matches.Groups[3].Value, data); + case "<=": + return IsSmallerNumeric(matches.Groups[1].Value, matches.Groups[3].Value, data) || IsEqualNumeric(matches.Groups[1].Value, matches.Groups[3].Value, data); + case ">=": + return IsGreaterNumeric(matches.Groups[1].Value, matches.Groups[3].Value, data) || IsEqualNumeric(matches.Groups[1].Value, matches.Groups[3].Value, data); + case "<": + return IsSmallerNumeric(matches.Groups[1].Value, matches.Groups[3].Value, data); + case ">": + return IsGreaterNumeric(matches.Groups[1].Value, matches.Groups[3].Value, data); + } + } + + return false; + } + + private static bool IsEqual(string fieldName, string value, IDictionary data) + { + value = value.TrimStart(new char[] { '"', '\'' }).TrimEnd(new char[] { '"', '\'' }); + if (!data.ContainsKey(fieldName)) return string.IsNullOrEmpty(value); + return FieldDataAsString(fieldName, data, null, null) == value; + } + + private static bool IsEqualNumeric(string fieldName, string value, IDictionary data) + { + var fieldData = (object)null; + data.TryGetValue(fieldName, out fieldData); + + if (fieldData is short) return (short)fieldData == Convert.ToInt16(value); + else if (fieldData is int) return (int)fieldData == Convert.ToInt32(value); + else if (fieldData is long) return (long)fieldData == Convert.ToInt64(value); + else if (fieldData is decimal) return (decimal)fieldData == Convert.ToDecimal(value); + + // Cannot compare float/double because they are stored as binary fractions, not decimal fractions. + // Explanation: https://stackoverflow.com/questions/21895756/why-are-floating-point-numbers-inaccurate + // A solution described in this blog post (https://csharp.2000things.com/2011/09/21/416-use-an-epsilon-to-compare-two-floating-point-numbers/) suggests checking if the two values are "nearly" identical + else if (fieldData is float) return Math.Abs((float)fieldData - Convert.ToSingle(value)) < 0.00001; + else if (fieldData is double) return Math.Abs((double)fieldData - Convert.ToDouble(value)) < 0.00001; + + return false; + } + + private static bool IsSmaller(string fieldName, string value, IDictionary data) + { + if (!data.ContainsKey(fieldName)) return false; + value = value.TrimStart(new char[] { '"', '\'' }).TrimEnd(new char[] { '"', '\'' }); + return string.Compare(data[fieldName].ToString(), value, StringComparison.Ordinal) < 0; + } + + private static bool IsSmallerNumeric(string fieldName, string value, IDictionary data) + { + var fieldData = (object)null; + data.TryGetValue(fieldName, out fieldData); + + if (fieldData is short) return (short)fieldData < Convert.ToInt16(value); + else if (fieldData is int) return (int)fieldData < Convert.ToInt32(value); + else if (fieldData is long) return (long)fieldData < Convert.ToInt64(value); + else if (fieldData is decimal) return (decimal)fieldData < Convert.ToDecimal(value); + + // When checking if a float/double is smaller than another, we must make sure the two values are not "nearly" identical + else if (fieldData is float) return (float)fieldData < Convert.ToSingle(value) && Math.Abs((float)fieldData - Convert.ToSingle(value)) > 0.00001; + else if (fieldData is double) return (double)fieldData < Convert.ToDouble(value) && Math.Abs((double)fieldData - Convert.ToDouble(value)) > 0.00001; + + return false; + } + + private static bool IsGreater(string fieldName, string value, IDictionary data) + { + if (!data.ContainsKey(fieldName)) return false; + value = value.TrimStart(new char[] { '"', '\'' }).TrimEnd(new char[] { '"', '\'' }); + return string.Compare(data[fieldName].ToString(), value, StringComparison.Ordinal) > 0; + } + + private static bool IsGreaterNumeric(string fieldName, string value, IDictionary data) + { + var fieldData = (object)null; + data.TryGetValue(fieldName, out fieldData); + + if (fieldData is short) return (short)fieldData > Convert.ToInt16(value); + else if (fieldData is int) return (int)fieldData > Convert.ToInt32(value); + else if (fieldData is long) return (long)fieldData > Convert.ToInt64(value); + else if (fieldData is decimal) return (decimal)fieldData > Convert.ToDecimal(value); + + // When checking if a float/double is greater than another, we must make sure the two values are not "nearly" identical + else if (fieldData is float) return (float)fieldData > Convert.ToSingle(value) && Math.Abs((float)fieldData - Convert.ToSingle(value)) > 0.00001; + else if (fieldData is double) return (double)fieldData > Convert.ToDouble(value) && Math.Abs((double)fieldData - Convert.ToDouble(value)) > 0.00001; + + return false; + } + + private static bool IsLike(string fieldName, string value, IDictionary data) + { + if (!data.ContainsKey(fieldName)) return false; + value = value.TrimStart(new char[] { '"', '\'' }).TrimEnd(new char[] { '"', '\'' }); + var a = Regex.IsMatch(data[fieldName].ToString(), value.Replace("%", "(.*?)")); + return a; + } + + private static string FieldDataAsString(string fieldName, IDictionary data, string format = null, string fallbackValue = null) + { + var fieldData = (object)null; + data.TryGetValue(fieldName, out fieldData); + + var returnValue = (string)null; + if (fieldData != null) + { + if (fieldData is DateTime) returnValue = ((DateTime)fieldData).ToString(format ?? "yyyy-MM-dd HH-mm-ss"); + else if (fieldData is short) returnValue = ((short)fieldData).ToString(format); + else if (fieldData is int) returnValue = ((int)fieldData).ToString(format); + else if (fieldData is long) returnValue = ((long)fieldData).ToString(format); + else if (fieldData is decimal) returnValue = ((decimal)fieldData).ToString(format); + else if (fieldData is float) returnValue = ((float)fieldData).ToString(format); + else if (fieldData is double) returnValue = ((double)fieldData).ToString(format); + else returnValue = fieldData.ToString(); + } + + return returnValue ?? fallbackValue ?? string.Empty; + } + } +} diff --git a/Source/CakeMail.RestClient/Utilities/CakeMailMergeFieldsParser.cs b/Source/CakeMail.RestClient/Utilities/CakeMailMergeFieldsParser.cs deleted file mode 100644 index 9ad7819..0000000 --- a/Source/CakeMail.RestClient/Utilities/CakeMailMergeFieldsParser.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; - -namespace CakeMail.RestClient.Utilities -{ - public static class CakeMailMergeFieldsParser - { - private static readonly Regex _currentDateRegex = new Regex(@"\[(NOW|TODAY|DATE)\s*(?:\|\s*(.*?))?\]", RegexOptions.Compiled); - private static readonly Regex _mergeFieldsRegex = new Regex(@"\[(.*?)\s*(?:\|\s*(.*?))?\]", RegexOptions.Compiled); - - public static string Parse(string content, IDictionary data) - { - if (string.IsNullOrEmpty(content)) - { - return string.Empty; - } - - var mergedContent = _currentDateRegex.Replace(content, (Match m) => CurrentDateMatchEval(m)); - mergedContent = _mergeFieldsRegex.Replace(mergedContent, (Match m) => MatchEval(m, data)); - - return mergedContent; - } - - private static string CurrentDateMatchEval(Match m) - { - var format = (m.Groups.Count >= 3 ? m.Groups[2] : null)?.Value ?? string.Empty; - var dateAsString = DateTime.UtcNow.ToString(format); - - return dateAsString; - } - - private static string MatchEval(Match m, IDictionary data) - { - var group1 = m.Groups[1].Value.Split(','); - var group2 = (m.Groups.Count >= 3 ? m.Groups[2] : null); - - var fieldName = group1[0].Trim(); - var fallbackValue = (group1.Length >= 2 ? group1[1] : string.Empty).Trim(); - var format = group2?.Value?.Trim() ?? string.Empty; - - if (data != null && data.ContainsKey(fieldName)) - { - if (data[fieldName] is DateTime) return ((DateTime)data[fieldName]).ToString(format); - else if (data[fieldName] is short) return ((short)data[fieldName]).ToString(format); - else if (data[fieldName] is int) return ((int)data[fieldName]).ToString(format); - else if (data[fieldName] is long) return ((long)data[fieldName]).ToString(format); - else if (data[fieldName] is decimal) return ((decimal)data[fieldName]).ToString(format); - else if (data[fieldName] is float) return ((float)data[fieldName]).ToString(format); - else if (data[fieldName] is double) return ((double)data[fieldName]).ToString(format); - else return data[fieldName]?.ToString() ?? fallbackValue; - } - else - { - return fallbackValue; - } - } - } -}