diff options
Diffstat (limited to 'lib/Emails.Transactional.Plugin/src/Mta')
6 files changed, 539 insertions, 0 deletions
diff --git a/lib/Emails.Transactional.Plugin/src/Mta/IEmailMessageData.cs b/lib/Emails.Transactional.Plugin/src/Mta/IEmailMessageData.cs new file mode 100644 index 0000000..8d1b261 --- /dev/null +++ b/lib/Emails.Transactional.Plugin/src/Mta/IEmailMessageData.cs @@ -0,0 +1,38 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: Emails.Transactional +* File: IEmailMessageData.cs +* +* IEmailMessageData.cs is part of Emails.Transactional which is part of the larger +* VNLib collection of libraries and utilities. +* +* Emails.Transactional is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* Emails.Transactional is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Emails.Transactional. If not, see http://www.gnu.org/licenses/. +*/ + +namespace Emails.Transactional.Mta +{ + /// <summary> + /// Represents an email's message body + /// </summary> + internal interface IEmailMessageData + { + /// <summary> + /// Gets the template data as an HTML string. + /// </summary> + /// <returns>The template HTML string</returns> + string GetHtml(); + } +} diff --git a/lib/Emails.Transactional.Plugin/src/Mta/IMailTransferAgent.cs b/lib/Emails.Transactional.Plugin/src/Mta/IMailTransferAgent.cs new file mode 100644 index 0000000..3c2e98d --- /dev/null +++ b/lib/Emails.Transactional.Plugin/src/Mta/IMailTransferAgent.cs @@ -0,0 +1,44 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: Emails.Transactional +* File: IMailTransferAgent.cs +* +* IMailTransferAgent.cs is part of Emails.Transactional which is part of the larger +* VNLib collection of libraries and utilities. +* +* Emails.Transactional is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* Emails.Transactional is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Emails.Transactional. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Threading; +using System.Threading.Tasks; + +namespace Emails.Transactional.Mta +{ + /// <summary> + /// Represents an email transfer agent or mail server. + /// </summary> + internal interface IMailTransferAgent + { + /// <summary> + /// Sends an email using the specified transaction and template. + /// </summary> + /// <param name="transaction">The email transaction containing message metadata</param> + /// <param name="message">The email message body</param> + /// <param name="cancellation">A token to cancel the operation</param> + /// <returns>A task that completes with the result of the operation</returns> + Task<MtaResult> SendEmailAsync(EmailTransaction transaction, IEmailMessageData message, CancellationToken cancellation = default); + } +} diff --git a/lib/Emails.Transactional.Plugin/src/Mta/MailTransferAgent.cs b/lib/Emails.Transactional.Plugin/src/Mta/MailTransferAgent.cs new file mode 100644 index 0000000..ed74646 --- /dev/null +++ b/lib/Emails.Transactional.Plugin/src/Mta/MailTransferAgent.cs @@ -0,0 +1,57 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: Emails.Transactional +* File: MailTransferAgent.cs +* +* MailTransferAgent.cs is part of Emails.Transactional which is part of the larger +* VNLib collection of libraries and utilities. +* +* Emails.Transactional is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* Emails.Transactional is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Emails.Transactional. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Threading; +using System.Threading.Tasks; + +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading; + +namespace Emails.Transactional.Mta +{ + [ConfigurationName("mta")] + internal sealed class MailTransferAgent: IMailTransferAgent + { + private readonly IMailTransferAgent _agent; + + public MailTransferAgent(PluginBase plugin, IConfigScope config) + { + //Recover config + string? agentType = config.GetRequiredProperty("type", e => e.GetString())?.ToLowerInvariant(); + + _agent = agentType switch + { + "smtp" => plugin.GetOrCreateSingleton<SmtpProvider>(), + "resend" => plugin.GetOrCreateSingleton<ResendMta>(), + _ => throw new NotSupportedException("The specified MTA type is not supported"), + }; + } + + public Task<MtaResult> SendEmailAsync(EmailTransaction transaction, IEmailMessageData message, CancellationToken cancellation = default) + { + return _agent.SendEmailAsync(transaction, message, cancellation); + } + } +} diff --git a/lib/Emails.Transactional.Plugin/src/Mta/MtaResult.cs b/lib/Emails.Transactional.Plugin/src/Mta/MtaResult.cs new file mode 100644 index 0000000..e7ab771 --- /dev/null +++ b/lib/Emails.Transactional.Plugin/src/Mta/MtaResult.cs @@ -0,0 +1,33 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: Emails.Transactional +* File: MtaResult.cs +* +* MtaResult.cs is part of Emails.Transactional which is part of the larger +* VNLib collection of libraries and utilities. +* +* Emails.Transactional is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* Emails.Transactional is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Emails.Transactional. If not, see http://www.gnu.org/licenses/. +*/ + +namespace Emails.Transactional.Mta +{ + /// <summary> + /// The result of an MTA request + /// </summary> + /// <param name="Success">A value that indicates if the transfer was successful</param> + /// <param name="Message">The result of the operation regardless of the status</param> + public readonly record struct MtaResult(bool Success, string? Message); +} diff --git a/lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs b/lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs new file mode 100644 index 0000000..3f20aba --- /dev/null +++ b/lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs @@ -0,0 +1,224 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: Emails.Transactional +* File: ResendMta.cs +* +* ResendMta.cs is part of Emails.Transactional which is part of the larger +* VNLib collection of libraries and utilities. +* +* Emails.Transactional is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* Emails.Transactional is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Emails.Transactional. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Net; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json.Serialization; + +using RestSharp; + +using VNLib.Net.Http; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Net.Rest.Client.Construction; +using VNLib.Net.Rest.Client; +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Events; + +using ContentType = VNLib.Net.Http.ContentType; + +namespace Emails.Transactional.Mta +{ + [ConfigurationName("resend")] + internal sealed class ResendMta : IMailTransferAgent + { + const int ResendMaxPerSecond = 10; + + /* + * Resend has a rate limit of 10 request per second, + * this timer resets the request counter every second + */ + private int _remainingRequests; + + Task ResetRateLimitAsync(ILogProvider log, CancellationToken pluginExitToken) + { + //Reset the request counter + _remainingRequests = ResendMaxPerSecond; + return Task.CompletedTask; + } + + private readonly ResendSiteAdapter _resendAdapter; + private readonly TimeSpan _rateLimitDelay; + private readonly int _rateLimitRetry; + + public ResendMta(PluginBase plugin, IConfigScope config) + { + int timeoutMs = config.GetRequiredProperty("timeout_ms", e => e.GetInt32()); + int maxClients = config.GetRequiredProperty("max_clients", e => e.GetInt32()); + string? userAgent = config.GetProperty("user_agent", e => e.GetString()); + _rateLimitDelay = config["rate_limit_delay_ms"].GetTimeSpan(TimeParseType.Milliseconds); + _rateLimitRetry = config["rate_limit_retry"].GetInt32(); + + //Create the resend adapter + _resendAdapter = new ResendSiteAdapter(timeoutMs, maxClients, userAgent); + + //Retrieve the access token + IAsyncLazy<string> authToken = plugin.GetSecretAsync("resend_token").ToLazy(p => p.Result.ToString()); + + //Define the send endpoint + //https://resend.com/docs/api-reference/emails/send-email + _resendAdapter.DefineSingleEndpoint() + .WithEndpoint<ResendEmail>() + .WithMethod(Method.Post) + .WithUrl("/emails") + .WithHeader("Accept", "application/json") + .WithHeader("Content-Type", HttpHelpers.GetContentTypeString(ContentType.Json)) + //Add authorization header on requests + .WithHeader("Authorization", e => $"Bearer {authToken.Value}") + //Add message body + .WithModifier(static (re, r) => r.AddJsonBody(re)); + + //Schedule timer to reset the rate limit + plugin.ScheduleInterval(ResetRateLimitAsync, TimeSpan.FromSeconds(1)); + } + + ///<inheritdoc/> + public async Task<MtaResult> SendEmailAsync(EmailTransaction transaction, IEmailMessageData message, CancellationToken cancellation = default) + { + int retryCount = 0; + + while (retryCount < _rateLimitRetry) + { + //Increment the request counter + int newCount = Interlocked.Increment(ref _remainingRequests); + + //if rate limit hasn't been reached, break + if (newCount >= 0) + { + break; + } + + retryCount++; + + //Wait for the rate limit delay + await Task.Delay(_rateLimitDelay, cancellation); + } + + //Create the resend email object + ResendEmail email = new(transaction) + { + Html = message.GetHtml(), + }; + + //Execute the request + RestResponse<ResendResponse> response = await _resendAdapter.ExecuteAsync<ResendEmail, ResendResponse>(email, cancellation); + + if(response.IsSuccessful) + { + //Store the raw response content data + transaction.Result = response.Content; + return new MtaResult(true, response.Data?.Id); + } + else + { + switch (response.StatusCode) + { + case HttpStatusCode.UnprocessableEntity: + return new MtaResult(false, response.Data?.StatusText); + default: + return new MtaResult(false, response.Content); + } + } + } + + private sealed record class ResendEmail(EmailTransaction Transaction) + { + [JsonPropertyName("from")] + public string From => $"{Transaction.FromName} <{Transaction.From}>"; + + [JsonPropertyName("subject")] + public string? Subject => Transaction.Subject; + + [JsonPropertyName("to")] + public string[]? To => Transaction.ToAddresses?.Select(static t => $"{t.Value} <{t.Key}>").ToArray(); + + [JsonPropertyName("bcc")] + public string[]? Bcc => Transaction.BccAddresses?.Select(static t => $"{t.Value} <{t.Key}>").ToArray(); + + [JsonPropertyName("cc")] + public string[]? Cc => Transaction.CcAddresses?.Select(static t => $"{t.Value} <{t.Key}>").ToArray(); + + [JsonPropertyName("reply_to")] + public string? ReplyTo => Transaction.ReplyTo == null ? null : $"{Transaction.ReplyToName} <{Transaction.ReplyTo}>"; + + [JsonPropertyName("html")] + public string Html { get; init; } = ""; + } + + private sealed class ResendResponse + { + [JsonPropertyName("id")] + public string Id { get; set; } = null!; + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("statusCode")] + public int StatusCpde { get; set; } + + [JsonPropertyName("message")] + public string? StatusText { get; set; } + } + + private sealed class ResendSiteAdapter : RestSiteAdapterBase + { + //Fixed resend api url + private const string BaseUrl = "https://api.resend.com"; + + ///<inheritdoc/> + protected override RestClientPool Pool { get; } + + public ResendSiteAdapter(int maxTimeoutMs, int maxClients, string? userAgent) + { + RestClientOptions options = new(BaseUrl) + { + AutomaticDecompression = DecompressionMethods.All, + Encoding = Encoding.UTF8, + FollowRedirects = false, + MaxTimeout = maxTimeoutMs, + UserAgent = userAgent, + }; + + //Create client pool + Pool = new RestClientPool(maxClients, options); + } + + ///<inheritdoc/> + public override void OnResponse(RestResponse response) + { } + + ///<inheritdoc/> + public override Task WaitAsync(CancellationToken cancellation = default) + { + //TODO implement rate limit wait + return Task.CompletedTask; + } + } + } +} diff --git a/lib/Emails.Transactional.Plugin/src/Mta/SmtpProvider.cs b/lib/Emails.Transactional.Plugin/src/Mta/SmtpProvider.cs new file mode 100644 index 0000000..78781d4 --- /dev/null +++ b/lib/Emails.Transactional.Plugin/src/Mta/SmtpProvider.cs @@ -0,0 +1,143 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: VNLib +* Package: Emails.Transactional +* File: SmtpProvider.cs +* +* SmtpProvider.cs is part of Emails.Transactional which is part of the larger +* VNLib collection of libraries and utilities. +* +* Emails.Transactional is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* Emails.Transactional is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Emails.Transactional. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +using MailKit.Net.Smtp; + +using MimeKit; +using MimeKit.Text; + +using VNLib.Utils.Extensions; +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading; + +namespace Emails.Transactional.Mta +{ + + [ConfigurationName("smtp")] + internal sealed class SmtpProvider : IMailTransferAgent + { + private readonly Uri ServerAddress; + private readonly IAsyncLazy<ICredentials> ServerCreds; + private readonly TimeSpan Timeout; + + public SmtpProvider(PluginBase plugin, IConfigScope config) + { + ServerAddress = config.GetRequiredProperty("server_address", e => new Uri(e.GetString()!)); + Timeout = config["timeout_ms"].GetTimeSpan(TimeParseType.Milliseconds); + + //Get the client id from the config + string clientId = config.GetRequiredProperty("username", e => e.GetString()!); + + //Get the password from the secret store and make it lazy loaded + ServerCreds = plugin.GetSecretAsync("smtp_password").ToLazy<ICredentials>(r => new NetworkCredential(clientId, r.Result.ToString())); + } + + + ///<inheritdoc/> + public async Task<MtaResult> SendEmailAsync(EmailTransaction transaction, IEmailMessageData template, CancellationToken cancellation = default) + { + _ = transaction ?? throw new ArgumentNullException(nameof(transaction)); + + ICredentials creds = await ServerCreds; + + //Configured a new message + using MimeMessage message = new() + { + Date = DateTime.UtcNow, + Subject = transaction.Subject + }; + + //From address is the stored from address + message.From.Add(new MailboxAddress(transaction.FromName, transaction.From)); + + if (transaction.ToAddresses == null) + { + throw new ArgumentException("The transaction must contain at least one To address"); + } + + //Add to email addresses + foreach (KeyValuePair<string, string> tos in transaction.ToAddresses) + { + message.To.Add(new MailboxAddress(tos.Value, tos.Key)); + } + + //Add ccs + if (transaction.CcAddresses != null) + { + foreach (KeyValuePair<string, string> ccs in transaction.CcAddresses) + { + message.Cc.Add(new MailboxAddress(ccs.Value, ccs.Key)); + } + } + + //Add bccs + if (transaction.BccAddresses != null) + { + foreach (KeyValuePair<string, string> bccs in transaction.BccAddresses) + { + message.Bcc.Add(new MailboxAddress(bccs.Value, bccs.Key)); + } + } + + //Use html format since we expect to be reading html templates + using TextPart body = new(TextFormat.Html) + { + IsAttachment = false + }; + + //Set the body text + body.SetText(Encoding.UTF8, template.GetHtml()); + + //Set message body + message.Body = body; + + //Open a new mail client + using SmtpClient client = new(); + + //Set timeout for senting messages + client.Timeout = (int)Timeout.TotalMilliseconds; + + //Connect to server + await client.ConnectAsync(ServerAddress, cancellation); + + //Authenticate + await client.AuthenticateAsync(creds, cancellation); + + //Send the email + transaction.Result = await client.SendAsync(message, cancellation); + + //Disconnect from the server + await client.DisconnectAsync(true, CancellationToken.None); + + return new MtaResult(true, transaction.Result); + } + } +} |