aboutsummaryrefslogtreecommitdiff
path: root/lib/Emails.Transactional.Plugin/src/Mta
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-08-28 21:54:26 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2023-08-28 21:54:26 -0400
commitb153adbd86e226ad805c2edbb90e4032d386a1b0 (patch)
tree9d3e5d7f2966c66e0264001cb38c67f74d6cf707 /lib/Emails.Transactional.Plugin/src/Mta
parent964e81b81cdb430ecee8f67a68e3c616b3f339aa (diff)
Refactor overhaul, data extensions & Resend.com support
Diffstat (limited to 'lib/Emails.Transactional.Plugin/src/Mta')
-rw-r--r--lib/Emails.Transactional.Plugin/src/Mta/IEmailMessageData.cs38
-rw-r--r--lib/Emails.Transactional.Plugin/src/Mta/IMailTransferAgent.cs44
-rw-r--r--lib/Emails.Transactional.Plugin/src/Mta/MailTransferAgent.cs57
-rw-r--r--lib/Emails.Transactional.Plugin/src/Mta/MtaResult.cs33
-rw-r--r--lib/Emails.Transactional.Plugin/src/Mta/ResendMta.cs224
-rw-r--r--lib/Emails.Transactional.Plugin/src/Mta/SmtpProvider.cs143
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);
+ }
+ }
+}