Src/CrispyWaffle.Utils/Communications/SmtpMailer.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Mail;
using System.Net.Mime;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using CrispyWaffle.Cache;
using CrispyWaffle.Configuration;
using CrispyWaffle.Extensions;
using CrispyWaffle.Log;
using CrispyWaffle.Log.Providers;
using CrispyWaffle.Telemetry;
using CrispyWaffle.Utils.Extensions;
using CrispyWaffle.Utils.GoodPractices;
namespace CrispyWaffle.Utils.Communications
{
/// <summary>
/// The SMTP mailer class.
/// </summary>
/// <seealso cref="IMailer"/>
[ConnectionName("SMTP")]
public class SmtpMailer : IMailer
{
/// <summary>
/// The <see cref="SmtpClient"/>.
/// </summary>
private readonly SmtpClient _client;
/// <summary>
/// The <see cref="MailMessage"/> to be sent by the <see cref="SmtpClient"/>.
/// </summary>
private readonly MailMessage _message;
/// <summary>
/// True if disposed.
/// </summary>
private bool _disposed;
/// <summary>
/// True if message is already defined.
/// </summary>
private bool _messageSet;
/// <summary>
/// The HTML version of the message.
/// </summary>
private string _htmlMessage;
/// <summary>
/// The options.
/// </summary>
private readonly SmtpMailerOptions _options;
/// <summary>
/// Initializes a new instance of the <see cref="SmtpMailer"/> class.
/// </summary>
/// <param name="connection">The connection.</param>
/// <param name="options">The options.</param>
/// <exception cref="ArgumentNullException">Throws when the connection is null.</exception>
/// <exception cref="ArgumentNullException">Throws when the options are null.</exception>
public SmtpMailer(IConnection connection, SmtpMailerOptions options)
{
if (connection == null)
{
throw new ArgumentNullException(nameof(connection));
}
_options = options ?? throw new ArgumentNullException(nameof(options));
_client = new()
{
Host = connection.Host,
Port = connection.Port,
Credentials = new NetworkCredential(
connection.Credentials.Username,
connection.Credentials.Password
),
Timeout = 300000,
EnableSsl = true
};
_message = new() { From = new(_options.FromAddress, _options.FromName) };
_disposed = false;
}
/// <summary>
/// Initializes a new instance of the <see cref="SmtpMailer"/> class.
/// </summary>
/// <param name="host">The host.</param>
/// <param name="port">The port.</param>
/// <param name="userName">Name of the user.</param>
/// <param name="password">The password.</param>
/// <param name="senderDisplayName">Display name of the sender.</param>
/// <param name="senderEmailAddress">The sender email address.</param>
public SmtpMailer(
string host,
int port,
string userName,
string password,
string senderDisplayName,
string senderEmailAddress
)
: this(
new Connection
{
Credentials = new Credentials { Password = password, Username = userName },
Host = host,
Port = port
},
new() { FromAddress = senderEmailAddress, FromName = senderDisplayName }
) { }
/// <summary>
/// Finalizes an instance of the <see cref="SmtpMailer"/> class.
/// </summary>
~SmtpMailer() => Dispose(false);
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting
/// unmanaged resources.
/// </summary>
/// <param name="disposing">
/// true to release both managed and unmanaged resources; false to release only unmanaged resources.
/// </param>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_message?.Dispose();
_client?.Dispose();
}
_disposed = true;
}
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting
/// unmanaged resources.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Sets the recipient.
/// </summary>
/// <param name="toName">To name.</param>
/// <param name="toEmailAddress">To email address.</param>
/// <exception cref="ArgumentNullException">
/// toEmailAddress - The receiver's e-mail address cannot be null.
/// </exception>
private void SetRecipient(string toName, string toEmailAddress)
{
if (string.IsNullOrWhiteSpace(toEmailAddress))
{
throw new ArgumentNullException(
nameof(toEmailAddress),
"The receiver's e-mail address cannot be null"
);
}
_message.To.Add(new MailAddress(toEmailAddress, toName));
}
/// <summary>
/// Sets the message body
/// </summary>
/// <param name="plainTextMessage">The plain text message.</param>
/// <param name="htmlMessage">The HTML message.</param>
/// <exception cref="CrispyWaffle.Utils.GoodPractices.MessageException"></exception>
public void SetMessageBody(string plainTextMessage, string htmlMessage)
{
if (_messageSet)
{
throw new MessageException();
}
_message.Body = plainTextMessage;
_message.IsBodyHtml = false;
_message.BodyEncoding = Encoding.UTF8;
_messageSet = true;
if (string.IsNullOrWhiteSpace(htmlMessage))
{
return;
}
_message.AlternateViews.Add(
AlternateView.CreateAlternateViewFromString(
htmlMessage,
Encoding.UTF8,
MediaTypeNames.Text.Html
)
);
_htmlMessage = htmlMessage;
}
/// <summary>
/// Adds the attachment. First sets the message.
/// </summary>
/// <param name="attachment">The attachment.</param>
/// <exception cref="NullMessageException">Throws when the message was not set before.</exception>
public void AddAttachment(Attachment attachment)
{
if (!_messageSet)
{
throw new NullMessageException();
}
if (_htmlMessage.Length > 150_000)
{
return;
}
try
{
_message.Attachments.Add(attachment);
}
catch (Exception e)
{
LogConsumer.Handle(e);
}
}
/// <summary>
/// Sets the subject.
/// </summary>
/// <param name="subject">The subject.</param>
public void SetSubject(string subject)
{
_message.Subject = subject;
_message.SubjectEncoding = Encoding.UTF8;
}
/// <summary>
/// Sets the reply to.
/// </summary>
/// <param name="name">Name of the reply.</param>
/// <param name="emailAddress">The reply email address.</param>
public void SetReplyTo(string name, string emailAddress)
{
_message.ReplyToList.Add(new MailAddress(emailAddress, name));
_message.Headers.Add(@"Return-path", $@"{name} <{emailAddress}>");
}
/// <summary>
/// Sets the read notification to.
/// </summary>
/// <param name="name">To name.</param>
/// <param name="emailAddress">To email address.</param>
public void SetReadNotificationTo(string name, string emailAddress) =>
_message.Headers.Add(@"Disposition-Notification-To", $@"{name} <{emailAddress}>");
/// <summary>
/// Sets the recipients.
/// </summary>
/// <param name="recipients">To list.</param>
public void SetRecipients(Dictionary<string, string> recipients)
{
if (recipients == null)
{
return;
}
foreach (var receiver in recipients)
{
SetRecipient(receiver.Key, receiver.Value);
}
}
/// <summary>
/// Sets the priority.
/// </summary>
/// <param name="priority">The priority.</param>
public void SetPriority(MailPriority priority) => _message.Priority = priority;
/// <summary>
/// Sends the asynchronous.
/// </summary>
/// <returns>Task.</returns>
public async Task SendAsync()
{
var cacheKey = TypeExtensions.GetCallingMethod();
string eml;
using (var sr = new StreamReader(_message.RawMessage()))
{
eml = await sr.ReadToEndAsync().ConfigureAwait(false);
}
var date = DateTime.Now.ToString(
@"yyyy-MM-dd HH.mm.ss.ffffff",
CultureInfo.InvariantCulture
);
if (_options.EnableDebug)
{
LogConsumer.DebugTo<TextFileLogProvider>(
eml,
$@"{_message.Subject} {date}.{Guid.NewGuid()}.eml"
);
}
if (_options.IsSandbox)
{
LogConsumer.Trace("E-mail sending disabled due {0}", "test environment");
return;
}
try
{
await SendInternalAsync(cacheKey).ConfigureAwait(false);
}
catch (Exception e)
{
if (!HandleExtension(e, cacheKey))
{
throw;
}
}
}
/// <summary>
/// send internal as an asynchronous operation.
/// </summary>
/// <param name="cacheKey">The cache key.</param>
/// <returns>A Task representing the asynchronous operation.</returns>
private async Task SendInternalAsync(string cacheKey)
{
if (CacheManager.TryGet(cacheKey, out bool exists) && exists)
{
LogConsumer.Trace("E-mail sending disabled due {0}", "network error");
return;
}
var receivers = _message.To.Select(d => d).ToList();
_message.CC.ToList().ForEach(receivers.Add);
LogConsumer.Trace(
"Sending email with subject {0} to the following recipients: {1}",
_message.Subject,
string.Join(@",", receivers.Select(d => d.DisplayName))
);
await _client.SendMailAsync(_message).ConfigureAwait(false);
}
/// <summary>
/// Handles the extension.
/// </summary>
/// <param name="e">The e.</param>
/// <param name="cacheKey">The cache key.</param>
/// <returns><c>true</c> if handled, <c>false</c> otherwise.</returns>
private static bool HandleExtension(Exception e, string cacheKey)
{
TelemetryAnalytics.TrackMetric("SMTPError", e.Message);
if (
e.InnerException?.InnerException is SocketException
|| e.Message.IndexOf(@"4.7.1", StringComparison.InvariantCultureIgnoreCase) != -1
|| e.Message.IndexOf(@"5.0.3", StringComparison.InvariantCultureIgnoreCase) != -1
)
{
CacheManager.Set(true, cacheKey, new TimeSpan(0, 15, 0));
return true;
}
if (
e.Message.IndexOf(@"4.4.2", StringComparison.InvariantCultureIgnoreCase) != -1
|| e.Message.IndexOf(@"4.7.0", StringComparison.InvariantCultureIgnoreCase) != -1
)
{
return false;
}
LogConsumer.Handle(e);
return true;
}
}
}