Skip to content

Commit 5683015

Browse files
committed
Merge branch 'main' into feature/posts
2 parents c06fce2 + 0e26a13 commit 5683015

17 files changed

Lines changed: 352 additions & 254 deletions

File tree

benchmarks/Jordnaer.Benchmarks/Jordnaer.Benchmarks.csproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12-
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
13-
<PackageReference Include="Bogus" Version="35.6.2" />
14-
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.3" />
12+
<PackageReference Include="BenchmarkDotNet" Version="0.15.2" />
13+
<PackageReference Include="Bogus" Version="35.6.3" />
14+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.7" />
1515
</ItemGroup>
1616

1717
<ItemGroup>

src/shared/Jordnaer.Shared/Jordnaer.Shared.csproj

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77

88
<ItemGroup>
99
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
10-
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="9.0.3" />
11-
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.3" />
12-
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.3" />
13-
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.3" />
14-
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.3.0" />
10+
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="9.0.7" />
11+
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
12+
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.7" />
13+
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.7" />
14+
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0" />
1515
<PackageReference Include="NetEscapades.EnumGenerators" Version="1.0.0-beta12" />
1616
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
17-
<PackageReference Include="MassTransit.Abstractions" Version="8.3.7" />
17+
<PackageReference Include="MassTransit.Abstractions" Version="8.5.1" />
1818
</ItemGroup>
1919

2020
<ItemGroup>
Lines changed: 65 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,36 @@
1+
using Azure;
2+
using Azure.Communication.Email;
13
using Jordnaer.Extensions;
24
using Jordnaer.Features.Email;
35
using Jordnaer.Features.Metrics;
46
using MassTransit;
57
using Polly;
68
using Polly.CircuitBreaker;
79
using Polly.Retry;
8-
using SendGrid;
9-
using SendGrid.Helpers.Mail;
10-
using Response = SendGrid.Response;
1110

1211
namespace Jordnaer.Consumers;
1312

