< Summary

Information
Class: ArturRios.Messaging.Email.MailgunEmailService
Assembly: ArturRios.Messaging
File(s): /home/runner/work/dotnet-messaging/dotnet-messaging/src/Email/MailgunEmailService.cs
Line coverage
97%
Covered lines: 47
Uncovered lines: 1
Coverable lines: 48
Total lines: 162
Line coverage: 97.9%
Branch coverage
91%
Covered branches: 11
Total branches: 12
Branch coverage: 91.6%
Method coverage
75%
Covered methods: 3
Fully covered methods: 3
Total methods: 4
Method coverage: 75%
Full method coverage: 75%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)50%22100%
SendEmailAsync()100%88100%
GetApiVersion()100%22100%

File(s)

/home/runner/work/dotnet-messaging/dotnet-messaging/src/Email/MailgunEmailService.cs

#LineLine coverage
 1using System.Net.Http.Headers;
 2using System.Text;
 3using ArturRios.Output;
 4using Microsoft.Extensions.Logging;
 5
 6namespace ArturRios.Messaging.Email;
 7
 8/// <summary>
 9/// Sends transactional e-mails through the <see href="https://www.mailgun.com/">Mailgun</see> HTTP API.
 10/// </summary>
 11/// <remarks>
 12/// Configuration is read from environment variables at call time:
 13/// <c>MAILGUN_API_KEY</c>, <c>MAILGUN_DOMAIN</c> and the optional <c>MAILGUN_API_VERSION</c>.
 14/// </remarks>
 15public class MailgunEmailService : IEmailService
 16{
 17    private const string MailgunApiBaseUrl = "https://api.mailgun.net";
 18    private const string MailgunMessagesEndpoint = "messages";
 19
 20    /// <summary>
 21    /// The client used when the caller supplies none. One per process rather than one per service
 22    /// instance: a fresh <see cref="HttpClient"/> per instance holds its connections open after it goes
 23    /// out of scope, and nothing here ever disposed one.
 24    /// </summary>
 025    private static readonly Lazy<HttpClient> SharedClient = new(() => new HttpClient(), isThreadSafe: true);
 26
 27    private readonly ILogger<MailgunEmailService> _logger;
 28    private readonly HttpClient _httpClient;
 29
 30    /// <summary>
 31    /// Name of the environment variable holding the Mailgun private API key.
 32    /// </summary>
 33    public const string ApiKeyVariable = "MAILGUN_API_KEY";
 34
 35    /// <summary>
 36    /// Name of the environment variable holding the Mailgun sending domain.
 37    /// </summary>
 38    public const string DomainVariable = "MAILGUN_DOMAIN";
 39
 40    /// <summary>
 41    /// Name of the environment variable holding the Mailgun API version to target.
 42    /// </summary>
 43    public const string ApiVersionVariable = "MAILGUN_API_VERSION";
 44
 45    /// <summary>
 46    /// API version used when <see cref="ApiVersionVariable"/> is not set or is blank.
 47    /// </summary>
 48    public const string DefaultMailgunApiVersion = "v3";
 49
 50    /// <summary>
 51    /// Initializes a new instance of the <see cref="MailgunEmailService"/> class.
 52    /// </summary>
 53    /// <param name="logger">Logger used to record request and response details.</param>
 54    /// <param name="httpClient">
 55    /// HTTP client used to reach the Mailgun API. When <see langword="null"/>, a client shared by the
 56    /// whole process is used.
 57    /// </param>
 58    /// <exception cref="ArgumentNullException"><paramref name="logger"/> is <see langword="null"/>.</exception>
 2559    public MailgunEmailService(ILogger<MailgunEmailService> logger, HttpClient? httpClient = null)
 60    {
 2561        ArgumentNullException.ThrowIfNull(logger);
 62
 2463        _logger = logger;
 2464        _httpClient = httpClient ?? SharedClient.Value;
 2465    }
 66
 67    /// <summary>
 68    /// Sends a plain text e-mail through the Mailgun API.
 69    /// </summary>
 70    /// <param name="to">Recipient e-mail address.</param>
 71    /// <param name="subject">Subject line of the e-mail.</param>
 72    /// <param name="body">Plain text body of the e-mail.</param>
 73    /// <returns>
 74    /// A <see cref="ProcessOutput"/> without errors when Mailgun accepts the message, or carrying the status code and
 75    /// response body when it does not. A missing API key or sending domain is reported the same way rather
 76    /// than sent to Mailgun as an unauthenticated request.
 77    /// </returns>
 78    /// <remarks>
 79    /// The credential is attached to the request rather than to <see cref="HttpClient.DefaultRequestHeaders"/>.
 80    /// The client may be shared — the documented registration is <c>AddHttpClient</c>, which hands out a
 81    /// client this service does not own — and writing a credential onto its defaults both races with
 82    /// concurrent sends and leaves the Mailgun key attached to every later request that client makes.
 83    /// </remarks>
 84    public async Task<ProcessOutput> SendEmailAsync(string to, string subject, string body)
 85    {
 2486        var output = new ProcessOutput();
 87
 2488        var apiKey = Environment.GetEnvironmentVariable(ApiKeyVariable);
 2489        var domain = Environment.GetEnvironmentVariable(DomainVariable);
 90
 2491        if (string.IsNullOrWhiteSpace(apiKey))
 92        {
 493            output.AddError($"Cannot send e-mail via Mailgun: the {ApiKeyVariable} environment variable is not set.");
 94        }
 95
 2496        if (string.IsNullOrWhiteSpace(domain))
 97        {
 298            output.AddError($"Cannot send e-mail via Mailgun: the {DomainVariable} environment variable is not set.");
 99        }
 100
 24101        if (!output.Success)
 102        {
 5103            _logger.LogError("Mailgun is not configured: {Errors}", string.Join(" ", output.Errors));
 104
 5105            return output;
 106        }
 107
 19108        var apiVersion = GetApiVersion();
 109
 19110        using var request = new HttpRequestMessage(
 19111            HttpMethod.Post,
 19112            $"{MailgunApiBaseUrl}/{apiVersion}/{domain}/{MailgunMessagesEndpoint}")
 19113        {
 19114            Headers =
 19115            {
 19116                Authorization = new AuthenticationHeaderValue(
 19117                    "Basic",
 19118                    Convert.ToBase64String(Encoding.ASCII.GetBytes($"api:{apiKey}")))
 19119            },
 19120            Content = new FormUrlEncodedContent([
 19121                new KeyValuePair<string, string>("from", $"Mailgun Sandbox <postmaster@{domain}>"),
 19122                new KeyValuePair<string, string>("to", to),
 19123                new KeyValuePair<string, string>("subject", subject),
 19124                new KeyValuePair<string, string>("text", body)
 19125            ])
 19126        };
 127
 19128        _logger.LogInformation("Sending e-mail to {Recipient} via Mailgun...", to);
 129
 19130        using var response = await _httpClient.SendAsync(request).ConfigureAwait(false);
 131
 19132        var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
 133
 19134        if (response.IsSuccessStatusCode)
 135        {
 16136            _logger.LogInformation("Mailgun accepted the message for {Recipient}.", to);
 137
 16138            return output;
 139        }
 140
 3141        _logger.LogError("Mailgun rejected the message for {Recipient}: {StatusCode} | {ResponseContent}",
 3142            to, response.StatusCode, responseContent);
 143
 3144        output.AddError(
 3145            $"Failed to send e-mail via Mailgun. Status Code: {response.StatusCode} | Response: {responseContent}");
 146
 3147        return output;
 24148    }
 149
 150    /// <summary>
 151    /// Reads the Mailgun API version from the environment.
 152    /// </summary>
 153    /// <returns>
 154    /// The value of <see cref="ApiVersionVariable"/>, or <see cref="DefaultMailgunApiVersion"/> when it is unset or bla
 155    /// </returns>
 156    private static string GetApiVersion()
 157    {
 19158        var apiVersion = Environment.GetEnvironmentVariable(ApiVersionVariable);
 159
 19160        return string.IsNullOrWhiteSpace(apiVersion) ? DefaultMailgunApiVersion : apiVersion;
 161    }
 162}