Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add email sender feature. #70

Merged
merged 2 commits into from
Jul 6, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/SenseNet.Tools.Tests/EmailSenderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SenseNet.Extensions.DependencyInjection;
using SenseNet.Tools.Mail;

namespace SenseNet.Tools.Tests
{
[TestClass]
public class EmailSenderTests
{
// Disabled to avoid sending emails during tests
//[TestMethod]
public async Task EmailSender_Valid()
{
var es = GetEmailSender();

await es.SendAsync("sensenettest@example.com", "SN Test", "test", "test message",
CancellationToken.None);

await es.SendAsync(new EmailData
{
ToAddresses = new[]
{
new EmailAddress("email1@example.com", "Recipient 1"),
new EmailAddress("email2@example.com", "Recipient 2")
},
Subject = "test",
Body = "test message",
FromAddress = "customfrom@example.com",
FromName = "Custom From"
},
CancellationToken.None);
}

[TestMethod]
[ExpectedException(typeof(System.ArgumentNullException))]
public async Task EmailSender_EmptyEmail()
{
var es = GetEmailSender();

// empty email should result in ArgumentNullException
await es.SendAsync(null, "SN Test", "test", "test message",
CancellationToken.None);
}

private static IEmailSender GetEmailSender()
{
var config = new ConfigurationBuilder()
.AddUserSecrets<EmailSenderTests>()
.Build();

// registers the default email sender that sends real emails
var services = new ServiceCollection()
.AddLogging(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Trace);
})
.AddSenseNetEmailSender(options =>
{
config.GetSection("sensenet:Email").Bind(options);
})
.BuildServiceProvider();

return services.GetRequiredService<IEmailSender>();
}
}
}
4 changes: 4 additions & 0 deletions src/SenseNet.Tools.Tests/SenseNet.Tools.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<UserSecretsId>0c0d0f0f-1ccf-431b-b035-69256cb265cb</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
Expand Down
3 changes: 3 additions & 0 deletions src/SenseNet.Tools.Tests/SnConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public static void InitializeAssembly(TestContext context)
// workaround for .net 6+: renaming the legacy configuration from App.config to the runtime assembly name
var outputConfigFile = System.Configuration.ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None).FilePath;
File.Copy("App.config", outputConfigFile, true);

// workaround for .net 6+: register the code page encoding provider
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
#endif
}

Expand Down
90 changes: 90 additions & 0 deletions src/SenseNet.Tools/Mail/DefaultEmailSender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
using SmtpClient = MailKit.Net.Smtp.SmtpClient;

namespace SenseNet.Tools.Mail
{
internal class DefaultEmailSender : IEmailSender
{
private readonly ILogger<DefaultEmailSender> _logger;
private readonly EmailOptions _options;

public DefaultEmailSender(IOptions<EmailOptions> options, ILogger<DefaultEmailSender> logger)
{
_logger = logger;
_options = options.Value;
}

public Task SendAsync(string email, string name, string subject, string message, CancellationToken cancel)
{
if (string.IsNullOrEmpty(email))
throw new ArgumentNullException(nameof(email));

return SendAsync(new EmailData
{
ToAddresses = new[] { new EmailAddress(email, name) },
Subject = subject,
Body = message
}, cancel);
}

public async Task SendAsync(EmailData emailData, CancellationToken cancel)
{
if (emailData == null)
throw new ArgumentNullException(nameof(emailData));
if (!(emailData.ToAddresses?.Any() ?? false))
throw new ArgumentException("No recipient address is specified.", nameof(emailData));

if (string.IsNullOrEmpty(_options.Server))
throw new InvalidOperationException("No SMTP server is configured.");

_logger.LogTrace($"Sending email to {emailData.GetAddresses()}. " +
$"Subject: {emailData.Subject}, Server: {_options.Server}");

try
{
// fallback to global options if local sender is not provided
var senderName = string.IsNullOrEmpty(emailData.FromName)
? _options.SenderName
: emailData.FromName;
var fromAddress = string.IsNullOrEmpty(emailData.FromAddress)
? _options.FromAddress
: emailData.FromAddress;

var mimeMessage = new MimeMessage();
mimeMessage.From.Add(new MailboxAddress(senderName, fromAddress));
mimeMessage.To.AddRange(emailData.ToAddresses?.Select(ea => new MailboxAddress(ea.Name, ea.Address)));
mimeMessage.Subject = emailData.Subject;
mimeMessage.Body = new TextPart("html")
{
Text = emailData.Body
};

using var client = new SmtpClient
{
// accept all SSL certificates (in case the server supports STARTTLS)
ServerCertificateValidationCallback = (_, _, _, _) => true
};

await client.ConnectAsync(_options.Server, _options.Port, cancellationToken: cancel).ConfigureAwait(false);

// Note: only needed if the SMTP server requires authentication
if (!string.IsNullOrEmpty(_options.Username))
await client.AuthenticateAsync(_options.Username, _options.Password, cancel).ConfigureAwait(false);

await client.SendAsync(mimeMessage, cancel).ConfigureAwait(false);
await client.DisconnectAsync(true, cancel).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
$"Error sending email message to {emailData.GetAddresses()}. {ex.Message}");
}
}
}
}
28 changes: 28 additions & 0 deletions src/SenseNet.Tools/Mail/EmailAddress.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace SenseNet.Tools.Mail
{
/// <summary>
/// Represents an email address.
/// </summary>
public class EmailAddress
{
/// <summary>
/// Email address.
/// </summary>
public string Address { get; set; }
/// <summary>
/// Display name.
/// </summary>
public string Name { get; set; }

/// <summary>
/// Initializes a new instance of the EmailAddress class.
/// </summary>
/// <param name="address">Email address.</param>
/// <param name="name">Display name.</param>
public EmailAddress(string address, string name = null)
{
Address = address;
Name = name;
}
}
}
47 changes: 47 additions & 0 deletions src/SenseNet.Tools/Mail/EmailData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System;
using System.Linq;

