aboutsummaryrefslogtreecommitdiff
path: root/Transactional Emails/Api Endpoints/SendEndpoint.cs
diff options
context:
space:
mode:
Diffstat (limited to 'Transactional Emails/Api Endpoints/SendEndpoint.cs')
-rw-r--r--Transactional Emails/Api Endpoints/SendEndpoint.cs249
1 files changed, 249 insertions, 0 deletions
diff --git a/Transactional Emails/Api Endpoints/SendEndpoint.cs b/Transactional Emails/Api Endpoints/SendEndpoint.cs
new file mode 100644
index 0000000..c19dbcf
--- /dev/null
+++ b/Transactional Emails/Api Endpoints/SendEndpoint.cs
@@ -0,0 +1,249 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using Fluid;
+
+using Minio;
+using Minio.DataModel;
+using Minio.Exceptions;
+
+using MimeKit.Text;
+
+using VNLib.Utils.Extensions;
+using VNLib.Utils.Logging;
+using VNLib.Utils.Memory.Caching;
+using VNLib.Plugins;
+using VNLib.Plugins.Essentials;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Essentials.Oauth;
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Sql;
+
+using Emails.Transactional.Transactions;
+using static Emails.Transactional.TEmailEntryPoint;
+
+#nullable enable
+
+namespace Emails.Transactional.Endpoints
+{
+ [ConfigurationName("transaction_endpoint")]
+ internal class SendEndpoint : O2EndpointBase
+ {
+ public const string OAUTH2_USER_SCOPE_PERMISSION = "email:send";
+
+ private class FluidCache : ICacheable
+ {
+ public FluidCache(IFluidTemplate temp)
+ {
+ this.Template = temp;
+ }
+
+ public IFluidTemplate Template { get; }
+
+ DateTime ICacheable.Expires { get; set; }
+
+ bool IEquatable<ICacheable>.Equals(ICacheable? other)
+ {
+ return ReferenceEquals(Template, (other as FluidCache)?.Template);
+ }
+
+ void ICacheable.Evicted()
+ {}
+ }
+
+ private static readonly EmailTransactionValidator Validator = new();
+ private static readonly FluidParser Parser = new();
+
+ private readonly string FromAddress;
+ private readonly string? BaseObjectPath;
+ private readonly SmtpProvider EmailService;
+ private readonly TransactionStore Transactions;
+
+ private readonly Dictionary<string, FluidCache> TemplateCache;
+ private readonly TimeSpan TemplateCacheTimeout;
+ private readonly MinioClient Client;
+ private readonly S3Config S3Config;
+
+ public SendEndpoint(PluginBase plugin, IReadOnlyDictionary<string, JsonElement> config)
+ {
+ string? path = config["path"].GetString();
+ FromAddress = config["from_address"].GetString() ?? throw new KeyNotFoundException("Missing required key 'from_address' in 'transaction_endpoint'");
+ TemplateCacheTimeout = config["cache_valid_sec"].GetTimeSpan(TimeParseType.Seconds);
+ BaseObjectPath = config["base_object_path"].GetString();
+
+ InitPathAndLog(path, plugin.Log);
+
+ //Smtp setup
+ {
+ IReadOnlyDictionary<string, JsonElement> smtp = plugin.GetConfig("smtp");
+ Uri serverUri = new(smtp["server_address"].GetString()!);
+ string username = smtp["username"].GetString() ?? throw new KeyNotFoundException("Missing required key 'usename' in 'smtp' config");
+ string password = plugin.TryGetSecretAsync("smtp_password").Result ?? throw new KeyNotFoundException("Missing required 'smtp_password' in secrets");
+ TimeSpan timeout = smtp["timeout_sec"].GetTimeSpan(TimeParseType.Seconds);
+ NetworkCredential cred = new(username, password);
+ //Init email service
+ EmailService = new(serverUri, cred, timeout);
+ }
+ //S3 minio setup
+ {
+ //Init minio s3 client
+ S3Config = plugin.TryGetS3Config() ?? throw new KeyNotFoundException("Missing required 's3_config' configuration variable");
+ //Init minio from config
+ Client = new();
+ Client.WithEndpoint(S3Config.ServerAddress)
+ .WithCredentials(S3Config.ClientId, S3Config.ClientSecret);
+
+ if (S3Config.UseSsl == true)
+ {
+ Client.WithSSL();
+ }
+ //Accept optional region
+ if (!string.IsNullOrWhiteSpace(S3Config.Region))
+ {
+ Client.WithRegion(S3Config.Region);
+ }
+ //Build client
+ Client.Build();
+ //If the plugin is in debug mode, log requests to logger
+ if (plugin.IsDebug())
+ {
+ Client.SetTraceOn(new ReqLogger(Log));
+ }
+ }
+
+ //Load transactions
+ Transactions = new(plugin.GetContextOptions());
+
+ TemplateCache = new(20, StringComparer.OrdinalIgnoreCase);
+ }
+
+ protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
+ {
+ //Make sure the user has the required scope to send an email
+ if (!entity.Session.HasScope(OAUTH2_USER_SCOPE_PERMISSION))
+ {
+ entity.CloseResponseError(HttpStatusCode.Forbidden, ErrorType.InvalidScope, "Your account does not have the required permissions scope");
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Get the transaction request from the client
+ EmailTransaction? transaction = await entity.GetJsonFromFileAsync<EmailTransaction>();
+ if(transaction == null)
+ {
+ entity.CloseResponseError(HttpStatusCode.BadRequest, ErrorType.InvalidRequest, "No request body was received");
+ return VfReturnType.VirtualSkip;
+ }
+
+ TransactionResult webm = new()
+ {
+ Success = false
+ };
+ //Set a default from name/address if not set by user
+ transaction.From ??= FromAddress;
+ transaction.FromName ??= FromAddress;
+ //Specify user-id
+ transaction.UserId = entity.Session.UserID;
+ //validate the form
+ if (!Validator.Validate(transaction, webm))
+ {
+ //return errors
+ entity.CloseResponseJson(HttpStatusCode.UnprocessableEntity, webm);
+ return VfReturnType.VirtualSkip;
+ }
+
+ IFluidTemplate fTemp;
+
+ //See if the template is in cache
+ if (TemplateCache.TryGetOrEvictRecord(transaction.TemplateId!, out FluidCache? cache) == 1)
+ {
+ fTemp = cache.Template;
+ }
+ //Record was evicted or not found
+ else
+ {
+ string? templateData = null;
+ try
+ {
+ //Combine base obj path
+ string objPath = string.Concat(BaseObjectPath, transaction.TemplateId);
+ //Recover the template from the store
+ GetObjectArgs args = new();
+ args.WithBucket(S3Config.BaseBucket)
+ .WithObject(objPath)
+ .WithCallbackStream((stream) =>
+ {
+ //Read template from stream
+ using StreamReader reader = new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true);
+ templateData = reader.ReadToEnd();
+ });
+ //get the template object
+ ObjectStat status = await Client.GetObjectAsync(args, entity.EventCancellation);
+
+
+ }
+ catch(ObjectNotFoundException)
+ {
+ entity.CloseResponseError(HttpStatusCode.NotFound, ErrorType.InvalidRequest, "The requested template does not exist");
+ return VfReturnType.VirtualSkip;
+ }
+ catch(MinioException me)
+ {
+ Log.Error(me);
+ return VfReturnType.Error;
+ }
+
+ //Make sure template data was found
+ if (string.IsNullOrWhiteSpace(templateData))
+ {
+ entity.CloseResponseError(HttpStatusCode.NotFound, ErrorType.InvalidRequest, "The requested template does not exist");
+ return VfReturnType.VirtualSkip;
+ }
+
+ //Try to parse the template
+ if (!Parser.TryParse(templateData, out fTemp, out string error))
+ {
+ Log.Error(error, "Liquid template parsing error");
+ return VfReturnType.Error;
+ }
+
+ //Cache the new template
+ TemplateCache.StoreRecord(transaction.TemplateId!, new FluidCache(fTemp), TemplateCacheTimeout);
+ }
+
+ //Allways add the template name to the model
+ transaction.Variables!["template_name"] = transaction.TemplateId!;
+
+ //Create a template model
+ TemplateContext ctx = new(transaction.Variables);
+ //render the template
+ string rendered = fTemp.Render(ctx);
+ try
+ {
+ //Send the template
+ _ = await EmailService.SendAsync(transaction, rendered, TextFormat.Html, entity.EventCancellation);
+ //Set success flag
+ webm.Success = true;
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex);
+ //Write an error status message to the transaction store
+ transaction.Result = ex.Message;
+ }
+ //Write transaction to db (we need to return the transaction id)
+ _ = await Transactions.AddOrUpdateAsync(transaction);
+ //Store the results object
+ webm.SmtpStatus = transaction.Result;
+ webm.TransactionId = transaction.Id;
+ //Return the response object
+ entity.CloseResponse(webm);
+ return VfReturnType.VirtualSkip;
+ }
+ }
+}