1413
public class SendEmailConsumer(
15-
ILogger<SendMessageConsumer> logger,
16-
ISendGridClient sendGridClient)
14+
ILogger<SendEmailConsumer> logger,
15+
EmailClient emailClient)
1716
: IConsumer<SendEmail>
1817
{
19-
private static readonly ResiliencePipeline<Response> Retry =
20-
new ResiliencePipelineBuilder<Response>()
21-
.AddRetry(new RetryStrategyOptions<Response>
18+
private static readonly ResiliencePipeline<EmailSendOperation> Retry =
19+
new ResiliencePipelineBuilder<EmailSendOperation>()
20+
.AddRetry(new RetryStrategyOptions<EmailSendOperation>
2221
{
23-
ShouldHandle = new PredicateBuilder<Response>()
22+
ShouldHandle = new PredicateBuilder<EmailSendOperation>()
2423
.Handle<Exception>()
25-
.HandleResult(x => !x.IsSuccessStatusCode),
24+
.HandleResult(x => x.HasValue is false)
25+
.HandleResult(x => x.GetRawResponse().IsError),
2626
BackoffType = DelayBackoffType.Exponential,
2727
Delay = TimeSpan.FromSeconds(1),
2828
UseJitter = true,
29-
MaxDelay = TimeSpan.FromSeconds(30),
30-
MaxRetryAttempts = 8,
31-
Name = "SendGridRetry"
29+
MaxDelay = TimeSpan.FromMinutes(5),
30+
MaxRetryAttempts = 15,
31+
Name = "AzureEmailRetry"
3232
})
33-
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<Response>
33+
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<EmailSendOperation>
3434
{
3535
FailureRatio = 0.20,
3636
SamplingDuration = TimeSpan.FromSeconds(30),
@@ -39,62 +39,73 @@ public class SendEmailConsumer(
3939
})
4040
.Build();
4141

42-
private static readonly TrackingSettings DefaultTrackingSettings = new()
43-
{
44-
ClickTracking = new ClickTracking { Enable = false },
45-
Ganalytics = new Ganalytics { Enable = false },
46-
OpenTracking = new OpenTracking { Enable = false },
47-
SubscriptionTracking = new SubscriptionTracking { Enable = false }
48-
};
49-
5042
public async Task Consume(ConsumeContext<SendEmail> consumeContext)
5143
{
5244
logger.LogFunctionBegan();
53-
5445
var message = consumeContext.Message;
55-
var email = new SendGridMessage
56-
{
57-
From = message.From ?? EmailConstants.ContactEmail, // Must be from a verified email
58-
Subject = message.Subject,
59-
HtmlContent = message.HtmlContent
60-
};
6146

62-
if (message.To?.Count is > 0)
63-
{
64-
email.AddTos(message.To);
65-
}
47+
var emailMessage = new EmailMessage(senderAddress: message.From?.Email ?? EmailConstants.ContactEmail.Email,
48+
content: new EmailContent(message.Subject)
49+
{
50+
Html = message.HtmlContent
51+
},
52+
recipients: new EmailRecipients(
53+
to: message.To?.Select(ConvertToEmailAddress).ToList(),
54+
bcc: message.Bcc?.Select(ConvertToEmailAddress).ToList()
55+
))
56+
{ UserEngagementTrackingDisabled = message.DisableUserEngagementTracking };
6657

58+
// Set reply-to if specified
6759
if (message.ReplyTo is not null)
6860
{
69-
email.ReplyTo = message.ReplyTo;
61+
emailMessage.ReplyTo.Add(ConvertToEmailAddress(message.ReplyTo));
7062
}
7163

72-
if (message.Bcc?.Count is > 0)
64+
try
7365
{
74-
email.AddBccs(message.Bcc);
75-
}
66+
var response = await Retry.ExecuteAsync(
67+
async token => await emailClient.SendAsync(WaitUntil.Completed, emailMessage, token),
68+
consumeContext.CancellationToken);
7669

77-
email.TrackingSettings = message.TrackingSettings ?? DefaultTrackingSettings;
70+
var rawResponse = response.GetRawResponse();
7871

79-
var response = await Retry.ExecuteAsync(
80-
async token => await sendGridClient.SendEmailAsync(email, token),
81-
consumeContext.CancellationToken);
72+
if (rawResponse.IsError)
73+
{
74+
logger.LogError("Failed to send email to {Recipients}. " +
75+
"StatusCode: {StatusCode}. " +
76+
"Response: {Response}. " +
77+
"Email: {Email}",
78+
message.GetAllRecipients(),
79+
rawResponse.Status,
80+
rawResponse.ReasonPhrase,
81+
message);
82+
}
83+
else
84+
{
85+
logger.LogInformation("Email(s) sent to: {Recipients}. MessageId: {MessageId}",
86+
message.GetAllRecipients(), response.Id);
8287

83-
if (!response.IsSuccessStatusCode)
84-
{
85-
logger.LogError("Failed to send email to {@Recipient}. " +
86-
"StatusCode: {StatusCode}. " +
87-
"Response: {Response}. " +
88-
"Email: {Email}",
89-
message.To, response.StatusCode.ToString(),
90-
await response.Body.ReadAsStringAsync(), message);
88+
JordnaerMetrics.EmailsSentCounter.Add(1);
89+
}
9190
}
92-
else
91+
catch (RequestFailedException exception)
9392
{
94-
logger.LogInformation("Email sent to {@Recipient}. Subject: {Subject}",
95-
message.To?.Select(x => x.Email), message.Subject);
96-
97-
JordnaerMetrics.EmailsSentCounter.Add(1);
93+
logger.LogError(exception, "Failed to send email to {Recipients}. " +
94+
"ErrorCode: {ErrorCode}. " +
95+
"Message: {Message}. " +
96+
"Sender: {Email}",
97+
message.GetAllRecipients(),
98+
exception.ErrorCode,
99+
exception.Message,
100+
message.From?.ToString());
101+
throw;
98102
}
99103
}
104+
105+
private static EmailAddress ConvertToEmailAddress(EmailRecipient recipient)
106+
{
107+
return string.IsNullOrWhiteSpace(recipient.DisplayName)
108+
? new EmailAddress(recipient.Email)
109+
: new EmailAddress(recipient.Email, recipient.DisplayName);
110+
}
100111
}

src/web/Jordnaer/Features/Chat/ChatNotificationService.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using Microsoft.AspNetCore.Hosting.Server;
77
using Microsoft.AspNetCore.Hosting.Server.Features;
88
using Microsoft.EntityFrameworkCore;
9-
using SendGrid.Helpers.Mail;
109

1110
namespace Jordnaer.Features.Chat;
1211

@@ -22,9 +21,8 @@ public async Task NotifyRecipients(StartChat startChat, CancellationToken cancel
2221
var recipientIds = startChat.Recipients.Select(recipient => recipient.Id);
2322
var recipients = await context.Users
2423
.AsNoTracking()
25-
.Where(x => recipientIds.Contains(x.Id))
26-
.Select(x => new { x.Email, x.Id })
27-
.ToDictionaryAsync(x => x.Id, x => x.Email, cancellationToken);
24+
.Where(x => recipientIds.Contains(x.Id) && !string.IsNullOrEmpty(x.Email))
25+
.ToDictionaryAsync(x => x.Id, x => x.Email!, cancellationToken);
2826

2927
var emailsToSend = CreateEmails(startChat, recipients);
3028

@@ -33,7 +31,7 @@ public async Task NotifyRecipients(StartChat startChat, CancellationToken cancel
3331
logger.LogInformation("Sent emails to users notifying them about a newly started chat.");
3432
}
3533

36-
internal IEnumerable<SendEmail> CreateEmails(StartChat startChat, Dictionary<string, string?> recipients)
34+
internal IEnumerable<SendEmail> CreateEmails(StartChat startChat, Dictionary<string, string> recipients)
3735
{
3836
var initiator = startChat.Recipients.First(x => x.Id == startChat.InitiatorId);
3937

@@ -43,7 +41,11 @@ internal IEnumerable<SendEmail> CreateEmails(StartChat startChat, Dictionary<str
4341

4442
var email = recipients[recipientId];
4543

46-
var recipientsEmailAddress = new EmailAddress(email, user.DisplayName);
44+
var recipientsEmailAddress = new EmailRecipient
45+
{
46+
Email = email,
47+
DisplayName = user.DisplayName
48+
};
4749

4850
yield return new SendEmail
4951
{

src/web/Jordnaer/Features/DeleteUser/DeleteUserService.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using Microsoft.AspNetCore.Components;
77
using Microsoft.AspNetCore.Identity;
88
using Microsoft.EntityFrameworkCore;
9-
using SendGrid.Helpers.Mail;
109
using Serilog;
1110

1211
namespace Jordnaer.Features.DeleteUser;
@@ -51,7 +50,7 @@ public async Task<bool> InitiateDeleteUserAsync(string userId, CancellationToken
5150
return false;
5251
}
5352

54-
var to = new EmailAddress(user.Email);
53+
var to = new EmailRecipient { Email = user.Email! };
5554

5655
var token = await userManager.GenerateUserTokenAsync(user, TokenProvider, TokenPurpose);
5756

src/web/Jordnaer/Features/Email/EmailConstants.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
using SendGrid.Helpers.Mail;
2-
31
namespace Jordnaer.Features.Email;
42

53
public static class EmailConstants
64
{
7-
public static readonly EmailAddress ContactEmail =
8-
new("kontakt@mini-moeder.dk", "Kontakt @ Mini Møder");
5+
public static readonly EmailRecipient ContactEmail = new() { Email = "kontakt@mini-moeder.dk", DisplayName = "Kontakt @ Mini Møder" };
96

107
public static readonly string Signature = """
118
<p>Venlig hilsen,<br />
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
namespace Jordnaer.Features.Email;
2+
3+
/// <summary>
4+
/// Represents an email recipient for Azure Communication Services.
5+
/// </summary>
6+
public class EmailRecipient
7+
{
8+
public required string Email { get; init; }
9+
public string? DisplayName { get; init; }
10+
11+
/// <summary>
12+
/// Concatenates all non-null recipient lists for logging purposes.
13+
/// Returns GDPR-compliant masked email addresses.
14+
/// </summary>
15+
public static string ConcatRecipients(params IEnumerable<EmailRecipient>?[] recipientLists)
16+
{
17+
var allRecipients = recipientLists
18+
.Where(list => list != null)
19+
.SelectMany(list => list!)
20+
.ToList();
21+
22+
return allRecipients.Count == 0
23+
? "[no recipients]"
24+
: string.Join(", ", allRecipients.Select(r => r.ToString()));
25+
}
26+
27+
/// <summary>
28+
/// Returns a GDPR-compliant string representation that masks the email address.
29+
/// </summary>
30+
public override string ToString()
31+
{
32+
var maskedEmail = MaskEmailAddress(Email);
33+
34+
return string.IsNullOrWhiteSpace(DisplayName)
35+
? maskedEmail
36+
: $"{DisplayName} <{maskedEmail}>";
37+
}
38+
39+
private static string MaskEmailAddress(string email)
40+
{
41+
if (string.IsNullOrWhiteSpace(email))
42+
{
43+
return "[invalid]";
44+
}
45+
46+
var atIndex = email.LastIndexOf('@');
47+
if (atIndex <= 0 || atIndex == email.Length - 1)
48+
{
49+
return "[invalid]";
50+
}
51+
52+
var localPart = email[..atIndex];
53+
var domain = email[(atIndex + 1)..];
54+
55+
// Mask local part - show first character and last character (if length > 2)
56+
var maskedLocal = localPart.Length switch
57+
{
58+
1 => "*",
59+
2 => $"{localPart[0]}*",
60+
_ => $"{localPart[0]}***{localPart[^1]}"
61+
};
62+
63+
// Mask domain - show first character and keep the TLD
64+
var dotIndex = domain.LastIndexOf('.');
65+
if (dotIndex <= 0)
66+
{
67+
// No TLD found, just mask most of domain
68+
var maskedDomain = domain.Length > 1 ? $"{domain[0]}***" : "*";
69+
return $"{maskedLocal}@{maskedDomain}";
70+
}
71+
72+
var domainName = domain[..dotIndex];
73+
var tld = domain[dotIndex..];
74+
var maskedDomainName = domainName.Length > 1 ? $"{domainName[0]}***" : "*";
75+
76+
return $"{maskedLocal}@{maskedDomainName}{tld}";
77+
}
78+
}

0 commit comments

Comments
 (0)