namespace SenseNet.Tools.Mail
{
/// <summary>
/// Represents an email message.
/// </summary>
public class EmailData
{
/// <summary>
/// Sender address if different from the configured default.
/// </summary>
public string FromAddress { get; set; }
/// <summary>
/// Sender name if different from the configured default.
/// </summary>
public string FromName { get; set; }
/// <summary>
/// Recipient address.
/// </summary>
public EmailAddress[] ToAddresses { get; set; } = Array.Empty<EmailAddress>();
/// <summary>
/// Email subject.
/// </summary>
public string Subject { get; set; }
/// <summary>
/// Email body.
/// </summary>
public string Body { get; set; }

/// <summary>
/// Gets the first 3 recipient addresses separated by comma. Intended for logging.
/// </summary>
internal string GetAddresses()
{
if (!(ToAddresses?.Any() ?? false))
return string.Empty;

var emails = string.Join(", ", ToAddresses.Select(ea => ea.Address).Take(3));
if (ToAddresses.Length > 3)
emails += ", ...";

return emails;
}
}
}
33 changes: 33 additions & 0 deletions src/SenseNet.Tools/Mail/EmailOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace SenseNet.Tools.Mail
{
/// <summary>
/// Email options for sending emails.
/// </summary>
public class EmailOptions
{
/// <summary>
/// Mail server address.
/// </summary>
public string Server { get; set; }
/// <summary>
/// Mail server port.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Default sender address.
/// </summary>
public string FromAddress { get; set; }
/// <summary>
/// Default sender name.
/// </summary>
public string SenderName { get; set; }
/// <summary>
/// Username for authentication if required.
/// </summary>
public string Username { get; set; }
/// <summary>
/// Password for authentication if required.
/// </summary>
public string Password { get; set; }
}
}
42 changes: 42 additions & 0 deletions src/SenseNet.Tools/Mail/EmailSenderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using SenseNet.Tools.Mail;

// ReSharper disable once CheckNamespace
namespace SenseNet.Extensions.DependencyInjection
{
/// <summary>
/// Extension methods for adding email sender to the DI container.
/// </summary>
public static class EmailSenderExtensions
{
/// <summary>
/// Registers an email sender implementation in the service collection.
/// </summary>
public static IServiceCollection AddSenseNetEmailSender<T>(this IServiceCollection services,
Action<EmailOptions> configure = null)
where T : class, IEmailSender
{
if (configure != null)
services.Configure(configure);

return services.AddSingleton<IEmailSender, T>();
}

/// <summary>
/// Registers the default email sender implementation in the service collection.
/// </summary>
public static IServiceCollection AddSenseNetEmailSender(this IServiceCollection services,
Action<EmailOptions> configure = null)
{
return services.AddSenseNetEmailSender<DefaultEmailSender>(configure);
}
/// <summary>
/// Registers the null email sender implementation in the service collection.
/// </summary>
public static IServiceCollection AddNullEmailSender(this IServiceCollection services)
{
return services.AddSenseNetEmailSender<NullEmailSender>();
}
}
}
54 changes: 54 additions & 0 deletions src/SenseNet.Tools/Mail/IEmailSender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

namespace SenseNet.Tools.Mail
{
/// <summary>
/// Defines methods for sending emails.
/// </summary>
public interface IEmailSender
{
/// <summary>
/// Sends an email message.
/// </summary>
/// <param name="email">Recipient email address.</param>
/// <param name="name">Display name</param>
/// <param name="subject">Email subject</param>
/// <param name="message">Message body</param>
/// <param name="cancel">The token to monitor for cancellation requests.</param>
Task SendAsync(string email, string name, string subject, string message, CancellationToken cancel);

/// <summary>
/// Sends an email message.
/// </summary>
/// <param name="emailData">Email data.</param>
/// <param name="cancel">The token to monitor for cancellation requests.</param>
Task SendAsync(EmailData emailData, CancellationToken cancel);
}

internal class NullEmailSender : IEmailSender
{
private readonly ILogger<NullEmailSender> _logger;

public NullEmailSender(ILogger<NullEmailSender> logger)
{
_logger = logger;
}

public Task SendAsync(string email, string name, string subject, string message, CancellationToken cancel)
{
_logger.LogTrace("Email sending is disabled. To: {email} Subject: {subject}", email, subject);
return Task.FromResult(0);
}

public Task SendAsync(EmailData emailData, CancellationToken cancel)
{
_logger.LogTrace("Email sending is disabled. To: {email} Subject: {subject}",
emailData?.ToAddresses?.FirstOrDefault()?.Address, emailData?.Subject);

return Task.FromResult(0);
}
}
}
Loading