Skip to content

Commit e731180

Browse files
jhartmann123davidroth
authored andcommitted
feature: Allow specific domains to be ignored for sending emails
1 parent c42a21d commit e731180

File tree

6 files changed

+185
-4
lines changed

6 files changed

+185
-4
lines changed

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>10.0.1</Version>
3+
<Version>10.1.0</Version>
44
<TargetFramework>net10.0</TargetFramework>
55
</PropertyGroup>
66

src/Email/src/AssemblyInfo.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Copyright (c) Fusonic GmbH. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE file in the project root for license information.
3+
4+
using System.Runtime.CompilerServices;
5+
6+
[assembly:InternalsVisibleTo("Fusonic.Extensions.Email.Tests")]

src/Email/src/EmailOptions.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,27 @@ public class EmailOptions
6969
/// </summary>
7070
public Func<BlazorPostProcessContext, ValueTask<string>>? BlazorContentPostProcessor { get; set; } = static ctx => ValueTask.FromResult(CssInliner.Inline(ctx.Html));
7171

72+
private static readonly string[] DefaultIgnoredDomains = ["invalid"];
73+
74+
/// <summary>
75+
/// Ignores domains when sending emails. The domain "invalid" always gets ignored, as it may cause bounces and may impact the sender score.
76+
/// </summary>
77+
/// <remarks>
78+
/// As per <a href="https://datatracker.ietf.org/doc/html/rfc6761">RFC6761</a> the domain ".invalid" is reserved and should result in negative responses.
79+
/// The domain ".invalid" is the only domain that MAY be recognized by applications AND should cause a negative response.
80+
/// This is not the case for other testing domains like ".test" or "example.com", which is why they are not on the default ignore list.
81+
/// </remarks>
82+
public IReadOnlyList<string> IgnoredDomains { get; set; } = DefaultIgnoredDomains;
83+
84+
internal IReadOnlyList<string> IgnoredDomainsCleaned => field ??=
85+
IgnoredDomains
86+
.Select(d => d.ToLowerInvariant().Trim())
87+
.Union(DefaultIgnoredDomains)
88+
.Distinct()
89+
.Select(d => d.StartsWith(".", StringComparison.OrdinalIgnoreCase) ? d : "." + d)
90+
.ToList()
91+
.AsReadOnly();
92+
7293
internal void Validate()
7394
{
7495
var errors = new List<string>();

src/Email/src/SendEmail.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Globalization;
55
using Fusonic.Extensions.Mediator;
6+
using Microsoft.Extensions.Logging;
67
using MimeKit;
78

89
namespace Fusonic.Extensions.Email;
@@ -33,12 +34,18 @@ public record SendEmail(
3334
string? ReplyTo = null) : ICommand
3435
{
3536
[OutOfBand]
36-
public class Handler(EmailOptions emailOptions, ISmtpClient smtpClient, IEnumerable<IEmailRenderingService> emailRenderingServices, IEnumerable<IEmailAttachmentResolver> emailAttachmentResolvers) : AsyncRequestHandler<SendEmail>, IAsyncDisposable
37+
public class Handler(EmailOptions emailOptions, ISmtpClient smtpClient, IEnumerable<IEmailRenderingService> emailRenderingServices, IEnumerable<IEmailAttachmentResolver> emailAttachmentResolvers, ILogger<SendEmail> logger) : AsyncRequestHandler<SendEmail>, IAsyncDisposable
3738
{
3839
private readonly List<Stream> openedStreams = [];
3940

4041
protected override async Task Handle(SendEmail request, CancellationToken cancellationToken)
4142
{
43+
if (ShouldIgnore(request.Recipient))
44+
{
45+
logger.LogInformation("Email of type {Type} to {Recipient} ignored. The domain is on the ignore list.", request.ViewModel?.GetType()?.Name, request.Recipient);
46+
return;
47+
}
48+
4249
var emailRenderingService = GetRenderingService(request.ViewModel);
4350

4451
var (subject, body) = await emailRenderingService.RenderAsync(request.ViewModel, request.Culture, request.SubjectKey, request.SubjectFormatParameters);
@@ -54,7 +61,7 @@ protected override async Task Handle(SendEmail request, CancellationToken cancel
5461
Subject = subject
5562
};
5663

57-
if (!string.IsNullOrWhiteSpace(request.BccRecipient))
64+
if (!string.IsNullOrWhiteSpace(request.BccRecipient) && !ShouldIgnore(request.BccRecipient))
5865
{
5966
message.Bcc.Add(new MailboxAddress(request.BccRecipient, request.BccRecipient));
6067
}
@@ -115,6 +122,12 @@ private async Task<MimeEntity> GetMessageBody(string htmlBody, Attachment[]? att
115122
return builder.ToMessageBody();
116123
}
117124

125+
private bool ShouldIgnore(string recipient)
126+
{
127+
var domain = "." + recipient.Split('@').Last();
128+
return emailOptions.IgnoredDomainsCleaned.Any(c => domain.EndsWith(c, StringComparison.OrdinalIgnoreCase));
129+
}
130+
118131
private IEmailRenderingService GetRenderingService(object viewModel)
119132
=> emailRenderingServices.FirstOrDefault(r => r.Supports(viewModel))
120133
?? throw new InvalidOperationException("No email rendering service registered for view model.");
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) Fusonic GmbH. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE file in the project root for license information.
3+
4+
using System.ComponentModel.DataAnnotations;
5+
6+
namespace Fusonic.Extensions.Email.Tests;
7+
8+
public class EmailOptionsTests
9+
{
10+
[Fact]
11+
public void IgnoredDomainsCleaned_NormalizesConfiguredDomainsAndIncludesInvalid()
12+
{
13+
// Arrange
14+
var options = new EmailOptions
15+
{
16+
IgnoredDomains = [" example.com ", ".Sub.Domain", "EXAMPLE.COM"]
17+
};
18+
19+
// Act
20+
var ignoredDomains = options.IgnoredDomainsCleaned;
21+
22+
// Assert
23+
ignoredDomains.Should().BeEquivalentTo([".example.com", ".sub.domain", ".invalid"], o => o.WithStrictOrdering());
24+
}
25+
26+
[Fact]
27+
public void Validate_ValidSmtpOptions_DoesNotThrow()
28+
{
29+
// Arrange
30+
var options = new EmailOptions
31+
{
32+
SenderAddress = "sender@fusonic.net",
33+
SenderName = "Fusonic",
34+
SmtpServer = "smtp.fusonic.net",
35+
SmtpPort = 25
36+
};
37+
38+
// Act
39+
var act = () => options.Validate();
40+
41+
// Assert
42+
act.Should().NotThrow();
43+
}
44+
45+
[Fact]
46+
public void Validate_StoreInDirectorySet_DoesNotRequireSmtpSettings()
47+
{
48+
// Arrange
49+
var options = new EmailOptions
50+
{
51+
SenderAddress = "sender@fusonic.net",
52+
SenderName = "Fusonic",
53+
StoreInDirectory = "c:\\emails",
54+
SmtpPort = 0
55+
};
56+
57+
// Act
58+
var act = () => options.Validate();
59+
60+
// Assert
61+
act.Should().NotThrow();
62+
}
63+
64+
[Fact]
65+
public void Validate_InvalidOptions_ThrowsValidationExceptionWithAllMessages()
66+
{
67+
// Arrange
68+
var options = new EmailOptions
69+
{
70+
SmtpPort = 0
71+
};
72+
73+
// Act
74+
var act = () => options.Validate();
75+
76+
// Assert
77+
act.Should().Throw<ValidationException>()
78+
.Which.Message.Should().ContainAll("SenderAddress is required.", "SmtpServer is required.", "SMTP port is invalid.");
79+
}
80+
}

src/Email/test/SendEmailTests.cs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ public partial class SendEmailTests(SendEmailTests.SendEmailFixture fixture) : T
1616
[Fact]
1717
public async Task SendEmail_Razor()
1818
{
19+
// Arrange
1920
Fixture.SmtpServer!.ClearReceivedEmail();
2021

2122
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
23+
24+
// Act
2225
await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model));
2326

27+
// Assert
2428
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(1);
2529
var email = Fixture.SmtpServer.ReceivedEmail.Single();
2630
email.ToAddresses
@@ -44,11 +48,15 @@ public async Task SendEmail_Razor()
4448
[Fact]
4549
public async Task SendEmail_Blazor()
4650
{
51+
// Arrange
4752
Fixture.SmtpServer!.ClearReceivedEmail();
4853

4954
var model = new SendEmailTestEmailComponentModel { SomeField = "Some field." };
55+
56+
// Act
5057
await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model));
5158

59+
// Assert
5260
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(1);
5361
var email = Fixture.SmtpServer.ReceivedEmail.Single();
5462
email.ToAddresses
@@ -71,11 +79,15 @@ public async Task SendEmail_Blazor()
7179
[Fact]
7280
public async Task SendEmail_BccAdded()
7381
{
82+
// Arrange
7483
Fixture.SmtpServer!.ClearReceivedEmail();
7584

7685
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
86+
87+
// Act
7788
await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model, BccRecipient: "bcc@fusonic.net"));
7889

