diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index 16394cca4d..a759b2db9b 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Text.Json; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.Formatting; @@ -8,12 +9,14 @@ using Foundatio.Serializer; using HandlebarsDotNet; using Microsoft.Extensions.Logging; +using NetMailAddress = System.Net.Mail.MailAddress; namespace Exceptionless.Core.Mail; public class Mailer : IMailer { private readonly ConcurrentDictionary> _cachedTemplates = new(); + private readonly IHandlebars _handlebars = Handlebars.Create(); private readonly IQueue _queue; private readonly FormattingPluginManager _pluginManager; private readonly AppOptions _appOptions; @@ -29,6 +32,12 @@ public Mailer(IQueue queue, FormattingPluginManager pluginManager, _timeProvider = timeProvider; _serializer = serializer; _logger = logger; + + _handlebars.RegisterHelper("json", (writer, _, parameters) => + { + object? value = parameters.Length > 0 ? parameters[0] : null; + writer.WriteSafeString(JsonSerializer.Serialize(value)); + }); } public async Task SendContactRequestAsync(string name, string emailAddress, string? company, string? subject, string message, string? clientIpAddress, string? userAgent, string? referrer) @@ -115,7 +124,10 @@ private void AddUserInfo(PersistentEvent ev, Dictionary data) data["UserDescription"] = ud.Description; if (!String.IsNullOrEmpty(ud?.EmailAddress)) + { data["UserEmail"] = ud.EmailAddress; + data["UserEmailHref"] = BuildMailtoHref(ud.EmailAddress, ud.Description); + } string? displayName = null; if (!String.IsNullOrEmpty(ui?.Identity)) @@ -135,6 +147,19 @@ private void AddUserInfo(PersistentEvent ev, Dictionary data) data["HasUserInfo"] = ud is not null || ui is not null; } + private static string BuildMailtoHref(string emailAddress, string? body) + { + string address = NetMailAddress.TryCreate(emailAddress, out NetMailAddress? parsedAddress) + ? parsedAddress.Address + : emailAddress; + int atIndex = address.LastIndexOf('@'); + string escapedAddress = atIndex > 0 + ? $"{Uri.EscapeDataString(address[..atIndex])}@{Uri.EscapeDataString(address[(atIndex + 1)..])}" + : Uri.EscapeDataString(address); + string href = $"mailto:{escapedAddress}"; + return String.IsNullOrEmpty(body) ? href : $"{href}?body={Uri.EscapeDataString(body)}"; + } + private static void AddDefaultFields(PersistentEvent ev, Dictionary data) { if (ev.Tags?.Count > 0) @@ -269,7 +294,8 @@ private static IEnumerable GetStackTemplateData(IEnumerable stack StackId = s.Id, Title = s.Title.Truncate(50), TypeName = s.GetTypeName()?.Truncate(50), - s.Status + s.Status, + IsRegressed = s.Status == StackStatus.Regressed }); } @@ -334,7 +360,7 @@ private HandlebarsTemplate GetCompiledTemplate(string name) using var reader = new StreamReader(stream ?? throw new InvalidOperationException()); string template = reader.ReadToEnd(); - var compiledTemplateFunc = Handlebars.Compile(template); + var compiledTemplateFunc = _handlebars.Compile(template); return compiledTemplateFunc; }); } diff --git a/src/Exceptionless.Core/Mail/Templates/event-notice.html b/src/Exceptionless.Core/Mail/Templates/event-notice.html index b07c342172..b3d939af5f 100644 --- a/src/Exceptionless.Core/Mail/Templates/event-notice.html +++ b/src/Exceptionless.Core/Mail/Templates/event-notice.html @@ -1,7 +1,7 @@ -{{Subject}}
Exceptionless
 

