feat: consume transactional email notifications
Add contract validation, SMTP delivery results, terminal failure context, neutral development seeding, and local Docker setup. Ref: IT-1033
This commit is contained in:
+67
@@ -0,0 +1,67 @@
|
||||
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||
|
||||
public sealed class EmailTemplateRenderingServiceTests
|
||||
{
|
||||
private readonly EmailTemplateRenderingService _service = new();
|
||||
|
||||
[Fact]
|
||||
public void Render_InterpolatesItemTrackerVariables()
|
||||
{
|
||||
EmailTemplate template = CreateTemplate();
|
||||
SendEmailMessageData data = CreateData(new Dictionary<string, string>
|
||||
{
|
||||
["AppName"] = "StoreMate",
|
||||
["VerificationUrl"] = "https://example.invalid/verify"
|
||||
});
|
||||
|
||||
RenderedEmail result = _service.Render(template, data);
|
||||
|
||||
Assert.Equal("Verify StoreMate", result.Subject);
|
||||
Assert.Equal("<a href=\"https://example.invalid/verify\">Verify</a>", result.HtmlBody);
|
||||
Assert.Equal("Verify at https://example.invalid/verify", result.TextBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_WhenRequiredVariableIsMissing_ThrowsObservableFailure()
|
||||
{
|
||||
EmailTemplate template = CreateTemplate();
|
||||
SendEmailMessageData data = CreateData(new Dictionary<string, string>
|
||||
{
|
||||
["AppName"] = "StoreMate"
|
||||
});
|
||||
|
||||
InvalidDataException exception = Assert.Throws<InvalidDataException>(
|
||||
() => _service.Render(template, data));
|
||||
|
||||
Assert.Equal("Required template variables are missing: VerificationUrl.", exception.Message);
|
||||
}
|
||||
|
||||
private static EmailTemplate CreateTemplate() => new()
|
||||
{
|
||||
ServiceName = "StoreMate-Prod",
|
||||
Key = "EmailVerification",
|
||||
LanguageCode = "uk",
|
||||
Subject = "Verify {{AppName}}",
|
||||
HtmlBody = "<a href=\"{{VerificationUrl}}\">Verify</a>",
|
||||
TextBody = "Verify at {{VerificationUrl}}",
|
||||
Variables =
|
||||
[
|
||||
new EmailTemplateVariable { Name = "AppName", Required = true },
|
||||
new EmailTemplateVariable { Name = "VerificationUrl", Required = true }
|
||||
]
|
||||
};
|
||||
|
||||
private static SendEmailMessageData CreateData(IReadOnlyDictionary<string, string> variables) => new()
|
||||
{
|
||||
ServiceName = "StoreMate-Prod",
|
||||
TemplateKey = "EmailVerification",
|
||||
RecipientEmail = "owner@example.com",
|
||||
RecipientName = "Owner",
|
||||
LanguageCode = "uk",
|
||||
Variables = variables
|
||||
};
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||
using NSubstitute;
|
||||
|
||||
public sealed class EmailTemplateServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_WhenRequestedLanguageDoesNotExist_DoesNotSilentlyFallback()
|
||||
{
|
||||
IEmailTemplateRepository repository = Substitute.For<IEmailTemplateRepository>();
|
||||
var service = new EmailTemplateService(repository);
|
||||
|
||||
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.GetAsync("StoreMate-Prod", "EmailVerification", "UK", CancellationToken.None));
|
||||
|
||||
Assert.Contains("language='uk'", exception.Message);
|
||||
await repository.Received(1).GetAsync(
|
||||
"StoreMate-Prod",
|
||||
"EmailVerification",
|
||||
"uk",
|
||||
CancellationToken.None);
|
||||
await repository.DidNotReceive().GetAsync(
|
||||
"StoreMate-Prod",
|
||||
"EmailVerification",
|
||||
"en",
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||
|
||||
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||
using System.Net.Mail;
|
||||
using System.Net.Sockets;
|
||||
|
||||
public sealed class NotificationDeliveryErrorFormatterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Format_UsesInnermostMessageAndRemovesLineBreaks()
|
||||
{
|
||||
var exception = new InvalidOperationException(
|
||||
"outer",
|
||||
new Exception("provider failed\r\nretry rejected"));
|
||||
|
||||
string result = NotificationDeliveryErrorFormatter.Format(exception);
|
||||
|
||||
Assert.Equal(
|
||||
"Notification delivery failed after all retry attempts (provider failed retry rejected).",
|
||||
result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_UnresolvedSmtpHost_AddsSafeChannelContext()
|
||||
{
|
||||
var socketException = new SocketException((int)SocketError.HostNotFound);
|
||||
var exception = new SmtpException("Failure sending mail.", socketException);
|
||||
|
||||
string result = NotificationDeliveryErrorFormatter.Format(exception);
|
||||
|
||||
Assert.Equal(
|
||||
$"SMTP delivery failed after all retry attempts: the configured SMTP server host could not be resolved ({socketException.Message}).",
|
||||
result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_RefusedSmtpConnection_AddsSafeChannelContext()
|
||||
{
|
||||
var socketException = new SocketException((int)SocketError.ConnectionRefused);
|
||||
var exception = new SmtpException("Failure sending mail.", socketException);
|
||||
|
||||
string result = NotificationDeliveryErrorFormatter.Format(exception);
|
||||
|
||||
Assert.Equal(
|
||||
$"SMTP delivery failed after all retry attempts: the configured SMTP server refused the connection ({socketException.Message}).",
|
||||
result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Format_LongError_IsBoundedToOutboxColumnLength()
|
||||
{
|
||||
var exception = new Exception(new string('x', 2100));
|
||||
|
||||
string result = NotificationDeliveryErrorFormatter.Format(exception);
|
||||
|
||||
Assert.Equal(2000, result.Length);
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||
using Hrynco.RabbitMq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
public sealed class NotificationResultPublisherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishAsync_TerminalFailure_PublishesNeutralFailureResultToReplyQueue()
|
||||
{
|
||||
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
|
||||
var publisher = new NotificationResultPublisher(
|
||||
rabbitMqPublisher,
|
||||
NullLogger<NotificationResultPublisher>.Instance);
|
||||
SendEmailMessage message = CreateMessage();
|
||||
NotificationResultMessage? publishedResult = null;
|
||||
rabbitMqPublisher
|
||||
.When(x => x.PublishAsync(
|
||||
"item-tracker.notifications.result",
|
||||
Arg.Any<NotificationResultMessage>(),
|
||||
Arg.Any<CancellationToken>()))
|
||||
.Do(call => publishedResult = call.ArgAt<NotificationResultMessage>(1));
|
||||
|
||||
await publisher.PublishAsync(
|
||||
message,
|
||||
"Email provider could not deliver the notification.",
|
||||
CancellationToken.None);
|
||||
|
||||
await rabbitMqPublisher.Received(1).PublishAsync(
|
||||
"item-tracker.notifications.result",
|
||||
Arg.Any<NotificationResultMessage>(),
|
||||
CancellationToken.None);
|
||||
Assert.NotNull(publishedResult);
|
||||
Assert.Equal(message.CorrelationContext.CorrelationId, publishedResult.CorrelationContext.CorrelationId);
|
||||
Assert.Null(publishedResult.CorrelationContext.ReplyTo);
|
||||
Assert.Equal(message.Data.ServiceName, publishedResult.Data.ServiceName);
|
||||
Assert.Equal(message.Data.TemplateKey, publishedResult.Data.TemplateKey);
|
||||
Assert.Equal(message.Data.RecipientEmail, publishedResult.Data.RecipientEmail);
|
||||
Assert.Equal("Email provider could not deliver the notification.", publishedResult.Data.ErrorMessage);
|
||||
Assert.False(publishedResult.Data.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_NoReplyQueue_DoesNotPublish()
|
||||
{
|
||||
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
|
||||
var publisher = new NotificationResultPublisher(
|
||||
rabbitMqPublisher,
|
||||
NullLogger<NotificationResultPublisher>.Instance);
|
||||
SendEmailMessage message = CreateMessage();
|
||||
message.CorrelationContext = message.CorrelationContext with { ReplyTo = null };
|
||||
|
||||
await publisher.PublishAsync(message, "delivery failed", CancellationToken.None);
|
||||
|
||||
await rabbitMqPublisher.DidNotReceive()
|
||||
.PublishAsync<NotificationResultData>(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<NotificationResultMessage>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_ResultBrokerFailure_IsBestEffort()
|
||||
{
|
||||
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
|
||||
rabbitMqPublisher
|
||||
.PublishAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<NotificationResultMessage>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns<Task>(_ => throw new InvalidOperationException("reply broker unavailable"));
|
||||
var publisher = new NotificationResultPublisher(
|
||||
rabbitMqPublisher,
|
||||
NullLogger<NotificationResultPublisher>.Instance);
|
||||
|
||||
Exception? exception = await Record.ExceptionAsync(() => publisher.PublishAsync(
|
||||
CreateMessage(),
|
||||
"delivery failed",
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
private static SendEmailMessage CreateMessage()
|
||||
{
|
||||
return new SendEmailMessage
|
||||
{
|
||||
CorrelationContext = new CorrelationContext
|
||||
{
|
||||
CorrelationId = "correlation-id",
|
||||
ReplyTo = "item-tracker.notifications.result"
|
||||
},
|
||||
Data = new SendEmailMessageData
|
||||
{
|
||||
ServiceName = "TestService",
|
||||
TemplateKey = "TestEmail",
|
||||
RecipientEmail = "test.user@itemtracker.local",
|
||||
RecipientName = "Test User",
|
||||
LanguageCode = "en",
|
||||
Variables = new Dictionary<string, string>()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||
|
||||
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||
|
||||
public sealed class RecipientAddressRedactorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("owner@example.com", "o***@e***.com")]
|
||||
[InlineData("a@b.test", "a***@b***.test")]
|
||||
[InlineData("invalid", "***")]
|
||||
[InlineData(null, "<missing>")]
|
||||
public void Redact_DoesNotExposeFullAddress(string? address, string expected)
|
||||
{
|
||||
Assert.Equal(expected, RecipientAddressRedactor.Redact(address));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||
|
||||
public sealed class SendEmailContractTests
|
||||
{
|
||||
private const string ItemTrackerPayload = """
|
||||
{
|
||||
"CorrelationContext": {
|
||||
"CorrelationId": "3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc",
|
||||
"ReplyTo": "item-tracker.notifications.result"
|
||||
},
|
||||
"Data": {
|
||||
"ServiceName": "StoreMate-Prod",
|
||||
"TemplateKey": "EmailVerification",
|
||||
"RecipientEmail": "owner@example.com",
|
||||
"RecipientName": "StoreMate Owner",
|
||||
"Variables": {
|
||||
"AppName": "StoreMate",
|
||||
"VerificationUrl": "https://example.invalid/sensitive-token"
|
||||
},
|
||||
"LanguageCode": "uk"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void PascalCaseItemTrackerPayload_DeserializesWithoutLosingValues()
|
||||
{
|
||||
SendEmailMessage? message = JsonSerializer.Deserialize<SendEmailMessage>(ItemTrackerPayload);
|
||||
|
||||
Assert.NotNull(message);
|
||||
SendEmailMessageValidator.Validate(message);
|
||||
Assert.Equal("3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc", message.CorrelationContext.CorrelationId);
|
||||
Assert.Equal("item-tracker.notifications.result", message.CorrelationContext.ReplyTo);
|
||||
Assert.Equal("StoreMate-Prod", message.Data.ServiceName);
|
||||
Assert.Equal("EmailVerification", message.Data.TemplateKey);
|
||||
Assert.Equal("owner@example.com", message.Data.RecipientEmail);
|
||||
Assert.Equal("StoreMate Owner", message.Data.RecipientName);
|
||||
Assert.Equal("uk", message.Data.LanguageCode);
|
||||
Assert.Equal("StoreMate", message.Data.Variables["AppName"]);
|
||||
Assert.Equal(
|
||||
"https://example.invalid/sensitive-token",
|
||||
message.Data.Variables["VerificationUrl"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingLanguageCode_IsRejected()
|
||||
{
|
||||
SendEmailMessage? message = JsonSerializer.Deserialize<SendEmailMessage>(
|
||||
ItemTrackerPayload.Replace("\"uk\"", "null", StringComparison.Ordinal));
|
||||
|
||||
InvalidDataException exception = Assert.Throws<InvalidDataException>(
|
||||
() => SendEmailMessageValidator.Validate(message!));
|
||||
|
||||
Assert.Equal("LanguageCode is required.", exception.Message);
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.Worker;
|
||||
using Hrynco.RabbitMq;
|
||||
|
||||
public sealed class SendEmailDeliveryValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidItemTrackerDelivery_IsAccepted()
|
||||
{
|
||||
string? error = SendEmailDeliveryValidator.GetValidationError(
|
||||
CreateMessage(),
|
||||
CreateContext(),
|
||||
SendEmailConsumer.SupportedMessageType);
|
||||
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingMessageId_IsRejected()
|
||||
{
|
||||
RabbitMqMessageContext context = CreateContext() with { MessageId = null };
|
||||
|
||||
string? error = SendEmailDeliveryValidator.GetValidationError(
|
||||
CreateMessage(),
|
||||
context,
|
||||
SendEmailConsumer.SupportedMessageType);
|
||||
|
||||
Assert.Equal("AMQP MessageId is required.", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedContractVersion_IsRejected()
|
||||
{
|
||||
RabbitMqMessageContext context = CreateContext() with
|
||||
{
|
||||
MessageType = "Notification.SendEmail.v2"
|
||||
};
|
||||
|
||||
string? error = SendEmailDeliveryValidator.GetValidationError(
|
||||
CreateMessage(),
|
||||
context,
|
||||
SendEmailConsumer.SupportedMessageType);
|
||||
|
||||
Assert.Equal("Unsupported AMQP message type 'Notification.SendEmail.v2'.", error);
|
||||
}
|
||||
|
||||
private static RabbitMqMessageContext CreateContext() => new()
|
||||
{
|
||||
QueueName = SendEmailConsumer.IncomingQueue,
|
||||
Exchange = string.Empty,
|
||||
RoutingKey = SendEmailConsumer.IncomingQueue,
|
||||
MessageId = Guid.NewGuid().ToString("D"),
|
||||
MessageType = SendEmailConsumer.SupportedMessageType,
|
||||
CorrelationId = "correlation-id",
|
||||
ContentType = "application/json"
|
||||
};
|
||||
|
||||
private static SendEmailMessage CreateMessage() => new()
|
||||
{
|
||||
CorrelationContext = new CorrelationContext
|
||||
{
|
||||
CorrelationId = "correlation-id",
|
||||
ReplyTo = "item-tracker.notifications.result"
|
||||
},
|
||||
Data = new SendEmailMessageData
|
||||
{
|
||||
ServiceName = "StoreMate-Prod",
|
||||
TemplateKey = "EmailVerification",
|
||||
RecipientEmail = "owner@example.com",
|
||||
RecipientName = "Owner",
|
||||
LanguageCode = "uk",
|
||||
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||
using Hrynco.RabbitMq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
|
||||
public sealed class SendEmailServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessAsync_ValidItemTrackerMessage_SendsOnceAndUpdatesUsage()
|
||||
{
|
||||
Guid channelId = Guid.NewGuid();
|
||||
var settings = new SmtpChannelSettings
|
||||
{
|
||||
Host = "smtp.example.invalid",
|
||||
Port = 587,
|
||||
Username = "smtp-user",
|
||||
Password = "not-a-real-secret",
|
||||
UseSsl = true,
|
||||
FromEmail = "notifications@example.com",
|
||||
FromName = "StoreMate"
|
||||
};
|
||||
var channel = new EmailChannel
|
||||
{
|
||||
Id = channelId,
|
||||
ServiceName = "StoreMate-Prod",
|
||||
Priority = 1,
|
||||
EmailChannelType = EmailChannelType.Smtp,
|
||||
Settings = settings,
|
||||
IsActive = true
|
||||
};
|
||||
var template = new EmailTemplate
|
||||
{
|
||||
ServiceName = "StoreMate-Prod",
|
||||
Key = "EmailVerification",
|
||||
LanguageCode = "uk",
|
||||
Subject = "Verify {{AppName}}",
|
||||
HtmlBody = "<p>{{AppName}}</p>",
|
||||
TextBody = "{{AppName}}",
|
||||
Variables = [new EmailTemplateVariable { Name = "AppName", Required = true }]
|
||||
};
|
||||
|
||||
IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>();
|
||||
channels.GetByServiceAsync("StoreMate-Prod", Arg.Any<CancellationToken>())
|
||||
.Returns([channel]);
|
||||
IEmailChannelUsageRepository usage = Substitute.For<IEmailChannelUsageRepository>();
|
||||
IEmailTemplateService templates = Substitute.For<IEmailTemplateService>();
|
||||
templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
|
||||
.Returns(template);
|
||||
var renderer = new EmailTemplateRenderingService();
|
||||
var smtp = new RecordingSmtpEmailSender();
|
||||
INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>();
|
||||
var service = new SendEmailService(
|
||||
channels,
|
||||
usage,
|
||||
templates,
|
||||
renderer,
|
||||
smtp,
|
||||
resultPublisher,
|
||||
NullLogger<SendEmailService>.Instance);
|
||||
var message = new SendEmailMessage
|
||||
{
|
||||
CorrelationContext = new CorrelationContext
|
||||
{
|
||||
CorrelationId = Guid.NewGuid().ToString("D")
|
||||
},
|
||||
Data = new SendEmailMessageData
|
||||
{
|
||||
ServiceName = "StoreMate-Prod",
|
||||
TemplateKey = "EmailVerification",
|
||||
RecipientEmail = "owner@example.com",
|
||||
RecipientName = "Owner",
|
||||
LanguageCode = "uk",
|
||||
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
|
||||
}
|
||||
};
|
||||
|
||||
await service.ProcessAsync(message, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, smtp.SendCount);
|
||||
Assert.Same(settings, smtp.Settings);
|
||||
Assert.Equal("Verify StoreMate", smtp.Email?.Subject);
|
||||
Assert.Equal("owner@example.com", smtp.RecipientEmail);
|
||||
Assert.Equal("Owner", smtp.RecipientName);
|
||||
await usage.Received(1).IncrementUsageAsync(
|
||||
channelId,
|
||||
Arg.Any<DateOnly>(),
|
||||
CancellationToken.None);
|
||||
await resultPublisher.Received(1).PublishAsync(
|
||||
message,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class RecordingSmtpEmailSender : ISmtpEmailSender
|
||||
{
|
||||
public int SendCount { get; private set; }
|
||||
public SmtpChannelSettings? Settings { get; private set; }
|
||||
public RenderedEmail? Email { get; private set; }
|
||||
public string? RecipientEmail { get; private set; }
|
||||
public string? RecipientName { get; private set; }
|
||||
|
||||
public Task SendAsync(
|
||||
SmtpChannelSettings settings,
|
||||
RenderedEmail email,
|
||||
string recipientEmail,
|
||||
string recipientName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SendCount++;
|
||||
Settings = settings;
|
||||
Email = email;
|
||||
RecipientEmail = recipientEmail;
|
||||
RecipientName = recipientName;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user