90+
// Assert
7991
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(1);
8092
var email = Fixture.SmtpServer.ReceivedEmail.Single();
8193
email.ToAddresses.Select(a => a.Address).Should().BeEquivalentTo(["recipient@fusonic.net"]);
@@ -100,6 +112,7 @@ public async Task SendEmail_BccAdded()
100112
[InlineData("!even-More.xml")]
101113
public async Task SendEmail_AttachmentsAdded(string attachmentName)
102114
{
115+
// Arrange
103116
var attachmentPath = Path.Combine(Path.GetDirectoryName(typeof(TestFixture).Assembly.Location)!, "email.css");
104117
Fixture.SmtpServer!.ClearReceivedEmail();
105118

@@ -112,8 +125,10 @@ public async Task SendEmail_AttachmentsAdded(string attachmentName)
112125
)
113126
};
114127

128+
// Act
115129
await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model, Attachments: attachments));
116130

131+
// Assert
117132
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(1);
118133
var email = Fixture.SmtpServer.ReceivedEmail.Single();
119134
email.MessageParts.Should().HaveCount(2);
@@ -128,18 +143,22 @@ public async Task SendEmail_AttachmentsAdded(string attachmentName)
128143
email.MessageParts[1].HeaderData.Should().Contain(attachmentName);
129144
}
130145

