From eafa074ac29ae58998003e09d2bf0c8039ce3150 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 25 Aug 2026 07:10:45 -0500 Subject: [PATCH 01/11] test(mail): characterize production email contracts --- tests/Exceptionless.Tests/Mail/MailerTests.cs | 438 ++++++++++++++---- 1 file changed, 357 insertions(+), 81 deletions(-) diff --git a/tests/Exceptionless.Tests/Mail/MailerTests.cs b/tests/Exceptionless.Tests/Mail/MailerTests.cs index 582953a94b..95cfb05ef7 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Text.RegularExpressions; using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; @@ -17,6 +19,15 @@ namespace Exceptionless.Tests.Mail; public sealed class MailerTests : TestWithServices { + private static readonly HashSet _expectedExternalUrls = new(StringComparer.Ordinal) { + "https://exceptionless.com", + "https://github.com/exceptionless/Exceptionless/wiki/Frequently-Asked-Questions#q-what-happens-if-the-organization-plan-limit-is-reached", + "https://github.com/exceptionless/Exceptionless/wiki/Frequently-Asked-Questions#q-why-is-my-organization-throttled", + "https://www.facebook.com/exceptionless/", + "https://twitter.com/exceptionless", + "https://github.com/exceptionless/exceptionless" + }; + private readonly IMailer _mailer; private readonly AppOptions _options; private readonly BillingManager _billingManager; @@ -44,9 +55,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 +73,65 @@ 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", + "https://example.com/contact"); + 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); + Assert.Contains("https://example.com/contact", body, StringComparison.Ordinal); + } - return SendEventNoticeAsync(new PersistentEvent + [Fact] + public async Task SendEventNoticeAsync_WithSimpleError_RendersEventNotice() + { + // Arrange + var exception = GetException() ?? throw new InvalidOperationException("Expected test exception."); + 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 +139,21 @@ public Task SendEventNoticeErrorAsync() Event.KnownDataKeys.Error, _eventData.GenerateError() } } - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("View Event Details", 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 +167,123 @@ 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_RendersEventNotice() { - 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("View Event Details", 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 async Task SendEventNoticeAsync_WithEmptyLogEvent_RendersEventNotice() { - return SendEventNoticeAsync(new PersistentEvent + // Arrange + var ev = new PersistentEvent { Value = 1, Type = Event.KnownTypes.Log - }); + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("View Event Details", body, StringComparison.Ordinal); } [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 +291,33 @@ 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); } - private async Task SendEventNoticeAsync(PersistentEvent ev) + private async Task SendEventNoticeAsync(PersistentEvent ev) { var user = _userData.GenerateSampleUser(); var project = _projectData.GenerateSampleProject(); @@ -207,165 +328,320 @@ 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); + Assert.Contains($"{_options.BaseURL}/event/{TestConstants.EventId}", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/stack/{TestConstants.StackId}/mark-fixed", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/stack/{TestConstants.StackId}/ignored", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/stack/{TestConstants.StackId}/discarded", body, StringComparison.Ordinal); + AssertContainsUrl(body, $"{_options.BaseURL}/account/manage?projectId={TestConstants.ProjectId}&tab=notifications"); + 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); + Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/dashboard", 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(); + // Act + await _mailer.SendOrganizationInviteAsync(user, organization, invite); + var body = await RunMailJobAsync(); - var sender = GetService() as InMemoryMailSender; - Assert.NotNull(sender); - - Assert.Contains("Join Organization", sender.LastMessage?.Body); + // Assert + Assert.Contains("Join Organization", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/signup?token={invite.Token}", 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); + Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/upgrade", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/frequent", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/manage", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/account/manage?tab=notifications", body, StringComparison.Ordinal); } [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); + Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/upgrade", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/frequent", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/manage", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/account/manage?tab=notifications", body, StringComparison.Ordinal); } [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); + Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/manage?tab=billing", body, StringComparison.Ordinal); } [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); + Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/timeline", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/frequent", body, StringComparison.Ordinal); + AssertContainsUrl(body, $"{_options.BaseURL}/account/manage?projectId={project.Id}&tab=notifications"); + Assert.All(mostFrequent, stack => AssertContainsUrl(body, $"{_options.BaseURL}/stack/{stack.Id}")); } [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); + Assert.Contains($"{_options.BaseURL}/organization/{project.OrganizationId}/upgrade", body, StringComparison.Ordinal); } [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); + Assert.Contains($"{_options.BaseURL}/project/{project.Id}/configure", 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); + Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/timeline", body, StringComparison.Ordinal); } [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); + Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/timeline", body, StringComparison.Ordinal); } [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); + Assert.Contains($"{_options.BaseURL}/organization/{project.OrganizationId}/upgrade", body, StringComparison.Ordinal); + Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/new", body, StringComparison.Ordinal); + Assert.All(mostFrequent.Concat(newest), stack => AssertContainsUrl(body, $"{_options.BaseURL}/stack/{stack.Id}")); } [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); + Assert.Contains($"{_options.BaseURL}/reset-password/{user.PasswordResetToken}", body, StringComparison.Ordinal); } [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); + Assert.Contains($"{_options.BaseURL}/account/verify?token={user.VerifyEmailAddressToken}", 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; + return String.Empty; + + var body = sender.LastMessage?.Body ?? String.Empty; _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("[^\\\"]+)\\\"") + .Select(match => match.Groups["url"].Value) + .ToArray(); } private Exception? GetException() From ac9d4dbc912132c6e2b6063dc29c824708f0853a Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 25 Aug 2026 15:55:02 -0500 Subject: [PATCH 02/11] test(mail): assert external email links by host and trim test ceremony --- tests/Exceptionless.Tests/Mail/MailerTests.cs | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/tests/Exceptionless.Tests/Mail/MailerTests.cs b/tests/Exceptionless.Tests/Mail/MailerTests.cs index 95cfb05ef7..83b753373b 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -19,13 +19,11 @@ namespace Exceptionless.Tests.Mail; public sealed class MailerTests : TestWithServices { - private static readonly HashSet _expectedExternalUrls = new(StringComparer.Ordinal) { - "https://exceptionless.com", - "https://github.com/exceptionless/Exceptionless/wiki/Frequently-Asked-Questions#q-what-happens-if-the-organization-plan-limit-is-reached", - "https://github.com/exceptionless/Exceptionless/wiki/Frequently-Asked-Questions#q-why-is-my-organization-throttled", - "https://www.facebook.com/exceptionless/", - "https://twitter.com/exceptionless", - "https://github.com/exceptionless/exceptionless" + private static readonly HashSet _expectedExternalHosts = new(StringComparer.OrdinalIgnoreCase) { + "exceptionless.com", + "github.com", + "www.facebook.com", + "twitter.com" }; private readonly IMailer _mailer; @@ -105,7 +103,7 @@ public async Task SendContactRequestAsync_WithCompleteRequest_RendersAllFields() public async Task SendEventNoticeAsync_WithSimpleError_RendersEventNotice() { // Arrange - var exception = GetException() ?? throw new InvalidOperationException("Expected test exception."); + var exception = GetException(); var ev = new PersistentEvent { Type = Event.KnownTypes.Error, @@ -145,7 +143,7 @@ public async Task SendEventNoticeAsync_WithStructuredError_RendersEventNotice() string body = await SendEventNoticeAsync(ev); // Assert - Assert.Contains("View Event Details", body, StringComparison.Ordinal); + Assert.Contains("Generated exception message.", body, StringComparison.Ordinal); } @@ -177,7 +175,7 @@ public async Task SendEventNoticeAsync_WithDetailedError_RendersEventNotice() } [Fact] - public async Task SendEventNoticeAsync_WithNotFoundEvent_RendersEventNotice() + public Task SendEventNoticeAsync_WithNotFoundEvent_RendersEventNotice() { // Arrange var ev = new PersistentEvent @@ -187,10 +185,7 @@ public async Task SendEventNoticeAsync_WithNotFoundEvent_RendersEventNotice() }; // Act - string body = await SendEventNoticeAsync(ev); - - // Assert - Assert.Contains("View Event Details", body, StringComparison.Ordinal); + return SendEventNoticeAsync(ev); } [Fact] @@ -212,7 +207,7 @@ public async Task SendEventNoticeAsync_WithFeatureEvent_RendersEventNotice() } [Fact] - public async Task SendEventNoticeAsync_WithEmptyLogEvent_RendersEventNotice() + public Task SendEventNoticeAsync_WithEmptyLogEvent_RendersEventNotice() { // Arrange var ev = new PersistentEvent @@ -222,10 +217,7 @@ public async Task SendEventNoticeAsync_WithEmptyLogEvent_RendersEventNotice() }; // Act - string body = await SendEventNoticeAsync(ev); - - // Assert - Assert.Contains("View Event Details", body, StringComparison.Ordinal); + return SendEventNoticeAsync(ev); } [Fact] @@ -622,7 +614,7 @@ private void AssertValidUrls(string body, bool requireUrls) if (!String.Equals(uri.Authority, baseUri.Authority, StringComparison.OrdinalIgnoreCase)) { - Assert.Contains(url, _expectedExternalUrls); + Assert.Contains(uri.Authority, _expectedExternalHosts); continue; } @@ -644,7 +636,7 @@ private static string[] GetUrls(string body) .ToArray(); } - private Exception? GetException() + private Exception GetException() { void TestInner() { @@ -659,12 +651,11 @@ void TestInnerInner() try { TestInner(); + throw new InvalidOperationException("Expected exception was not thrown."); } - catch (Exception ex) + catch (ApplicationException ex) { return ex; } - - return null; } } From 3fc6da8ad4890a84ffd005a7a741da04b9a7d8af Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 25 Aug 2026 21:25:34 -0500 Subject: [PATCH 03/11] test(mail): characterize not-found event source rendering in notice body --- tests/Exceptionless.Tests/Mail/MailerTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Exceptionless.Tests/Mail/MailerTests.cs b/tests/Exceptionless.Tests/Mail/MailerTests.cs index 83b753373b..de972ee2e4 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -175,7 +175,7 @@ public async Task SendEventNoticeAsync_WithDetailedError_RendersEventNotice() } [Fact] - public Task SendEventNoticeAsync_WithNotFoundEvent_RendersEventNotice() + public async Task SendEventNoticeAsync_WithNotFoundEvent_RendersSourceUrl() { // Arrange var ev = new PersistentEvent @@ -185,7 +185,10 @@ public Task SendEventNoticeAsync_WithNotFoundEvent_RendersEventNotice() }; // Act - return SendEventNoticeAsync(ev); + string body = await SendEventNoticeAsync(ev); + + // Assert + Assert.Contains("[GET] /not-found?page=20", WebUtility.HtmlDecode(body), StringComparison.Ordinal); } [Fact] From 9a0147e7c5d55eafd43f7bec0933d414e08a9053 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 26 Aug 2026 22:11:51 -0500 Subject: [PATCH 04/11] fix(mail): strengthen email URL contracts --- src/Exceptionless.Core/Mail/Mailer.cs | 4 + .../Mail/Templates/event-notice.html | 4 +- .../Mail/Templates/organization-added.html | 4 +- .../Mail/Templates/organization-invited.html | 4 +- .../Mail/Templates/organization-notice.html | 4 +- .../organization-payment-failed.html | 4 +- .../Mail/Templates/project-daily-summary.html | 4 +- .../Mail/Templates/user-email-verify.html | 4 +- .../Mail/Templates/user-password-reset.html | 4 +- .../src/pages/event-notice.html | 4 +- .../src/pages/organization-added.html | 4 +- .../src/pages/organization-invited.html | 4 +- .../src/pages/organization-notice.html | 4 +- .../pages/organization-payment-failed.html | 4 +- .../src/pages/project-daily-summary.html | 4 +- .../src/pages/user-email-verify.html | 4 +- .../src/pages/user-password-reset.html | 4 +- tests/Exceptionless.Tests/Mail/MailerTests.cs | 163 +++++++++++++----- 18 files changed, 158 insertions(+), 73 deletions(-) diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index 16394cca4d..9da988fbee 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; @@ -319,6 +320,9 @@ public Task SendUserPasswordResetAsync(User user) private string RenderTemplate(string name, IDictionary data) { + if (data.TryGetValue("Subject", out object? subject) && subject is string subjectValue) + data["SubjectJson"] = JsonEncodedText.Encode(subjectValue).ToString(); + var template = GetCompiledTemplate(name); return template(data); } diff --git a/src/Exceptionless.Core/Mail/Templates/event-notice.html b/src/Exceptionless.Core/Mail/Templates/event-notice.html index b07c342172..fb3f01c2a4 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..d61a20369a 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..882bee8170 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..124c78de6f 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..bfd5ad0ecb 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..fb840b4f6a 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..b52feeafe2 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..63877c573e 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..296610cfa6 100644 --- a/src/Exceptionless.EmailTemplates/src/pages/event-notice.html +++ b/src/Exceptionless.EmailTemplates/src/pages/event-notice.html @@ -75,7 +75,7 @@
Other Actions
{ "@context": "http://schema.org", "@type": "EmailMessage", - "description": "\{{Subject}}", + "description": "\{{SubjectJson}}", "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..bcb9a3b899 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": "\{{SubjectJson}}", "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..019d108010 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": "\{{SubjectJson}}", "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..be67f949b6 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": "\{{SubjectJson}}", "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..49b5d3435d 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": "\{{SubjectJson}}", "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..38bb5fdf21 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": "\{{SubjectJson}}", "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..ea1b5fb79a 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": "\{{SubjectJson}}", "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..e89c43e101 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": "\{{SubjectJson}}", "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 de972ee2e4..715c7416e5 100644 --- a/tests/Exceptionless.Tests/Mail/MailerTests.cs +++ b/tests/Exceptionless.Tests/Mail/MailerTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text.Json; using System.Text.RegularExpressions; using Exceptionless.Core; using Exceptionless.Core.Billing; @@ -19,11 +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 static readonly HashSet _expectedExternalUrls = new(StringComparer.Ordinal) { + "https://exceptionless.com", + "https://github.com/exceptionless/exceptionless", + "https://github.com/exceptionless/Exceptionless/wiki/Frequently-Asked-Questions#q-what-happens-if-the-organization-plan-limit-is-reached", + "https://github.com/exceptionless/Exceptionless/wiki/Frequently-Asked-Questions#q-why-is-my-organization-throttled", + "https://twitter.com/exceptionless", + "https://www.facebook.com/exceptionless/" }; private readonly IMailer _mailer; @@ -325,11 +328,11 @@ private async Task SendEventNoticeAsync(PersistentEvent ev) await _mailer.SendEventNoticeAsync(user, ev, project, RandomData.GetBool(), RandomData.GetBool(), 1); var body = await RunMailJobAsync(); Assert.Contains("View Event Details", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/event/{TestConstants.EventId}", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/stack/{TestConstants.StackId}/mark-fixed", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/stack/{TestConstants.StackId}/ignored", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/stack/{TestConstants.StackId}/discarded", body, StringComparison.Ordinal); - AssertContainsUrl(body, $"{_options.BaseURL}/account/manage?projectId={TestConstants.ProjectId}&tab=notifications"); + AssertContainsHref(body, $"{_options.BaseURL}/event/{TestConstants.EventId}"); + AssertContainsHref(body, $"{_options.BaseURL}/stack/{TestConstants.StackId}/mark-fixed"); + AssertContainsHref(body, $"{_options.BaseURL}/stack/{TestConstants.StackId}/ignored"); + AssertContainsHref(body, $"{_options.BaseURL}/stack/{TestConstants.StackId}/discarded"); + AssertContainsHref(body, $"{_options.BaseURL}/account/manage?projectId={TestConstants.ProjectId}&tab=notifications"); return body; } @@ -346,7 +349,7 @@ public async Task SendOrganizationAddedAsync_WithOrganization_RendersOrganizatio // Assert Assert.Contains("View Organization", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/dashboard", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/dashboard"); } [Fact] @@ -368,7 +371,7 @@ public async Task SendOrganizationInviteAsync_WithInvite_RendersSignupLink() // Assert Assert.Contains("Join Organization", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/signup?token={invite.Token}", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/signup?token={invite.Token}"); } [Fact] @@ -384,10 +387,10 @@ public async Task SendOrganizationNoticeAsync_WithHourlyOverage_RendersUsageLink // Assert Assert.Contains("throttled", body, StringComparison.OrdinalIgnoreCase); - Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/upgrade", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/frequent", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/manage", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/account/manage?tab=notifications", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/upgrade"); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/frequent"); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/manage"); + AssertContainsHref(body, $"{_options.BaseURL}/account/manage?tab=notifications"); } [Fact] @@ -403,10 +406,10 @@ public async Task SendOrganizationNoticeAsync_WithMonthlyOverage_RendersUsageLin // Assert Assert.Contains("monthly plan limit", body, StringComparison.OrdinalIgnoreCase); - Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/upgrade", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/frequent", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/manage", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/account/manage?tab=notifications", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/upgrade"); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/frequent"); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/manage"); + AssertContainsHref(body, $"{_options.BaseURL}/account/manage?tab=notifications"); } [Fact] @@ -422,7 +425,7 @@ public async Task SendOrganizationPaymentFailedAsync_WithOrganization_RendersBil // Assert Assert.Contains("Payment failed", body, StringComparison.OrdinalIgnoreCase); - Assert.Contains($"{_options.BaseURL}/organization/{organization.Id}/manage?tab=billing", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/manage?tab=billing"); } [Fact] @@ -442,10 +445,10 @@ public async Task SendProjectDailySummaryAsync_WithSubmittedEvents_RendersTimeli // Assert Assert.Contains("View Timeline", body, StringComparison.Ordinal); Assert.Contains("Most Frequent", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/timeline", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/frequent", body, StringComparison.Ordinal); - AssertContainsUrl(body, $"{_options.BaseURL}/account/manage?projectId={project.Id}&tab=notifications"); - Assert.All(mostFrequent, stack => AssertContainsUrl(body, $"{_options.BaseURL}/stack/{stack.Id}")); + AssertContainsHref(body, $"{_options.BaseURL}/project/{project.Id}/error/timeline"); + AssertContainsHref(body, $"{_options.BaseURL}/project/{project.Id}/error/frequent"); + AssertContainsHref(body, $"{_options.BaseURL}/account/manage?projectId={project.Id}&tab=notifications"); + Assert.All(mostFrequent, stack => AssertContainsHref(body, $"{_options.BaseURL}/stack/{stack.Id}")); } [Fact] @@ -462,7 +465,7 @@ public async Task SendProjectDailySummaryAsync_WithAllEventsBlocked_RendersThrot // Assert Assert.Contains("discarded due to throttling", body, StringComparison.OrdinalIgnoreCase); - Assert.Contains($"{_options.BaseURL}/organization/{project.OrganizationId}/upgrade", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{project.OrganizationId}/upgrade"); } [Fact] @@ -478,7 +481,7 @@ public async Task SendProjectDailySummaryAsync_WithUnconfiguredProject_RendersCo // Assert Assert.Contains("Configure Project", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/project/{project.Id}/configure", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/project/{project.Id}/configure"); } [Fact] @@ -494,7 +497,7 @@ public async Task SendProjectDailySummaryAsync_WithOnlyFixedEvents_RendersFixedC // Assert Assert.Contains("marked as fixed", body, StringComparison.OrdinalIgnoreCase); - Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/timeline", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/project/{project.Id}/error/timeline"); } [Fact] @@ -510,7 +513,7 @@ public async Task SendProjectDailySummaryAsync_WithFixedAndOversizedEvents_Rende // Assert Assert.Contains("marked as fixed", body, StringComparison.OrdinalIgnoreCase); - Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/timeline", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/project/{project.Id}/error/timeline"); } [Fact] @@ -531,9 +534,9 @@ public async Task SendProjectDailySummaryAsync_WithFreeProject_RendersPlanConten // Assert Assert.Contains("free plan", body, StringComparison.OrdinalIgnoreCase); - Assert.Contains($"{_options.BaseURL}/organization/{project.OrganizationId}/upgrade", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/project/{project.Id}/error/new", body, StringComparison.Ordinal); - Assert.All(mostFrequent.Concat(newest), stack => AssertContainsUrl(body, $"{_options.BaseURL}/stack/{stack.Id}")); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{project.OrganizationId}/upgrade"); + AssertContainsHref(body, $"{_options.BaseURL}/project/{project.Id}/error/new"); + Assert.All(mostFrequent.Concat(newest), stack => AssertContainsHref(body, $"{_options.BaseURL}/stack/{stack.Id}")); } [Fact] @@ -551,7 +554,8 @@ public async Task SendUserPasswordResetAsync_WithResetToken_RendersResetLinks() 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); - Assert.Contains($"{_options.BaseURL}/reset-password/{user.PasswordResetToken}", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/reset-password/{user.PasswordResetToken}"); + AssertContainsHref(body, $"{_options.BaseURL}/reset-password/{user.PasswordResetToken}?cancel=true"); } [Fact] @@ -567,7 +571,7 @@ public async Task SendUserEmailVerifyAsync_WithVerificationToken_RendersVerifica // Assert Assert.Contains("Verify Address", body, StringComparison.Ordinal); - Assert.Contains($"{_options.BaseURL}/account/verify?token={user.VerifyEmailAddressToken}", body, StringComparison.Ordinal); + AssertContainsHref(body, $"{_options.BaseURL}/account/verify?token={user.VerifyEmailAddressToken}"); } private async Task RunMailJobAsync(bool requireUrls = true) @@ -594,7 +598,9 @@ private async Task RunMailJobAsync(bool requireUrls = true) private void AssertValidUrls(string body, bool requireUrls) { - var urls = GetUrls(body); + var hrefs = GetHrefs(body); + var structuredDataUrls = GetStructuredDataUrls(body); + var urls = hrefs.Concat(structuredDataUrls).ToArray(); if (!requireUrls) { Assert.Empty(urls); @@ -617,28 +623,103 @@ private void AssertValidUrls(string body, bool requireUrls) if (!String.Equals(uri.Authority, baseUri.Authority, StringComparison.OrdinalIgnoreCase)) { - Assert.Contains(uri.Authority, _expectedExternalHosts); + Assert.Contains(url, _expectedExternalUrls); continue; } Assert.DoesNotContain("/next/", uri.PathAndQuery, StringComparison.OrdinalIgnoreCase); - 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.PathAndQuery); + AssertValidInternalUrl(uri); } } - private static void AssertContainsUrl(string body, string expectedUrl) + private static void AssertValidInternalUrl(Uri uri) { - Assert.Contains(expectedUrl, GetUrls(body)); + 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 static string[] GetUrls(string body) + private static void AssertContainsHref(string body, string expectedUrl) + { + Assert.Contains(expectedUrl, GetHrefs(body)); + } + + private static string[] GetHrefs(string body) { string decodedBody = WebUtility.HtmlDecode(body); - return Regex.Matches(decodedBody, "(?:href=|\\\"(?:target|url)\\\":\\s*)\\\"(?[^\\\"]+)\\\"") + return Regex.Matches(decodedBody, "href\\s*=\\s*[\"'](?[^\"']+)[\"']", RegexOptions.IgnoreCase) .Select(match => match.Groups["url"].Value) .ToArray(); } + private static string[] GetStructuredDataUrls(string body) + { + 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); + } + + return urls.ToArray(); + } + + private static void AddStructuredDataUrls(JsonElement element, ICollection urls) + { + if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + AddStructuredDataUrls(item, urls); + + return; + } + + if (element.ValueKind != JsonValueKind.Object) + return; + + foreach (JsonProperty property in element.EnumerateObject()) + { + if (property.NameEquals("target") || property.NameEquals("url")) + { + Assert.Equal(JsonValueKind.String, property.Value.ValueKind); + urls.Add(property.Value.GetString()!); + } + + AddStructuredDataUrls(property.Value, urls); + } + } + private Exception GetException() { void TestInner() From b0504c92af12928f994b3498ebc1b0ef746a0859 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 25 Aug 2026 07:16:56 -0500 Subject: [PATCH 05/11] fix(mail): safely render event links and regression labels --- src/Exceptionless.Core/Mail/Mailer.cs | 12 +++- .../Mail/Templates/event-notice.html | 2 +- .../src/pages/event-notice.html | 2 +- tests/Exceptionless.Tests/Mail/MailerTests.cs | 60 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index 9da988fbee..217edbc18c 100644 --- a/src/Exceptionless.Core/Mail/Mailer.cs +++ b/src/Exceptionless.Core/Mail/Mailer.cs @@ -116,7 +116,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)) @@ -136,6 +139,12 @@ 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 href = $"mailto:{Uri.EscapeDataString(emailAddress)}"; + return String.IsNullOrEmpty(body) ? href : $"{href}?body={Uri.EscapeDataString(body)}"; + } + private static void AddDefaultFields(PersistentEvent ev, Dictionary data) { if (ev.Tags?.Count > 0) @@ -270,7 +279,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 }); } diff --git a/src/Exceptionless.Core/Mail/Templates/event-notice.html b/src/Exceptionless.Core/Mail/Templates/event-notice.html index fb3f01c2a4..010bd8541e 100644 --- a/src/Exceptionless.Core/Mail/Templates/event-notice.html +++ b/src/Exceptionless.Core/Mail/Templates/event-notice.html @@ -1,4 +1,4 @@ -{{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