< Summary

Information
Class: ArturRios.Messaging.Email.MailgunEmailService
Assembly: ArturRios.Messaging
File(s): /home/ci/agent/workspace/dotnet-messaging-docs_main/app/src/Email/MailgunEmailService.cs
Line coverage
97%
Covered lines: 73
Uncovered lines: 2
Coverable lines: 75
Total lines: 301
Line coverage: 97.3%
Branch coverage
91%
Covered branches: 33
Total branches: 36
Branch coverage: 91.6%
Method coverage
87%
Covered methods: 7
Fully covered methods: 6
Total methods: 8
Method coverage: 87.5%
Full method coverage: 75%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)50%22100%
SendEmailAsync(...)100%11100%
SendEmailAsync()100%1010100%
RecipientReference(...)50%2275%
GetSender(...)100%66100%
IsSingleMailbox(...)92.85%1414100%
GetApiVersion()100%22100%

File(s)

/home/ci/agent/workspace/dotnet-messaging-docs_main/app/src/Email/MailgunEmailService.cs

#LineLine coverage
 1using System.Net.Http.Headers;
 2using System.Net.Mail;
 3using System.Security.Cryptography;
 4using System.Text;
 5using ArturRios.Output;
 6using Microsoft.Extensions.Logging;
 7
 8namespace ArturRios.Messaging.Email;
 9
 10/// <summary>
 11/// Sends transactional e-mails through the <see href="https://www.mailgun.com/">Mailgun</see> HTTP API.
 12/// </summary>
 13/// <remarks>
 14/// Configuration is read from environment variables at call time:
 15/// <c>MAILGUN_API_KEY</c>, <c>MAILGUN_DOMAIN</c>, and the optional <c>MAILGUN_FROM</c> (the sender; defaults to
 16/// <c>postmaster@</c> the domain) and <c>MAILGUN_API_VERSION</c>.
 17/// </remarks>
 18public class MailgunEmailService : IEmailService
 19{
 20    private const string MailgunApiBaseUrl = "https://api.mailgun.net";
 21    private const string MailgunMessagesEndpoint = "messages";
 22
 23    /// <summary>
 24    /// The client used when the caller supplies none. One per process rather than one per service
 25    /// instance: a fresh <see cref="HttpClient"/> per instance holds its connections open after it goes
 26    /// out of scope, and nothing here ever disposed one.
 27    /// </summary>
 028    private static readonly Lazy<HttpClient> SharedClient = new(() => new HttpClient(), isThreadSafe: true);
 29
 30    private readonly ILogger<MailgunEmailService> _logger;
 31    private readonly HttpClient _httpClient;
 32
 33    /// <summary>
 34    /// Name of the environment variable holding the Mailgun private API key.
 35    /// </summary>
 36    public const string ApiKeyVariable = "MAILGUN_API_KEY";
 37
 38    /// <summary>
 39    /// Name of the environment variable holding the Mailgun sending domain.
 40    /// </summary>
 41    public const string DomainVariable = "MAILGUN_DOMAIN";
 42
 43    /// <summary>
 44    /// Name of the environment variable holding the Mailgun API version to target.
 45    /// </summary>
 46    public const string ApiVersionVariable = "MAILGUN_API_VERSION";
 47
 48    /// <summary>
 49    /// Name of the environment variable holding the sender (<c>from</c>) of every message: an address
 50    /// (<c>[email protected]</c>) or a display name and an address (<c>Example &lt;[email protected]&gt;</c>).
 51    /// When it is not set or is blank, messages are sent as <c>postmaster@</c> the <see cref="DomainVariable"/>
 52    /// domain, with no display name.
 53    /// </summary>
 54    public const string FromVariable = "MAILGUN_FROM";
 55
 56    /// <summary>
 57    /// API version used when <see cref="ApiVersionVariable"/> is not set or is blank.
 58    /// </summary>
 59    public const string DefaultMailgunApiVersion = "v3";
 60
 61    /// <summary>
 62    /// Initializes a new instance of the <see cref="MailgunEmailService"/> class.
 63    /// </summary>
 64    /// <param name="logger">Logger used to record request and response details.</param>
 65    /// <param name="httpClient">
 66    /// HTTP client used to reach the Mailgun API. When <see langword="null"/>, a client shared by the
 67    /// whole process is used.
 68    /// </param>
 69    /// <exception cref="ArgumentNullException"><paramref name="logger"/> is <see langword="null"/>.</exception>
 5170    public MailgunEmailService(ILogger<MailgunEmailService> logger, HttpClient? httpClient = null)
 71    {
 5172        ArgumentNullException.ThrowIfNull(logger);
 73
 5074        _logger = logger;
 5075        _httpClient = httpClient ?? SharedClient.Value;
 5076    }
 77
 78    /// <summary>
 79    /// Sends a plain text e-mail through the Mailgun API.
 80    /// </summary>
 81    /// <param name="to">Recipient e-mail address.</param>
 82    /// <param name="subject">Subject line of the e-mail.</param>
 83    /// <param name="body">Plain text body of the e-mail.</param>
 84    /// <returns>
 85    /// A <see cref="ProcessOutput"/> without errors when Mailgun accepts the message, or carrying the status code and
 86    /// response body when it does not. See <see cref="SendEmailAsync(string, string, string, CancellationToken)"/>.
 87    /// </returns>
 88    /// <exception cref="HttpRequestException">Mailgun could not be reached (DNS, connection or TLS failure).</exception
 89    /// <exception cref="TaskCanceledException">The request exceeded the <see cref="HttpClient.Timeout"/>.</exception>
 90    public Task<ProcessOutput> SendEmailAsync(string to, string subject, string body)
 4491        => SendEmailAsync(to, subject, body, CancellationToken.None);
 92
 93    /// <summary>
 94    /// Sends a plain text e-mail through the Mailgun API, observing <paramref name="cancellationToken"/>.
 95    /// </summary>
 96    /// <param name="to">Recipient e-mail address.</param>
 97    /// <param name="subject">Subject line of the e-mail.</param>
 98    /// <param name="body">Plain text body of the e-mail.</param>
 99    /// <param name="cancellationToken">Cancels the send, and the HTTP request it makes.</param>
 100    /// <returns>
 101    /// A <see cref="ProcessOutput"/> without errors when Mailgun accepts the message, or carrying the status code and
 102    /// response body when it does not. A missing API key or sending domain, or an invalid <see cref="FromVariable"/>
 103    /// sender, is reported the same way rather than sent to Mailgun.
 104    /// </returns>
 105    /// <remarks>
 106    /// <para>
 107    /// Missing configuration and messages Mailgun rejects are reported in the returned envelope; failing to reach
 108    /// Mailgun at all is thrown, as is cancellation. An already-canceled token throws before the environment is read
 109    /// or a request built.
 110    /// </para>
 111    /// <para>
 112    /// The recipient is logged as a pseudonymous reference — the first twelve hex characters of the SHA-256 of the
 113    /// trimmed, lower-cased address — never as the address itself.
 114    /// </para>
 115    /// <para>
 116    /// The credential is attached to the request rather than to <see cref="HttpClient.DefaultRequestHeaders"/>.
 117    /// The client may be shared — the documented registration is <c>AddHttpClient</c>, which hands out a
 118    /// client this service does not own — and writing a credential onto its defaults both races with
 119    /// concurrent sends and leaves the Mailgun key attached to every later request that client makes.
 120    /// </para>
 121    /// </remarks>
 122    /// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was canceled.</exception>
 123    /// <exception cref="HttpRequestException">Mailgun could not be reached (DNS, connection or TLS failure).</exception
 124    /// <exception cref="TaskCanceledException">The request exceeded the <see cref="HttpClient.Timeout"/>.</exception>
 125    public async Task<ProcessOutput> SendEmailAsync(
 126        string to,
 127        string subject,
 128        string body,
 129        CancellationToken cancellationToken)
 130    {
 47131        cancellationToken.ThrowIfCancellationRequested();
 132
 46133        var output = new ProcessOutput();
 134
 46135        var apiKey = Environment.GetEnvironmentVariable(ApiKeyVariable);
 46136        var domain = Environment.GetEnvironmentVariable(DomainVariable);
 137
 46138        if (string.IsNullOrWhiteSpace(apiKey))
 139        {
 4140            output.AddError($"Cannot send e-mail via Mailgun: the {ApiKeyVariable} environment variable is not set.");
 141        }
 142
 46143        if (string.IsNullOrWhiteSpace(domain))
 144        {
 2145            output.AddError($"Cannot send e-mail via Mailgun: the {DomainVariable} environment variable is not set.");
 146        }
 147
 46148        var from = GetSender(domain);
 149
 46150        if (from is null)
 151        {
 7152            output.AddError(
 7153                $"Cannot send e-mail via Mailgun: the {FromVariable} environment variable is not a single e-mail " +
 7154                "address, optionally with a display name (\"Example <[email protected]>\").");
 155        }
 156
 46157        if (!output.Success)
 158        {
 12159            _logger.LogError("Mailgun is not configured: {Errors}", string.Join(" ", output.Errors));
 160
 12161            return output;
 162        }
 163
 34164        var apiVersion = GetApiVersion();
 165
 34166        using var request = new HttpRequestMessage(
 34167            HttpMethod.Post,
 34168            $"{MailgunApiBaseUrl}/{apiVersion}/{domain}/{MailgunMessagesEndpoint}")
 34169        {
 34170            Headers =
 34171            {
 34172                Authorization = new AuthenticationHeaderValue(
 34173                    "Basic",
 34174                    Convert.ToBase64String(Encoding.ASCII.GetBytes($"api:{apiKey}")))
 34175            },
 34176            Content = new FormUrlEncodedContent([
 34177                new KeyValuePair<string, string>("from", from!),
 34178                new KeyValuePair<string, string>("to", to),
 34179                new KeyValuePair<string, string>("subject", subject),
 34180                new KeyValuePair<string, string>("text", body)
 34181            ])
 34182        };
 183
 34184        var recipientRef = RecipientReference(to);
 185
 34186        _logger.LogInformation("Sending e-mail to {RecipientRef} via Mailgun...", recipientRef);
 187
 34188        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 189
 32190        var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 191
 32192        if (response.IsSuccessStatusCode)
 193        {
 27194            _logger.LogInformation("Mailgun accepted the message for {RecipientRef}.", recipientRef);
 195
 27196            return output;
 197        }
 198
 5199        _logger.LogError("Mailgun rejected the message for {RecipientRef}: {StatusCode} | {ResponseContent}",
 5200            recipientRef, response.StatusCode, responseContent);
 201
 5202        output.AddError(
 5203            $"Failed to send e-mail via Mailgun. Status Code: {response.StatusCode} | Response: {responseContent}");
 204
 5205        return output;
 44206    }
 207
 208    /// <summary>
 209    /// A stable, non-reversible reference to a recipient address, so log lines can be correlated without writing
 210    /// the address — personal data — to the log.
 211    /// </summary>
 212    /// <param name="address">The recipient address.</param>
 213    /// <returns>
 214    /// The first twelve hex characters of the SHA-256 of the trimmed, lower-cased address, or <c>(none)</c> when
 215    /// there is no address.
 216    /// </returns>
 217    private static string RecipientReference(string? address)
 218    {
 34219        if (string.IsNullOrWhiteSpace(address))
 220        {
 0221            return "(none)";
 222        }
 223
 34224        var digest = SHA256.HashData(Encoding.UTF8.GetBytes(address.Trim().ToLowerInvariant()));
 225
 34226        return Convert.ToHexStringLower(digest)[..12];
 227    }
 228
 229    /// <summary>
 230    /// Reads the sender from <see cref="FromVariable"/>.
 231    /// </summary>
 232    /// <param name="domain">The sending domain, for the default sender.</param>
 233    /// <returns>
 234    /// The trimmed configured sender when it is one valid address, with or without a display name;
 235    /// <c>postmaster@<paramref name="domain"/></c> when it is unset or blank; <see langword="null"/> when it is set
 236    /// but invalid.
 237    /// </returns>
 238    private static string? GetSender(string? domain)
 239    {
 46240        var from = Environment.GetEnvironmentVariable(FromVariable)?.Trim();
 241
 46242        if (string.IsNullOrEmpty(from))
 243        {
 33244            return $"postmaster@{domain}";
 245        }
 246
 13247        return IsSingleMailbox(from) ? from : null;
 248    }
 249
 250    /// <summary>
 251    /// Whether <paramref name="value"/> is exactly one mailbox: a bare address, or a display name followed by an
 252    /// address in angle brackets. Line breaks are refused by <see cref="MailAddress"/> itself, so a header cannot be
 253    /// smuggled into the value.
 254    /// </summary>
 255    /// <remarks>
 256    /// <see cref="MailAddress"/> alone is not enough: it reads <c>[email protected], [email protected]</c> as the address
 257    /// <c>[email protected]</c> with the display name <c>[email protected],</c>. So the parsed address must account for the whole value,
 258    /// and an unquoted display name must not carry what would make it a list or another address.
 259    /// </remarks>
 260    private static bool IsSingleMailbox(string value)
 261    {
 13262        if (!MailAddress.TryCreate(value, out var mailbox))
 263        {
 4264            return false;
 265        }
 266
 9267        if (string.Equals(value, mailbox.Address, StringComparison.OrdinalIgnoreCase))
 268        {
 1269            return true;
 270        }
 271
 8272        var bracketed = $"<{mailbox.Address}>";
 273
 8274        if (!value.EndsWith(bracketed, StringComparison.OrdinalIgnoreCase))
 275        {
 1276            return false;
 277        }
 278
 7279        var displayName = value[..^bracketed.Length].TrimEnd();
 280
 7281        if (displayName.Length >= 2 && displayName[0] == '"' && displayName[^1] == '"')
 282        {
 1283            return true;
 284        }
 285
 6286        return displayName.Length > 0 && displayName.IndexOfAny([',', ';', '@', '<', '>', '"']) < 0;
 287    }
 288
 289    /// <summary>
 290    /// Reads the Mailgun API version from the environment.
 291    /// </summary>
 292    /// <returns>
 293    /// The value of <see cref="ApiVersionVariable"/>, or <see cref="DefaultMailgunApiVersion"/> when it is unset or bla
 294    /// </returns>
 295    private static string GetApiVersion()
 296    {
 34297        var apiVersion = Environment.GetEnvironmentVariable(ApiVersionVariable);
 298
 34299        return string.IsNullOrWhiteSpace(apiVersion) ? DefaultMailgunApiVersion : apiVersion;
 300    }
 301}