131-
var expectedAttachmentContent = await File.ReadAllTextAsync(attachmentPath, TestContext.Current.CancellationToken);
146+
var expectedAttachmentContent = await File.ReadAllTextAsync(attachmentPath, TestContext.Current.CancellationToken);
132147
email.MessageParts[1].BodyData.Should().Be(expectedAttachmentContent);
133148
}
134149

135150
[Fact]
136151
public async Task SendEmail_HeadersAdded()
137152
{
153+
// Arrange
138154
Fixture.SmtpServer!.ClearReceivedEmail();
139155

140156
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
157+
158+
// Act
141159
await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model, Headers: new Dictionary<string, string> { ["my-header"] = "value" }));
142160

161+
// Assert
143162
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(1);
144163
var email = Fixture.SmtpServer.ReceivedEmail.Single();
145164

@@ -150,15 +169,19 @@ public async Task SendEmail_HeadersAdded()
150169
[Fact]
151170
public async Task SendEmail_DefaultHeadersAdded()
152171
{
172+
// Arrange
153173
Fixture.SmtpServer!.ClearReceivedEmail();
154174

155175
var options = GetInstance<EmailOptions>();
156176

157177
options.DefaultHeaders = new Dictionary<string, string> { ["my-header"] = "value" };
158178

159179
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
180+
181+
// Act
160182
await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model));
161183

184+
// Assert
162185
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(1);
163186
var email = Fixture.SmtpServer.ReceivedEmail.Single();
164187