{{#if IsNew}} A new {{#if IsCritical}}critical {{/if}}event has occurred in the "{{ProjectName}}" project. {{else if IsRegression}} {{#if IsCritical}}A critical{{else}}An{{/if}} event has regressed in the "{{ProjectName}}" project. {{else}} {{#if IsCritical}}A critical{{else}}An{{/if}} event has reoccurred for the {{TotalOccurrences}} time in the "{{ProjectName}}" project. {{/if}}

View Event Details
{{#if Fields}}
 
{{#each Fields}} {{#if @index}}
{{/if}}

{{@key}}
{{this}}

{{/each}}
{{/if}} {{#if HasUserInfo}}
 

User Info

{{#if UserDisplayName}}

Name
{{#if UserEmail}} {{UserDisplayName}} {{else}} {{UserDisplayName}} {{/if}}

{{#if UserDescription}}
{{/if}} {{/if}} {{#if UserDescription}}

Description
{{UserDescription}}

{{/if}}
{{/if}}
Other Actions
                                                           
\ No newline at end of file +}
                                                           
diff --git a/src/Exceptionless.Core/Mail/Templates/organization-added.html b/src/Exceptionless.Core/Mail/Templates/organization-added.html index 468789dff4..ba7fac0131 100644 --- a/src/Exceptionless.Core/Mail/Templates/organization-added.html +++ b/src/Exceptionless.Core/Mail/Templates/organization-added.html @@ -1,7 +1,7 @@ {{Subject}}
Exceptionless
 

{{Subject}}

View Organization
 

What is Exceptionless? Exceptionless is an error reporting service. Go from signing up to catching every error in your application in 15 minutes or less.

Connect With Us:
GitHub
Contact Info:

Email: support@exceptionless.io

                                                           
\ No newline at end of file +}
                                                           
diff --git a/src/Exceptionless.Core/Mail/Templates/organization-invited.html b/src/Exceptionless.Core/Mail/Templates/organization-invited.html index 1586f4855c..aa02b97326 100644 --- a/src/Exceptionless.Core/Mail/Templates/organization-invited.html +++ b/src/Exceptionless.Core/Mail/Templates/organization-invited.html @@ -1,7 +1,7 @@ {{Subject}}
Exceptionless
 

You've been invited to become Exceptionless!

{{Subject}}

Join Organization
 

What is Exceptionless? Exceptionless is an error reporting service. Go from signing up to catching every error in your application in 15 minutes or less.

Connect With Us:
GitHub
Contact Info:

Email: support@exceptionless.io

                                                           
\ No newline at end of file +}
                                                           
diff --git a/src/Exceptionless.Core/Mail/Templates/organization-notice.html b/src/Exceptionless.Core/Mail/Templates/organization-notice.html index 1061155a48..19bb54387b 100644 --- a/src/Exceptionless.Core/Mail/Templates/organization-notice.html +++ b/src/Exceptionless.Core/Mail/Templates/organization-notice.html @@ -1,7 +1,7 @@ {{Subject}}
Exceptionless
 

{{#if IsOverMonthlyLimit}} {{OrganizationName}} has reached its monthly plan limit. Upgrade now to to continue receiving events. {{else if IsOverHourlyLimit}} Events are currently being throttled for {{OrganizationName}} until {{ThrottledUntil}} UTC to prevent using up your plan limit in a small window of time. Upgrade now to increase your limits. {{/if}}

Upgrade Plan
 

{{#if IsOverMonthlyLimit}} Learn more about what happens when the plan limit is reached. {{else if IsOverHourlyLimit}} Learn more about being throttled. {{/if}} You can also view the most frequent events to to see an overall picture of the events that are being counting against your plan limits.

 

Please send us an email at support@exceptionless.io if you have any questions or conserns.

Other Actions
                                                           
\ No newline at end of file +}
                                                           
diff --git a/src/Exceptionless.Core/Mail/Templates/organization-payment-failed.html b/src/Exceptionless.Core/Mail/Templates/organization-payment-failed.html index 6eb4275f54..bbb6eb4524 100644 --- a/src/Exceptionless.Core/Mail/Templates/organization-payment-failed.html +++ b/src/Exceptionless.Core/Mail/Templates/organization-payment-failed.html @@ -1,7 +1,7 @@ {{Subject}}
Exceptionless
 

Payment Failed

Payment failed for organization "{{OrganizationName}}". In order to avoid service interruption, please login and update your payment information.

Update Billing Information
 

Send us an email at support@exceptionless.io if you have any questions or need assistance.

Other Actions
                                                           
\ No newline at end of file +}
                                                           
diff --git a/src/Exceptionless.Core/Mail/Templates/project-daily-summary.html b/src/Exceptionless.Core/Mail/Templates/project-daily-summary.html index 0e76612df3..b8d6eedf77 100644 --- a/src/Exceptionless.Core/Mail/Templates/project-daily-summary.html +++ b/src/Exceptionless.Core/Mail/Templates/project-daily-summary.html @@ -1,7 +1,7 @@ {{Subject}}
Exceptionless
 

Summary for {{StartDate}}

{{#if HasSubmittedEvents}} {{#if Blocked}}
Count

{{Count}}

Unique

{{Unique}}

New

{{New}}

Discarded

{{Blocked}}

{{else}}
Count

{{Count}}

Unique

{{Unique}}

New

{{New}}

{{/if}} {{/if}}
{{#if HasSubmittedEvents}}

{{#if Count}} The "{{ProjectName}}" project had {{Count}} total, {{Unique}} unique, and {{New}} new errors. {{else}} Congrats! The "{{ProjectName}}" project was exceptionless! {{/if}} {{#if Fixed}} Additionally, {{Fixed}} errors that have been marked as fixed occurred in outdated instances of your application. {{/if}}

View Timeline
{{#if Blocked}}

{{Blocked}} events were discarded due to throttling. Upgrade now to increase your limits. Click here to learn more about throttling.

Upgrade Plan
{{/if}}
 
{{#if MostFrequent}}
Most Frequent
{{/if}} {{#if Newest}}
Newest
{{/if}} {{#if IsFreePlan}}
 

You are currently on a free plan. If you would like to receive notifications for errors as they happen, upgrade to a paid plan.

{{/if}} {{else}}

Unfortunately, it appears that your "{{ProjectName}}" project has not yet been configured to send errors to Exceptionless.

 
Configure Project
 

Send us an email at support@exceptionless.io if you have any questions or need help getting started.

{{/if}}
Other Actions
                                                           
\ No newline at end of file +}
                                                           
diff --git a/src/Exceptionless.Core/Mail/Templates/user-email-verify.html b/src/Exceptionless.Core/Mail/Templates/user-email-verify.html index 5fe6de7cf4..7a9f5b367a 100644 --- a/src/Exceptionless.Core/Mail/Templates/user-email-verify.html +++ b/src/Exceptionless.Core/Mail/Templates/user-email-verify.html @@ -1,7 +1,7 @@ {{Subject}}
Exceptionless
 

Hello {{UserFullName}},

We're ready to activate your account. All we need to do is make sure this is your email address.

Verify Address
 

If you didn't create an Exceptionless account, just delete this email and everything will go back to the way it was.

                                                           
\ No newline at end of file +}
                                                           
diff --git a/src/Exceptionless.Core/Mail/Templates/user-password-reset.html b/src/Exceptionless.Core/Mail/Templates/user-password-reset.html index 653da59f42..0b700124b6 100644 --- a/src/Exceptionless.Core/Mail/Templates/user-password-reset.html +++ b/src/Exceptionless.Core/Mail/Templates/user-password-reset.html @@ -1,7 +1,7 @@ {{Subject}}
Exceptionless
 

Hello {{UserFullName}},

We heard you need a password reset. Click the link below and you'll be redirected to a secure site from which you can set a new password.

Reset Password
 

If you didn't try to reset your password, click here to cancel the password reset request and we'll forget this ever happened.

                                                           
\ No newline at end of file +}
                                                           
diff --git a/src/Exceptionless.EmailTemplates/src/pages/event-notice.html b/src/Exceptionless.EmailTemplates/src/pages/event-notice.html index 0676fd5c77..1fcd0f0117 100644 --- a/src/Exceptionless.EmailTemplates/src/pages/event-notice.html +++ b/src/Exceptionless.EmailTemplates/src/pages/event-notice.html @@ -39,7 +39,7 @@

User Info

Name
\{{#if UserEmail}} - \{{UserDisplayName}} + \{{UserDisplayName}} \{{else}} \{{UserDisplayName}} \{{/if}} @@ -75,7 +75,7 @@

Other Actions
{ "@context": "http://schema.org", "@type": "EmailMessage", - "description": "\{{Subject}}", + "description": \{{json Subject}}, "potentialAction": { "@type": "ViewAction", "target": "\{{BaseUrl}}/event/\{{EventId}}", @@ -89,4 +89,4 @@
Other Actions
"logo": "https://be.exceptionless.io/img/exceptionless-48.png" } } - \ No newline at end of file + diff --git a/src/Exceptionless.EmailTemplates/src/pages/organization-added.html b/src/Exceptionless.EmailTemplates/src/pages/organization-added.html index 1a7a4de171..056301d211 100644 --- a/src/Exceptionless.EmailTemplates/src/pages/organization-added.html +++ b/src/Exceptionless.EmailTemplates/src/pages/organization-added.html @@ -24,7 +24,7 @@

\{{Subject}}

{ "@context": "http://schema.org", "@type": "EmailMessage", - "description": "\{{Subject}}", + "description": \{{json Subject}}, "potentialAction": { "@type": "ViewAction", "target": "\{{BaseUrl}}/organization/\{{OrganizationId}}/dashboard", @@ -38,4 +38,4 @@

\{{Subject}}

"logo": "https://be.exceptionless.io/img/exceptionless-48.png" } } - \ No newline at end of file + diff --git a/src/Exceptionless.EmailTemplates/src/pages/organization-invited.html b/src/Exceptionless.EmailTemplates/src/pages/organization-invited.html index a32b2cd6f7..b9cb851a9e 100644 --- a/src/Exceptionless.EmailTemplates/src/pages/organization-invited.html +++ b/src/Exceptionless.EmailTemplates/src/pages/organization-invited.html @@ -25,7 +25,7 @@

You've been invited to become Exceptionless!

{ "@context": "http://schema.org", "@type": "EmailMessage", - "description": "\{{Subject}}", + "description": \{{json Subject}}, "potentialAction": { "@type": "ViewAction", "target": "\{{BaseUrl}}/signup?token=\{{InviteToken}}", @@ -39,4 +39,4 @@

You've been invited to become Exceptionless!

"logo": "https://be.exceptionless.io/img/exceptionless-48.png" } } - \ No newline at end of file + diff --git a/src/Exceptionless.EmailTemplates/src/pages/organization-notice.html b/src/Exceptionless.EmailTemplates/src/pages/organization-notice.html index 717d04dcec..82311409aa 100644 --- a/src/Exceptionless.EmailTemplates/src/pages/organization-notice.html +++ b/src/Exceptionless.EmailTemplates/src/pages/organization-notice.html @@ -56,7 +56,7 @@
Other Actions
{ "@context": "http://schema.org", "@type": "EmailMessage", - "description": "\{{Subject}}", + "description": \{{json Subject}}, "potentialAction": { "@type": "ViewAction", "target": "\{{BaseUrl}}/organization/\{{OrganizationId}}/upgrade", @@ -70,4 +70,4 @@
Other Actions
"logo": "https://be.exceptionless.io/img/exceptionless-48.png" } } - \ No newline at end of file + diff --git a/src/Exceptionless.EmailTemplates/src/pages/organization-payment-failed.html b/src/Exceptionless.EmailTemplates/src/pages/organization-payment-failed.html index 6e740a33c7..bc1913953b 100644 --- a/src/Exceptionless.EmailTemplates/src/pages/organization-payment-failed.html +++ b/src/Exceptionless.EmailTemplates/src/pages/organization-payment-failed.html @@ -33,7 +33,7 @@
Other Actions
{ "@context": "http://schema.org", "@type": "EmailMessage", - "description": "\{{Subject}}", + "description": \{{json Subject}}, "potentialAction": { "@type": "ViewAction", "target": "\{{BaseUrl}}/organization/\{{OrganizationId}}/manage?tab=billing", @@ -47,4 +47,4 @@
Other Actions
"logo": "https://be.exceptionless.io/img/exceptionless-48.png" } } - \ No newline at end of file + diff --git a/src/Exceptionless.EmailTemplates/src/pages/project-daily-summary.html b/src/Exceptionless.EmailTemplates/src/pages/project-daily-summary.html index f2c761bfbd..b229a6328d 100644 --- a/src/Exceptionless.EmailTemplates/src/pages/project-daily-summary.html +++ b/src/Exceptionless.EmailTemplates/src/pages/project-daily-summary.html @@ -169,7 +169,7 @@
Other Actions
{ "@context": "http://schema.org", "@type": "EmailMessage", - "description": "\{{Subject}}", + "description": \{{json Subject}}, "potentialAction": { "@type": "ViewAction", \{{#if HasSubmittedEvents}} @@ -189,4 +189,4 @@
Other Actions
"logo": "https://be.exceptionless.io/img/exceptionless-48.png" } } - \ No newline at end of file + diff --git a/src/Exceptionless.EmailTemplates/src/pages/user-email-verify.html b/src/Exceptionless.EmailTemplates/src/pages/user-email-verify.html index 1d80db768d..91dc13e59b 100644 --- a/src/Exceptionless.EmailTemplates/src/pages/user-email-verify.html +++ b/src/Exceptionless.EmailTemplates/src/pages/user-email-verify.html @@ -25,7 +25,7 @@

Hello \{{UserFullName}},

{ "@context": "http://schema.org", "@type": "EmailMessage", - "description": "\{{Subject}}", + "description": \{{json Subject}}, "potentialAction": { "@type": "ViewAction", "target": "\{{BaseUrl}}/account/verify?token=\{{UserVerifyEmailAddressToken}}", @@ -39,4 +39,4 @@

Hello \{{UserFullName}},

"logo": "https://be.exceptionless.io/img/exceptionless-48.png" } } - \ No newline at end of file + diff --git a/src/Exceptionless.EmailTemplates/src/pages/user-password-reset.html b/src/Exceptionless.EmailTemplates/src/pages/user-password-reset.html index 311c3adfc3..59a58e0803 100644 --- a/src/Exceptionless.EmailTemplates/src/pages/user-password-reset.html +++ b/src/Exceptionless.EmailTemplates/src/pages/user-password-reset.html @@ -26,7 +26,7 @@

Hello \{{UserFullName}},

{ "@context": "http://schema.org", "@type": "EmailMessage", - "description": "\{{Subject}}", + "description": \{{json Subject}}, "potentialAction": { "@type": "ViewAction", "target": "\{{BaseUrl}}/reset-password/\{{UserPasswordResetToken}}", @@ -40,4 +40,4 @@

Hello \{{UserFullName}},

"logo": "https://be.exceptionless.io/img/exceptionless-48.png" } } - \ No newline at end of file + diff --git a/tests/Exceptionless.Tests/Mail/MailerTests.cs b/tests/Exceptionless.Tests/Mail/MailerTests.cs index 582953a94b..de6ed108be 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -1,3 +1,6 @@ +using System.Net; +using System.Text.Json; +using System.Text.RegularExpressions; using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -17,6 +20,13 @@ namespace Exceptionless.Tests.Mail; public sealed class MailerTests : TestWithServices { + private static readonly HashSet _expectedExternalHosts = new(StringComparer.OrdinalIgnoreCase) { + "exceptionless.com", + "github.com", + "www.facebook.com", + "twitter.com" + }; + private readonly IMailer _mailer; private readonly AppOptions _options; private readonly BillingManager _billingManager; @@ -44,9 +54,15 @@ public MailerTests(ITestOutputHelper output) : base(output) } [Fact] - public void CanParseSmtpUri() + public void Constructor_WithSecureSmtpUri_ParsesComponents() { - var uri = new SmtpUri("smtps://test%40test.com:testpass@smtp.test.com:587"); + // Arrange + const string value = "smtps://test%40test.com:testpass@smtp.test.com:587"; + + // Act + var uri = new SmtpUri(value); + + // Assert Assert.NotNull(uri); Assert.True(uri.IsSecure); Assert.Equal("smtp.test.com", uri.Host); @@ -56,30 +72,64 @@ public void CanParseSmtpUri() } [Fact] - public Task SendEventNoticeSimpleErrorAsync() + public async Task SendContactRequestAsync_WithCompleteRequest_RendersAllFields() { - var ex = GetException(); - Assert.NotNull(ex); + // Arrange + const string message = "First line\nSecond line"; + + // Act + bool queued = await _mailer.SendContactRequestAsync( + "Test User", + "test@example.com", + "Example Company", + "Need help", + message, + "127.0.0.1", + "Test Browser", + "contact-referrer"); + string body = await RunMailJobAsync(requireUrls: false); + + // Assert + Assert.True(queued); + Assert.Contains("Test User", body, StringComparison.Ordinal); + Assert.Contains("Example Company", body, StringComparison.Ordinal); + Assert.Contains("First line", body, StringComparison.Ordinal); + Assert.Contains("Second line", body, StringComparison.Ordinal); + Assert.Contains("127.0.0.1", body, StringComparison.Ordinal); + Assert.Contains("Test Browser", body, StringComparison.Ordinal); + } - return SendEventNoticeAsync(new PersistentEvent + [Fact] + public async Task SendEventNoticeAsync_WithSimpleError_RendersEventNotice() + { + // Arrange + var exception = GetException(); + var ev = new PersistentEvent { Type = Event.KnownTypes.Error, Data = new Core.Models.DataDictionary { { Event.KnownDataKeys.SimpleError, new SimpleError { - Message = ex.Message, - Type = ex.GetType().FullName, - StackTrace = ex.StackTrace + Message = exception.Message, + Type = exception.GetType().FullName, + StackTrace = exception.StackTrace } } } - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains(exception.Message, body, StringComparison.Ordinal); } [Fact] - public Task SendEventNoticeErrorAsync() + public async Task SendEventNoticeAsync_WithStructuredError_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Type = Event.KnownTypes.Error, Data = new Core.Models.DataDictionary { @@ -87,14 +137,21 @@ public Task SendEventNoticeErrorAsync() Event.KnownDataKeys.Error, _eventData.GenerateError() } } - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("Generated exception message.", body, StringComparison.Ordinal); } [Fact] - public Task SendEventNoticeErrorWithDetailsAsync() + public async Task SendEventNoticeAsync_WithDetailedError_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Type = Event.KnownTypes.Error, Geo = "44.5241,-87.9056", @@ -108,74 +165,120 @@ public Task SendEventNoticeErrorWithDetailsAsync() { Event.KnownDataKeys.UserInfo, new UserInfo("niemyjski", "Blake Niemyjski") }, { Event.KnownDataKeys.UserDescription, new UserDescription("noreply@exceptionless.io", "Blake ate two boxes of cookies and needs help") } } - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("Blake Niemyjski", body, StringComparison.Ordinal); } [Fact] - public Task SendEventNoticeNotFoundAsync() + public async Task SendEventNoticeAsync_WithNotFoundEvent_RendersSourceUrl() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Source = "[GET] /not-found?page=20", Type = Event.KnownTypes.NotFound - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("[GET] /not-found?page=20", WebUtility.HtmlDecode(body), StringComparison.Ordinal); } [Fact] - public Task SendEventNoticeFeatureAsync() + public async Task SendEventNoticeAsync_WithFeatureEvent_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Source = "My Feature Usage", Value = 1, Type = Event.KnownTypes.FeatureUsage - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("My Feature Usage", body, StringComparison.Ordinal); } [Fact] - public Task SendEventNoticeEmptyLogEventAsync() + public Task SendEventNoticeAsync_WithEmptyLogEvent_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Value = 1, Type = Event.KnownTypes.Log - }); + }; + + // Act + return SendEventNoticeAsync(ev); } [Fact] - public Task SendEventNoticeLogMessageAsync() + public async Task SendEventNoticeAsync_WithLogMessage_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Message = "Only Message", Type = Event.KnownTypes.Log - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("Only Message", body, StringComparison.Ordinal); } [Fact] - public Task SendEventNoticeLogSourceAsync() + public async Task SendEventNoticeAsync_WithLogSource_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Source = "Only Source", Type = Event.KnownTypes.Log - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("Only Source", body, StringComparison.Ordinal); } [Fact] - public Task SendEventNoticeLogReallyLongSourceAsync() + public async Task SendEventNoticeAsync_WithLongLogSource_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Source = "Soooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooorce", Type = Event.KnownTypes.Log - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("Soooooooo", body, StringComparison.Ordinal); } [Fact] - public Task SendEventNoticeLogMessageSourceLevelAsync() + public async Task SendEventNoticeAsync_WithLogDetails_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Message = "My Message", Source = "My Source", @@ -183,20 +286,78 @@ public Task SendEventNoticeLogMessageSourceLevelAsync() Data = new Core.Models.DataDictionary { { Event.KnownDataKeys.Level, "Warn" } } - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("My Message", body, StringComparison.Ordinal); } [Fact] - public Task SendEventNoticeDefaultAsync() + public async Task SendEventNoticeAsync_WithDefaultEvent_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Message = "Default Test Message", Source = "Default Test Source" - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("Default Test Message", body, StringComparison.Ordinal); + } + + [Fact] + public async Task SendEventNoticeAsync_WithQuotedSubject_RendersValidJsonLd() + { + // Arrange + var ev = new PersistentEvent + { + Type = Event.KnownTypes.Error, + Message = "Failure in the \"Acme\" worker" + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("Failure in the \"Acme\" worker", WebUtility.HtmlDecode(body), StringComparison.Ordinal); } - private async Task SendEventNoticeAsync(PersistentEvent ev) + [Fact] + public async Task SendEventNoticeAsync_WithHostileUserDescription_EncodesHtmlAndMailtoComponents() + { + // Arrange + var ev = new PersistentEvent + { + Type = Event.KnownTypes.Error, + Message = "Hostile user description", + Data = new Core.Models.DataDictionary { + { Event.KnownDataKeys.UserInfo, new UserInfo("user-id", "") }, + { Event.KnownDataKeys.UserDescription, new UserDescription("\"alerts@prod\"@example.com", "hello&bcc=attacker@example.com") } + } + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + string mailto = Assert.Single(GetHrefs(body), href => href.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase)); + int bodySeparatorIndex = mailto.IndexOf("?body=", StringComparison.OrdinalIgnoreCase); + Assert.True(bodySeparatorIndex > 0); + Assert.Equal("\"alerts@prod\"@example.com", Uri.UnescapeDataString(mailto["mailto:".Length..bodySeparatorIndex])); + Assert.Equal("hello&bcc=attacker@example.com", Uri.UnescapeDataString(mailto[(bodySeparatorIndex + "?body=".Length)..])); + Assert.DoesNotContain("&bcc=", body, StringComparison.OrdinalIgnoreCase); + Assert.Contains("<img", body, StringComparison.Ordinal); + Assert.DoesNotContain("", body, StringComparison.Ordinal); + } + + private async Task SendEventNoticeAsync(PersistentEvent ev) { var user = _userData.GenerateSampleUser(); var project = _projectData.GenerateSampleProject(); @@ -207,168 +368,403 @@ private async Task SendEventNoticeAsync(PersistentEvent ev) ev.StackId = TestConstants.StackId; await _mailer.SendEventNoticeAsync(user, ev, project, RandomData.GetBool(), RandomData.GetBool(), 1); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + Assert.Contains("View Event Details", body, StringComparison.Ordinal); + return body; } [Fact] - public async Task SendOrganizationAddedAsync() + public async Task SendOrganizationAddedAsync_WithOrganization_RendersOrganizationLink() { + // Arrange var user = _userData.GenerateSampleUser(); var organization = _organizationData.GenerateSampleOrganization(_billingManager, _plans); + // Act await _mailer.SendOrganizationAddedAsync(user, organization, user); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("View Organization", body, StringComparison.Ordinal); } [Fact] - public async Task SendOrganizationInviteAsync() + public async Task SendOrganizationInviteAsync_WithInvite_RendersSignupLink() { + // Arrange var user = _userData.GenerateSampleUser(); var organization = _organizationData.GenerateSampleOrganization(_billingManager, _plans); - - await _mailer.SendOrganizationInviteAsync(user, organization, new Invite + var invite = new Invite { DateAdded = DateTime.UtcNow, EmailAddress = "test@exceptionless.com", Token = "1" - }); - - await RunMailJobAsync(); + }; - var sender = GetService() as InMemoryMailSender; - Assert.NotNull(sender); + // Act + await _mailer.SendOrganizationInviteAsync(user, organization, invite); + var body = await RunMailJobAsync(); - Assert.Contains("Join Organization", sender.LastMessage?.Body); + // Assert + Assert.Contains("Join Organization", body, StringComparison.Ordinal); } [Fact] - public async Task SendOrganizationHourlyOverageNoticeAsync() + public async Task SendOrganizationNoticeAsync_WithHourlyOverage_RendersUsageLinks() { + // Arrange var user = _userData.GenerateSampleUser(); var organization = _organizationData.GenerateSampleOrganization(_billingManager, _plans); + // Act await _mailer.SendOrganizationNoticeAsync(user, organization, false, true); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("throttled", body, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task SendOrganizationMonthlyOverageNoticeAsync() + public async Task SendOrganizationNoticeAsync_WithMonthlyOverage_RendersUsageLinks() { + // Arrange var user = _userData.GenerateSampleUser(); var organization = _organizationData.GenerateSampleOrganization(_billingManager, _plans); + // Act await _mailer.SendOrganizationNoticeAsync(user, organization, true, false); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("monthly plan limit", body, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task SendOrganizationPaymentFailedAsync() + public async Task SendOrganizationPaymentFailedAsync_WithOrganization_RendersBillingLinks() { + // Arrange var user = _userData.GenerateSampleUser(); var organization = _organizationData.GenerateSampleOrganization(_billingManager, _plans); + // Act await _mailer.SendOrganizationPaymentFailedAsync(user, organization); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("Payment failed", body, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task SendProjectDailySummaryAsync() + public async Task SendProjectDailySummaryAsync_WithSubmittedEvents_RendersTimelineLinks() { + // Arrange var user = _userData.GenerateSampleUser(); var project = _projectData.GenerateSampleProject(); - var mostFrequent = _stackData.GenerateStacks(3, generateId: true, type: Event.KnownTypes.Error); + var mostFrequent = _stackData.GenerateStacks(3, generateId: true, type: Event.KnownTypes.Error).ToArray(); + for (int index = 0; index < mostFrequent.Length; index++) + mostFrequent[index].Id = $"frequent-stack-{index}"; + // Act await _mailer.SendProjectDailySummaryAsync(user, project, mostFrequent, null, DateTime.UtcNow.Date, true, 12, 1, 0, 1, 0, 0, false); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("View Timeline", body, StringComparison.Ordinal); + Assert.Contains("Most Frequent", body, StringComparison.Ordinal); } [Fact] - public async Task SendProjectDailySummaryWithAllBlockedAsync() + public async Task SendProjectDailySummaryAsync_WithAllEventsBlocked_RendersThrottleContent() { + // Arrange var user = _userData.GenerateSampleUser(); var project = _projectData.GenerateSampleProject(); var mostFrequent = _stackData.GenerateStacks(3, generateId: true, type: Event.KnownTypes.Error); + // Act await _mailer.SendProjectDailySummaryAsync(user, project, mostFrequent, null, DateTime.UtcNow.Date, true, 123456, 1, 0, 1, 123456, 0, false); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("discarded due to throttling", body, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task SendProjectDailySummaryNotConfiguredAsync() + public async Task SendProjectDailySummaryAsync_WithUnconfiguredProject_RendersConfigureLink() { + // Arrange var user = _userData.GenerateSampleUser(); var project = _projectData.GenerateSampleProject(); + // Act await _mailer.SendProjectDailySummaryAsync(user, project, null, null, DateTime.UtcNow.Date, false, 0, 0, 0, 0, 0, 0, false); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("Configure Project", body, StringComparison.Ordinal); } [Fact] - public async Task SendProjectDailySummaryWithNoEventsButHasFixedEventsAsync() + public async Task SendProjectDailySummaryAsync_WithOnlyFixedEvents_RendersFixedContent() { + // Arrange var user = _userData.GenerateSampleUser(); var project = _projectData.GenerateSampleProject(); + // Act await _mailer.SendProjectDailySummaryAsync(user, project, null, null, DateTime.UtcNow.Date, true, 0, 0, 0, 10, 0, 0, false); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("marked as fixed", body, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task SendProjectDailySummaryWithNoEventsButHasFixedAndTooBigEventsAsync() + public async Task SendProjectDailySummaryAsync_WithFixedAndOversizedEvents_RendersFixedContent() { + // Arrange var user = _userData.GenerateSampleUser(); var project = _projectData.GenerateSampleProject(); + // Act await _mailer.SendProjectDailySummaryAsync(user, project, null, null, DateTime.UtcNow.Date, true, 0, 0, 0, 10, 123456, 23, false); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("marked as fixed", body, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task SendProjectDailySummaryWithFreeProjectAsync() + public async Task SendProjectDailySummaryAsync_WithFreeProject_RendersPlanContent() { + // Arrange var user = _userData.GenerateSampleUser(); var project = _projectData.GenerateSampleProject(); - var mostFrequent = _stackData.GenerateStacks(3, generateId: true, type: Event.KnownTypes.Error); - var newest = _stackData.GenerateStacks(1, generateId: true, type: Event.KnownTypes.Error); + var mostFrequent = _stackData.GenerateStacks(3, generateId: true, type: Event.KnownTypes.Error).ToArray(); + var newest = _stackData.GenerateStacks(1, generateId: true, type: Event.KnownTypes.Error).ToArray(); + for (int index = 0; index < mostFrequent.Length; index++) + mostFrequent[index].Id = $"frequent-stack-{index}"; + newest[0].Id = "newest-stack-0"; + // Act await _mailer.SendProjectDailySummaryAsync(user, project, mostFrequent, newest, DateTime.UtcNow.Date, true, 12, 1, 1, 2, 0, 0, true); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("free plan", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task SendProjectDailySummaryAsync_WithRegressedStack_RendersRegressedLabel() + { + // Arrange + var user = _userData.GenerateSampleUser(); + var project = _projectData.GenerateSampleProject(); + var regressedStack = _stackData.GenerateStack(generateId: true, type: Event.KnownTypes.Error, status: StackStatus.Regressed); + regressedStack.Id = "regressed-stack"; + + // Act + await _mailer.SendProjectDailySummaryAsync(user, project, new[] { regressedStack }, null, DateTime.UtcNow.Date, true, 5, 3, 1, 0, 0, 0, false); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("[REGRESSED]", body, StringComparison.Ordinal); + } + + [Fact] + public async Task SendProjectDailySummaryAsync_WithNonRegressedStack_OmitsRegressedLabel() + { + // Arrange + var user = _userData.GenerateSampleUser(); + var project = _projectData.GenerateSampleProject(); + var openStack = _stackData.GenerateStack(generateId: true, type: Event.KnownTypes.Error, status: StackStatus.Open); + openStack.Id = "open-stack"; + + // Act + await _mailer.SendProjectDailySummaryAsync(user, project, new[] { openStack }, null, DateTime.UtcNow.Date, true, 5, 3, 1, 0, 0, 0, false); + var body = await RunMailJobAsync(); + + // Assert + Assert.DoesNotContain("[REGRESSED]", body, StringComparison.Ordinal); } [Fact] - public async Task SendUserPasswordResetAsync() + public async Task SendUserPasswordResetAsync_WithResetToken_RendersResetLinks() { + // Arrange var user = _userData.GenerateSampleUser(); user.CreatePasswordResetToken(TimeProvider); + // Act await _mailer.SendUserPasswordResetAsync(user); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("Reset Password", body, StringComparison.OrdinalIgnoreCase); + Assert.Contains("?cancel=true", body, StringComparison.OrdinalIgnoreCase); + Assert.Contains("click here to cancel the password reset request", body, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task SendUserEmailVerifyAsync() + public async Task SendUserEmailVerifyAsync_WithVerificationToken_RendersVerificationLink() { + // Arrange var user = _userData.GenerateSampleUser(); user.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + // Act await _mailer.SendUserEmailVerifyAsync(user); - await RunMailJobAsync(); + var body = await RunMailJobAsync(); + + // Assert + Assert.Contains("Verify Address", body, StringComparison.Ordinal); } - private async Task RunMailJobAsync() + private async Task RunMailJobAsync(bool requireUrls = true) { var job = GetService(); await job.RunAsync(); - if (GetService() is not InMemoryMailSender sender) - return; + var sender = Assert.IsType(GetService()); + var body = sender.LastMessage?.Body; + Assert.NotNull(body); _logger.LogTrace("To: {To}", sender.LastMessage?.To); _logger.LogTrace("Subject: {Subject}", sender.LastMessage?.Subject); - _logger.LogTrace("Body:\n{Body}", sender.LastMessage?.Body); + _logger.LogTrace("Body:\n{Body}", body); + + Assert.NotEmpty(body); + Assert.Contains(" Assert.Contains(actionUrl, hrefs)); + + var baseUri = new Uri(_options.BaseURL); + foreach (string url in urls) + { + if (url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase)) + { + Assert.Matches(@"^mailto:[^?]+(?:\?body=.+)?$", url); + continue; + } + + Assert.True(Uri.TryCreate(url, UriKind.Absolute, out var uri), $"Expected an absolute email URL but found '{url}'."); + Assert.Contains(uri.Scheme, new[] { Uri.UriSchemeHttp, Uri.UriSchemeHttps }); + + if (!String.Equals(uri.Authority, baseUri.Authority, StringComparison.OrdinalIgnoreCase)) + { + Assert.Contains(uri.Authority, _expectedExternalHosts); + continue; + } + + Assert.True(String.Equals(uri.Scheme, baseUri.Scheme, StringComparison.OrdinalIgnoreCase), $"Expected internal email URL scheme '{baseUri.Scheme}' but found '{uri.Scheme}'."); + Assert.DoesNotContain("/next/", uri.PathAndQuery, StringComparison.OrdinalIgnoreCase); + AssertValidInternalUrl(uri); + } + } + + private static void AssertValidInternalUrl(Uri uri) + { + Assert.Empty(uri.Fragment); + Assert.Matches(@"^/(?:event/[^/]+|stack/[^/]+(?:/(?:mark-fixed|ignored|discarded))?|project/[^/]+/(?:configure|error/(?:timeline|frequent|new))|account/(?:manage|verify)|organization/[^/]+/(?:dashboard|upgrade|frequent|manage)|signup|reset-password/[^/]+)$", uri.AbsolutePath); + + if (uri.AbsolutePath is "/account/verify" or "/signup") + { + Assert.Matches(@"^\?token=[^?&#]+$", uri.Query); + return; + } + + if (uri.AbsolutePath == "/account/manage") + { + Assert.Matches(@"^\?(?:tab=notifications|projectId=[^?&#]+&tab=notifications)$", uri.Query); + return; + } + + if (Regex.IsMatch(uri.AbsolutePath, @"^/organization/[^/]+/manage$")) + { + Assert.Matches(@"^(?:|\?tab=billing)$", uri.Query); + return; + } + + if (Regex.IsMatch(uri.AbsolutePath, @"^/reset-password/[^/]+$")) + { + Assert.Matches(@"^(?:|\?cancel=true)$", uri.Query); + return; + } + + Assert.Empty(uri.Query); } - private Exception? GetException() + private static string[] GetHrefs(string body) + { + string decodedBody = WebUtility.HtmlDecode(body); + return Regex.Matches(decodedBody, "href\\s*=\\s*[\"'](?[^\"']+)[\"']", RegexOptions.IgnoreCase) + .Select(match => match.Groups["url"].Value) + .ToArray(); + } + + private static string[] GetStructuredDataUrls(string body, bool actionOnly = false) + { + string decodedBody = WebUtility.HtmlDecode(body); + var urls = new List(); + var scripts = Regex.Matches( + decodedBody, + "]*\\btype\\s*=\\s*[\"']application/ld\\+json[\"'])[^>]*>(?.*?)", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + + foreach (Match script in scripts) + { + using JsonDocument document = JsonDocument.Parse(script.Groups["json"].Value); + AddStructuredDataUrls(document.RootElement, urls, actionOnly); + } + + return urls.ToArray(); + } + + private static void AddStructuredDataUrls(JsonElement element, ICollection urls, bool actionOnly, bool isAction = false) + { + if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + AddStructuredDataUrls(item, urls, actionOnly, isAction); + + return; + } + + if (element.ValueKind != JsonValueKind.Object) + return; + + foreach (JsonProperty property in element.EnumerateObject()) + { + if ((!actionOnly || isAction) && (property.NameEquals("target") || property.NameEquals("url"))) + { + Assert.Equal(JsonValueKind.String, property.Value.ValueKind); + urls.Add(property.Value.GetString()!); + } + + AddStructuredDataUrls(property.Value, urls, actionOnly, isAction || property.NameEquals("potentialAction")); + } + } + + private Exception GetException() { void TestInner() { @@ -383,12 +779,11 @@ void TestInnerInner() try { TestInner(); + throw new InvalidOperationException("Expected exception was not thrown."); } - catch (Exception ex) + catch (ApplicationException ex) { return ex; } - - return null; } }