diff --git a/src/Exceptionless.Core/Mail/Mailer.cs b/src/Exceptionless.Core/Mail/Mailer.cs index 16394cca4d..217edbc18c 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; @@ -115,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)) @@ -135,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) @@ -269,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 }); } @@ -319,6 +330,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..010bd8541e 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..551494aef5 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": "\{{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 582953a94b..055fee838f 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,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", + "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; private readonly AppOptions _options; private readonly BillingManager _billingManager; @@ -44,9 +56,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 +74,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(); + 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 +140,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 +168,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 +289,57 @@ 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) + [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("victim@example.com", "hello&bcc=attacker@example.com") } + } + }; + + // Act + string body = await SendEventNoticeAsync(ev); + + // Assert + AssertContainsHref(body, "mailto:victim%40example.com?body=hello%26bcc%3Dattacker%40example.com"); + 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 +350,436 @@ 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); + 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; } [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); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/dashboard"); } [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); + AssertContainsHref(body, $"{_options.BaseURL}/signup?token={invite.Token}"); } [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); + 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] - 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); + 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] - 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); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{organization.Id}/manage?tab=billing"); } [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); + 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] - 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); + AssertContainsHref(body, $"{_options.BaseURL}/organization/{project.OrganizationId}/upgrade"); } [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); + AssertContainsHref(body, $"{_options.BaseURL}/project/{project.Id}/configure"); } [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); + AssertContainsHref(body, $"{_options.BaseURL}/project/{project.Id}/error/timeline"); } [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); + AssertContainsHref(body, $"{_options.BaseURL}/project/{project.Id}/error/timeline"); } [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); + 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] - public async Task SendUserPasswordResetAsync() + 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); + AssertContainsHref(body, $"{_options.BaseURL}/stack/{regressedStack.Id}"); + } + + [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); + AssertContainsHref(body, $"{_options.BaseURL}/stack/{openStack.Id}"); + } + + [Fact] + 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); + AssertContainsHref(body, $"{_options.BaseURL}/reset-password/{user.PasswordResetToken}"); + AssertContainsHref(body, $"{_options.BaseURL}/reset-password/{user.PasswordResetToken}?cancel=true"); } [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); + AssertContainsHref(body, $"{_options.BaseURL}/account/verify?token={user.VerifyEmailAddressToken}"); } - 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("[^\"']+)[\"']", 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 Exception? GetException() + 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() { @@ -383,12 +794,11 @@ void TestInnerInner() try { TestInner(); + throw new InvalidOperationException("Expected exception was not thrown."); } - catch (Exception ex) + catch (ApplicationException ex) { return ex; } - - return null; } }