@@ -169,15 +192,19 @@ public async Task SendEmail_DefaultHeadersAdded()
169192
[Fact]
170193
public async Task SendEmail_DefaultHeadersOverriddenIfSetTwice()
171194
{
195+
// Arrange
172196
Fixture.SmtpServer!.ClearReceivedEmail();
173197

174198
var options = GetInstance<EmailOptions>();
175199

176200
options.DefaultHeaders = new Dictionary<string, string> { ["replaced"] = "value", ["default"] = "value" };
177201

178202
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
203+
204+
// Act
179205
await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model, Headers: new Dictionary<string, string> { ["replaced"] = "new-value" }));
180206

207+
// Assert
181208
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(1);
182209
var email = Fixture.SmtpServer.ReceivedEmail.Single();
183210

@@ -190,11 +217,15 @@ public async Task SendEmail_DefaultHeadersOverriddenIfSetTwice()
190217
[Fact]
191218
public async Task SendEmail_ReplyToAdded()
192219
{
220+
// Arrange
193221
Fixture.SmtpServer!.ClearReceivedEmail();
194222

195223
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
224+
225+
// Act
196226
await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model, ReplyTo: "reply@mail.com"));
197227

228+
// Assert
198229
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(1);
199230
var email = Fixture.SmtpServer.ReceivedEmail.Single();
200231

@@ -205,39 +236,69 @@ public async Task SendEmail_ReplyToAdded()
205236
[Fact]
206237
public async Task SendEmail_InvalidBccEmailAddress_ThrowsException()
207238
{
239+
// Arrange
208240
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
209241

242+
// Act
210243
var act = async () => await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model, BccRecipient: "invalidEmailAddress"));
211244

245+
// Assert
212246
await act.Should().ThrowAsync<SmtpCommandException>();
213247
}
214248

215249
[Fact]
216250
public async Task SendEmail_UnsupportedAttachmentUri_ThrowsException()
217251
{
252+
// Arrange
218253
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
219254

255+
// Act
220256
var act = async () => await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model, Attachments: [
221257
new Attachment("foo", new Uri("soso://over.there/file.txt"))
222258
]));
223259

260+
// Assert
224261
await act.Should().ThrowAsync<InvalidOperationException>();
225262
}
226263

227264
[Fact]
228265
public async Task SendEmail_SubjectFormatParameters_GetPassedToRenderer()
229266
{
267+
// Arrange
230268
Fixture.SmtpServer!.ClearReceivedEmail();
231269
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
232270

233271
var formatParams = new object[] { 1, "Test" };
272+
273+
// Act
234274
await SendAsync(new SendEmail("recipient@fusonic.net", "The Recipient", new CultureInfo("de-AT"), model, SubjectKey: "SubjectFormat", SubjectFormatParameters: formatParams));
235275

276+
// Assert
236277
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(1);
237278
var email = Fixture.SmtpServer.ReceivedEmail.Single();
238279
email.Subject.Should().Be("The formatted subject 1 Test");
239280
}
240281

282+
[Theory]
283+
[InlineData("user@test.invalid")]
284+
[InlineData("user@sub.domain.invalid")]
285+
[InlineData("user@invalid")]
286+
[InlineData("invalid")]
287+
[InlineData("user@SUB.DOMAIN.INVAlid")]
288+
[InlineData("user@DoMaIn.InValid")]
289+
public async Task SendEmail_IgnoredDomain_DoesNotSendEmail(string recipient)
290+
{
291+
// Arrange
292+
Fixture.SmtpServer!.ClearReceivedEmail();
293+
var model = new SendEmailTestEmailViewModel { SomeField = "Some field." };
294+
295+
// Act
296+
await SendAsync(new SendEmail(recipient, recipient, new CultureInfo("de-AT"), model));
297+
298+
// Assert
299+
Fixture.SmtpServer.ReceivedEmailCount.Should().Be(0);
300+
}
301+
241302
public class SendEmailFixture : TestFixture
242303
{
243304
public SimpleSmtpServer? SmtpServer { get; private set; }

0 commit comments

Comments
 (0)