aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore477
-rw-r--r--Emails.Transactional.Client/ClientExtensions.cs55
-rw-r--r--Emails.Transactional.Client/EmailTransactionRequest.cs138
-rw-r--r--Emails.Transactional.Client/Emails.Transactional.Client.csproj33
-rw-r--r--Emails.Transactional.Client/Emails.Transactional.Client.xml308
-rw-r--r--Emails.Transactional.Client/Exceptions/InvalidAuthorizationException.cs26
-rw-r--r--Emails.Transactional.Client/Exceptions/InvalidTransactionRequestException.cs28
-rw-r--r--Emails.Transactional.Client/Exceptions/InvalidTransactionResponseException.cs40
-rw-r--r--Emails.Transactional.Client/Exceptions/TransactionExceptionBase.cs35
-rw-r--r--Emails.Transactional.Client/Exceptions/ValidationFailedException.cs62
-rw-r--r--Emails.Transactional.Client/TransactionResult.cs27
-rw-r--r--Emails.Transactional.Client/TransactionalEmailConfig.cs62
-rw-r--r--Emails.Transactional.Client/ValidationErrorMessage.cs21
-rw-r--r--Transactional Emails/Api Endpoints/SendEndpoint.cs249
-rw-r--r--Transactional Emails/EmailDbCtx.cs16
-rw-r--r--Transactional Emails/EmailTransaction.cs138
-rw-r--r--Transactional Emails/SmtpProvider.cs94
-rw-r--r--Transactional Emails/TEmailEntryPoint.cs63
-rw-r--r--Transactional Emails/Transactional Emails.csproj62
-rw-r--r--Transactional Emails/Transactions/EmailTransactionValidator.cs109
-rw-r--r--Transactional Emails/Transactions/TransactionResult.cs20
-rw-r--r--Transactional Emails/Transactions/TransactionStore.cs50
22 files changed, 2113 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3b1d57d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,477 @@
+# Created by https://www.toptal.com/developers/gitignore/api/c,c++,visualstudio
+# Edit at https://www.toptal.com/developers/gitignore?templates=c,c++,visualstudio
+
+### C ###
+# Prerequisites
+*.d
+
+# Object files
+*.o
+*.ko
+*.obj
+*.elf
+
+# Linker output
+*.ilk
+*.map
+*.exp
+
+# Precompiled Headers
+*.gch
+*.pch
+
+# Libraries
+*.lib
+*.a
+*.la
+*.lo
+
+# Shared objects (inc. Windows DLLs)
+*.dll
+*.so
+*.so.*
+*.dylib
+
+# Executables
+*.exe
+*.out
+*.app
+*.i*86
+*.x86_64
+*.hex
+
+# Debug files
+*.dSYM/
+*.su
+*.idb
+*.pdb
+
+# Kernel Module Compile Results
+*.mod*
+*.cmd
+.tmp_versions/
+modules.order
+Module.symvers
+Mkfile.old
+dkms.conf
+
+### C++ ###
+# Prerequisites
+
+# Compiled Object files
+*.slo
+
+# Precompiled Headers
+
+# Compiled Dynamic libraries
+
+# Fortran module files
+*.mod
+*.smod
+
+# Compiled Static libraries
+*.lai
+
+# Executables
+
+### VisualStudio ###
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.meta
+*.iobj
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
+
+### VisualStudio Patch ###
+# Additional files built by Visual Studio
+
+# End of https://www.toptal.com/developers/gitignore/api/c,c++,visualstudio
+/VNLib.Utils
+
+*.json \ No newline at end of file
diff --git a/Emails.Transactional.Client/ClientExtensions.cs b/Emails.Transactional.Client/ClientExtensions.cs
new file mode 100644
index 0000000..3246b9f
--- /dev/null
+++ b/Emails.Transactional.Client/ClientExtensions.cs
@@ -0,0 +1,55 @@
+using System;
+
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Emails.Transactional.Client.Exceptions;
+
+using RestSharp;
+
+namespace Emails.Transactional.Client
+{
+ /// <summary>
+ /// Contains extension methods to send emails on remote transational email servers
+ /// </summary>
+ public static class ClientExtensions
+ {
+ private const Method DEFAULT_SEND_METHOD = Method.Post;
+
+ /// <summary>
+ /// Asynchronously begins an email transaction against the mail server with the specified
+ /// transaction request.
+ /// </summary>
+ /// <param name="client"></param>
+ /// <param name="transaction">The <see cref="EmailTransactionRequest"/> to submit</param>
+ /// <param name="token">A cancelaion token to cancel the operation</param>
+ /// <returns>A task that represents the async send operation</returns>
+ /// <exception cref="ValidationFailedException"></exception>
+ /// <exception cref="InvalidAuthorizationException"></exception>
+ /// <exception cref="InvalidTransactionRequestException"></exception>
+ /// <exception cref="InvalidTransactionResponseException"></exception>
+ public static async Task<TransactionResult> SendEmailAsync(this RestClient client, EmailTransactionRequest transaction, CancellationToken token = default)
+ {
+ //Init the new request
+ RestRequest request = new(transaction.Endpoint, DEFAULT_SEND_METHOD)
+ {
+ //Json request
+ RequestFormat = DataFormat.Json
+ };
+ //add/serialze the transacion request
+ request.AddJsonBody(transaction);
+ //Exec the tranasction on the client
+ RestResponse<TransactionResult> response = await client.ExecuteAsync<TransactionResult>(request, token);
+ //parse the response body
+ return response.StatusCode switch
+ {
+ HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => throw new InvalidAuthorizationException("The server did not accept the current authorization", response),
+ HttpStatusCode.BadRequest => throw new InvalidTransactionRequestException(response),
+ HttpStatusCode.UnprocessableEntity => throw new ValidationFailedException(response.Data),
+ HttpStatusCode.OK => response.Data,
+ _ => throw new InvalidTransactionResponseException("Unhandled status code", response),
+ };
+ }
+ }
+}
diff --git a/Emails.Transactional.Client/EmailTransactionRequest.cs b/Emails.Transactional.Client/EmailTransactionRequest.cs
new file mode 100644
index 0000000..1f1a4e2
--- /dev/null
+++ b/Emails.Transactional.Client/EmailTransactionRequest.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Emails.Transactional.Client
+{
+ /// <summary>
+ /// A transactional email request to send an email
+ /// template.
+ /// </summary>
+ public class EmailTransactionRequest
+ {
+ /// <summary>
+ /// The transactional send endpoint address
+ /// </summary>
+ [JsonIgnore]
+ public Uri Endpoint { get; init; }
+ /// <summary>
+ /// A dictionary of email addresses/names of
+ /// users to send this email to
+ /// </summary>
+ [JsonPropertyName("to")]
+ public Dictionary<string, string> ToAddresses { get; set; }
+ /// <summary>
+ /// A dictionary of email addresses/names of
+ /// users to carbon copy this email to
+ /// </summary>
+ [JsonPropertyName("cc")]
+ public Dictionary<string, string> CcAddresses { get; set; }
+ /// <summary>
+ /// A dictionary of email addresses/names of
+ /// users to blind carbon copy this email to
+ /// </summary>
+ [JsonPropertyName("bcc")]
+ public Dictionary<string, string> BccAddresses { get; set; }
+ /// <summary>
+ /// A dictionary of variables to substitute into the liquid
+ /// email template
+ /// </summary>
+ [JsonPropertyName("variables")]
+ public Dictionary<string, string> Variables { get; set; }
+
+ /// <summary>
+ /// The subject of the email to send
+ /// </summary>
+ [JsonPropertyName("subject")]
+ public string Subject { get; set; }
+
+ /// <summary>
+ /// The unique id of the email template to send
+ /// </summary>
+ [JsonPropertyName("template_id")]
+ public string TemplateId { get; set; }
+ /// <summary>
+ /// The system from email name. NOTE: This is a protected value
+ /// </summary>
+ [JsonPropertyName("from_name")]
+ public string FromName { get; set; }
+ /// <summary>
+ /// The system from email address. NOTE: This is a protected value
+ /// </summary>
+ [JsonPropertyName("from_address")]
+ public string FromAddress { get; set; }
+
+ /// <summary>
+ /// Creates a new email transaction with the specified email template to send
+ /// </summary>
+ /// <param name="templateId">The id of the template to send</param>
+ /// <exception cref="ArgumentNullException"></exception>
+ public EmailTransactionRequest(string templateId)
+ {
+ this.TemplateId = templateId ?? throw new ArgumentNullException(nameof(templateId));
+ }
+ /// <summary>
+ /// Creates a new email transaction with the specified email template
+ /// and a single recipient
+ /// </summary>
+ /// <param name="templateId">The id of the template to send</param>
+ /// <param name="toAddress">A singular recipient name</param>
+ /// <param name="toName">A singlular recipient email address</param>
+ /// <exception cref="ArgumentNullException"></exception>
+ public EmailTransactionRequest(string templateId, string toName, string toAddress)
+ {
+ this.TemplateId = templateId ?? throw new ArgumentNullException(nameof(templateId));
+ AddToAddress(toName, toAddress);
+ }
+ /// <summary>
+ /// Adds a recipient to the To email address dictionary
+ /// </summary>
+ /// <param name="toName">The name of the user to send the email to</param>
+ /// <param name="toAddress">The unique email address of the user to add to the recipient collection</param>
+ public void AddToAddress(string toName, string toAddress)
+ {
+ ToAddresses ??= new(1);
+ ToAddresses.Add(toAddress, toName);
+ }
+ /// <summary>
+ /// Adds a recipient to the To email address dictionary
+ /// </summary>
+ /// <param name="toAddress">The unique email address of the user to add to the recipient collection</param>
+ public void AddToAddress(string toAddress)
+ {
+ string name = toAddress.Split('@')[0];
+ AddToAddress(name, toAddress);
+ }
+ /// <summary>
+ /// Adds a carbon copy recipient to the current cc dictionary
+ /// </summary>
+ /// <param name="ccName">The name of the recipient</param>
+ /// <param name="ccAddress">The unique email address of the bcc recipient</param>
+ public void AddCcAddress(string ccName, string ccAddress)
+ {
+ CcAddresses ??= new(1);
+ CcAddresses.Add(ccAddress, ccName);
+ }
+ /// <summary>
+ /// Adds a blind carbon copy recipient to the current bcc dictionary
+ /// </summary>
+ /// <param name="bccName">The name of the recipient</param>
+ /// <param name="bccAddress">The unique email address of the bcc recipient</param>
+ public void AddBccAddress(string bccName, string bccAddress)
+ {
+ BccAddresses ??= new(1);
+ BccAddresses.Add(bccAddress, bccName);
+ }
+ /// <summary>
+ /// Adds a liquid template variable to be subsituted by the template
+ /// renderer.
+ /// </summary>
+ /// <param name="varName">The unique name of the variable to add to the collection</param>
+ /// <param name="varValue">The value if the variable that will be substituted into the template</param>
+ public void AddVariable(string varName, string varValue)
+ {
+ Variables ??= new(1);
+ Variables.Add(varName, varValue);
+ }
+ }
+}
diff --git a/Emails.Transactional.Client/Emails.Transactional.Client.csproj b/Emails.Transactional.Client/Emails.Transactional.Client.csproj
new file mode 100644
index 0000000..e39cb07
--- /dev/null
+++ b/Emails.Transactional.Client/Emails.Transactional.Client.csproj
@@ -0,0 +1,33 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <Authors>Vaughn Nugent</Authors>
+ <Version>1.0.0.1</Version>
+ <Description>A client library for using the Emails.Transactional server plugin</Description>
+ <PackageProjectUrl>www.vaughnnugent.com/resources</PackageProjectUrl>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+ <Platforms>AnyCPU;x64</Platforms>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+ <DocumentationFile></DocumentationFile>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
+ <DocumentationFile></DocumentationFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="RestSharp" Version="108.0.2" />
+ </ItemGroup>
+
+</Project>
diff --git a/Emails.Transactional.Client/Emails.Transactional.Client.xml b/Emails.Transactional.Client/Emails.Transactional.Client.xml
new file mode 100644
index 0000000..013b85e
--- /dev/null
+++ b/Emails.Transactional.Client/Emails.Transactional.Client.xml
@@ -0,0 +1,308 @@
+<?xml version="1.0"?>
+<doc>
+ <assembly>
+ <name>Emails.Transactional.Client</name>
+ </assembly>
+ <members>
+ <member name="T:Emails.Transactional.Client.ClientExtensions">
+ <summary>
+ Contains extension methods to send emails on remote transational email servers
+ </summary>
+ </member>
+ <member name="M:Emails.Transactional.Client.ClientExtensions.SendEmailAsync(RestSharp.RestClient,Emails.Transactional.Client.EmailTransactionRequest,System.Threading.CancellationToken)">
+ <summary>
+ Asynchronously begins an email transaction against the mail server with the specified
+ transaction request.
+ </summary>
+ <param name="client"></param>
+ <param name="transaction">The <see cref="T:Emails.Transactional.Client.EmailTransactionRequest"/> to submit</param>
+ <param name="token">A cancelaion token to cancel the operation</param>
+ <returns>A task that represents the async send operation</returns>
+ <exception cref="T:Emails.Transactional.Client.Exceptions.ValidationFailedException"></exception>
+ <exception cref="T:Emails.Transactional.Client.Exceptions.InvalidAuthorizationException"></exception>
+ <exception cref="T:Emails.Transactional.Client.Exceptions.InvalidTransactionRequestException"></exception>
+ <exception cref="T:Emails.Transactional.Client.Exceptions.InvalidTransactionResponseException"></exception>
+ </member>
+ <member name="T:Emails.Transactional.Client.EmailTransactionRequest">
+ <summary>
+ A transactional email request to send an email
+ template.
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.EmailTransactionRequest.Endpoint">
+ <summary>
+ The transactional send endpoint address
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.EmailTransactionRequest.ToAddresses">
+ <summary>
+ A dictionary of email addresses/names of
+ users to send this email to
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.EmailTransactionRequest.CcAddresses">
+ <summary>
+ A dictionary of email addresses/names of
+ users to carbon copy this email to
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.EmailTransactionRequest.BccAddresses">
+ <summary>
+ A dictionary of email addresses/names of
+ users to blind carbon copy this email to
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.EmailTransactionRequest.Variables">
+ <summary>
+ A dictionary of variables to substitute into the liquid
+ email template
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.EmailTransactionRequest.Subject">
+ <summary>
+ The subject of the email to send
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.EmailTransactionRequest.TemplateId">
+ <summary>
+ The unique id of the email template to send
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.EmailTransactionRequest.FromName">
+ <summary>
+ The system from email name. NOTE: This is a protected value
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.EmailTransactionRequest.FromAddress">
+ <summary>
+ The system from email address. NOTE: This is a protected value
+ </summary>
+ </member>
+ <member name="M:Emails.Transactional.Client.EmailTransactionRequest.#ctor(System.String)">
+ <summary>
+ Creates a new email transaction with the specified email template to send
+ </summary>
+ <param name="templateId">The id of the template to send</param>
+ <exception cref="T:System.ArgumentNullException"></exception>
+ </member>
+ <member name="M:Emails.Transactional.Client.EmailTransactionRequest.#ctor(System.String,System.String,System.String)">
+ <summary>
+ Creates a new email transaction with the specified email template
+ and a single recipient
+ </summary>
+ <param name="templateId">The id of the template to send</param>
+ <param name="toAddress">A singular recipient name</param>
+ <param name="toName">A singlular recipient email address</param>
+ <exception cref="T:System.ArgumentNullException"></exception>
+ </member>
+ <member name="M:Emails.Transactional.Client.EmailTransactionRequest.AddToAddress(System.String,System.String)">
+ <summary>
+ Adds a recipient to the To email address dictionary
+ </summary>
+ <param name="toName">The name of the user to send the email to</param>
+ <param name="toAddress">The unique email address of the user to add to the recipient collection</param>
+ </member>
+ <member name="M:Emails.Transactional.Client.EmailTransactionRequest.AddToAddress(System.String)">
+ <summary>
+ Adds a recipient to the To email address dictionary
+ </summary>
+ <param name="toAddress">The unique email address of the user to add to the recipient collection</param>
+ </member>
+ <member name="M:Emails.Transactional.Client.EmailTransactionRequest.AddCcAddress(System.String,System.String)">
+ <summary>
+ Adds a carbon copy recipient to the current cc dictionary
+ </summary>
+ <param name="ccName">The name of the recipient</param>
+ <param name="ccAddress">The unique email address of the bcc recipient</param>
+ </member>
+ <member name="M:Emails.Transactional.Client.EmailTransactionRequest.AddBccAddress(System.String,System.String)">
+ <summary>
+ Adds a blind carbon copy recipient to the current bcc dictionary
+ </summary>
+ <param name="bccName">The name of the recipient</param>
+ <param name="bccAddress">The unique email address of the bcc recipient</param>
+ </member>
+ <member name="M:Emails.Transactional.Client.EmailTransactionRequest.AddVariable(System.String,System.String)">
+ <summary>
+ Adds a liquid template variable to be subsituted by the template
+ renderer.
+ </summary>
+ <param name="varName">The unique name of the variable to add to the collection</param>
+ <param name="varValue">The value if the variable that will be substituted into the template</param>
+ </member>
+ <member name="T:Emails.Transactional.Client.Exceptions.InvalidAuthorizationException">
+ <summary>
+ A excption raised when an Authorization error occured
+ during a request
+ </summary>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidAuthorizationException.#ctor">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidAuthorizationException.#ctor(System.String)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidAuthorizationException.#ctor(System.String,System.Exception)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidAuthorizationException.#ctor(System.String,RestSharp.RestResponse{Emails.Transactional.Client.TransactionResult})">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidTransactionRequestException.#ctor">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidTransactionRequestException.#ctor(System.String)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidTransactionRequestException.#ctor(System.String,System.Exception)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidTransactionRequestException.#ctor(System.Runtime.Serialization.SerializationInfo,System.Runtime.Serialization.StreamingContext)">
+ <inheritdoc/>
+ </member>
+ <member name="T:Emails.Transactional.Client.Exceptions.InvalidTransactionResponseException">
+ <summary>
+ Raised when the results of an email transaction
+ failed. Inner exceptions may be set
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.Exceptions.InvalidTransactionResponseException.ErrorMessage">
+ <summary>
+ An error message received from the client
+ </summary>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidTransactionResponseException.#ctor">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidTransactionResponseException.#ctor(System.String)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidTransactionResponseException.#ctor(System.String,System.Exception)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.InvalidTransactionResponseException.#ctor(System.String,RestSharp.RestResponse{Emails.Transactional.Client.TransactionResult})">
+ <summary>
+ Initializes a new <see cref="T:Emails.Transactional.Client.Exceptions.InvalidTransactionResponseException"/> with
+ the response that contains the error
+ </summary>
+ <param name="message">The base exception message</param>
+ <param name="response">The response that caused the error</param>
+ </member>
+ <member name="T:Emails.Transactional.Client.Exceptions.TransactionExceptionBase">
+ <summary>
+ A base exception for all client transaction excepions
+ </summary>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.TransactionExceptionBase.#ctor">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.TransactionExceptionBase.#ctor(System.String)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.TransactionExceptionBase.#ctor(System.String,System.Exception)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.TransactionExceptionBase.#ctor(System.Runtime.Serialization.SerializationInfo,System.Runtime.Serialization.StreamingContext)">
+ <inheritdoc/>
+ </member>
+ <member name="P:Emails.Transactional.Client.Exceptions.TransactionExceptionBase.ErrorResponse">
+ <summary>
+ The response objec that caused the exception
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.Exceptions.TransactionExceptionBase.ResultMessage">
+ <summary>
+ The string represenation of the response body
+ </summary>
+ </member>
+ <member name="T:Emails.Transactional.Client.Exceptions.ValidationFailedException">
+ <summary>
+ Raised when server message validation failed
+ </summary>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.ValidationFailedException.#ctor">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.ValidationFailedException.#ctor(System.String)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.ValidationFailedException.#ctor(System.String,System.Exception)">
+ <inheritdoc/>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.ValidationFailedException.#ctor(System.Runtime.Serialization.SerializationInfo,System.Runtime.Serialization.StreamingContext)">
+ <inheritdoc/>
+ </member>
+ <member name="P:Emails.Transactional.Client.Exceptions.ValidationFailedException.ValidationErrors">
+ <summary>
+ A collection of validaion error messages
+ </summary>
+ </member>
+ <member name="M:Emails.Transactional.Client.Exceptions.ValidationFailedException.#ctor(Emails.Transactional.Client.TransactionResult)">
+ <summary>
+
+ </summary>
+ <param name="result"></param>
+ </member>
+ <member name="T:Emails.Transactional.Client.TransactionalEmailConfig">
+ <summary>
+ A global configuration object for transactional email clients
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.TransactionalEmailConfig.ServiceLocation">
+ <summary>
+ The server transaction endpoint location
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.TransactionalEmailConfig.TemplateIdLookup">
+ <summary>
+ An email id template/translation table for email template-ids
+ </summary>
+ </member>
+ <member name="M:Emails.Transactional.Client.TransactionalEmailConfig.WithUrl(System.Uri)">
+ <summary>
+ Adds the mail service location to the current instance
+ </summary>
+ <param name="serviceLocation">The address of the remote server transaction endpoint</param>
+ <returns>A referrence to the current object (fluent api)</returns>
+ <exception cref="T:System.ArgumentNullException"></exception>
+ </member>
+ <member name="M:Emails.Transactional.Client.TransactionalEmailConfig.WithTemplates(System.Collections.Generic.IReadOnlyDictionary{System.String,System.String})">
+ <summary>
+ Sets the template lookup table for the current instance
+ </summary>
+ <param name="templates">The template-id lookup table to referrence</param>
+ <returns>A referrence to the current object (fluent api)</returns>
+ <exception cref="T:System.ArgumentNullException"></exception>
+ </member>
+ <member name="M:Emails.Transactional.Client.TransactionalEmailConfig.GetTemplateRequest(System.String)">
+ <summary>
+ Gets a new <see cref="T:Emails.Transactional.Client.EmailTransactionRequest"/> from the specifed
+ template name.
+ </summary>
+ <param name="templateName"></param>
+ <returns></returns>
+ <exception cref="T:System.Collections.Generic.KeyNotFoundException"></exception>
+ <exception cref="T:System.ArgumentNullException"></exception>
+ </member>
+ <member name="T:Emails.Transactional.Client.TransactionResult">
+ <summary>
+ A JSON serializable object that contains the results of the transaction
+ </summary>
+ </member>
+ <member name="T:Emails.Transactional.Client.ValidationErrorMessage">
+ <summary>
+ A json serializable server validaion error
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.ValidationErrorMessage.PropertyName">
+ <summary>
+ The name of the propery that was invalid
+ </summary>
+ </member>
+ <member name="P:Emails.Transactional.Client.ValidationErrorMessage.ErrorMessage">
+ <summary>
+ The message that
+ </summary>
+ </member>
+ </members>
+</doc>
diff --git a/Emails.Transactional.Client/Exceptions/InvalidAuthorizationException.cs b/Emails.Transactional.Client/Exceptions/InvalidAuthorizationException.cs
new file mode 100644
index 0000000..eed2f42
--- /dev/null
+++ b/Emails.Transactional.Client/Exceptions/InvalidAuthorizationException.cs
@@ -0,0 +1,26 @@
+using System;
+
+using RestSharp;
+
+namespace Emails.Transactional.Client.Exceptions
+{
+ /// <summary>
+ /// A excption raised when an Authorization error occured
+ /// during a request
+ /// </summary>
+ public class InvalidAuthorizationException : InvalidTransactionResponseException
+ {
+ ///<inheritdoc/>
+ public InvalidAuthorizationException():base()
+ {}
+ ///<inheritdoc/>
+ public InvalidAuthorizationException(string message) : base(message)
+ {}
+ ///<inheritdoc/>
+ public InvalidAuthorizationException(string message, Exception innerException) : base(message, innerException)
+ {}
+ ///<inheritdoc/>
+ public InvalidAuthorizationException(string message, RestResponse<TransactionResult> response) : base(message, response)
+ {}
+ }
+}
diff --git a/Emails.Transactional.Client/Exceptions/InvalidTransactionRequestException.cs b/Emails.Transactional.Client/Exceptions/InvalidTransactionRequestException.cs
new file mode 100644
index 0000000..11d4bd9
--- /dev/null
+++ b/Emails.Transactional.Client/Exceptions/InvalidTransactionRequestException.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Runtime.Serialization;
+
+using RestSharp;
+
+namespace Emails.Transactional.Client.Exceptions
+{
+ public class InvalidTransactionRequestException : TransactionExceptionBase
+ {
+ ///<inheritdoc/>
+ public InvalidTransactionRequestException()
+ {}
+ ///<inheritdoc/>
+ public InvalidTransactionRequestException(string message) : base(message)
+ {}
+ ///<inheritdoc/>
+ public InvalidTransactionRequestException(string message, Exception innerException) : base(message, innerException)
+ {}
+ ///<inheritdoc/>
+ protected InvalidTransactionRequestException(SerializationInfo info, StreamingContext context) : base(info, context)
+ {}
+
+ public InvalidTransactionRequestException(RestResponse response)
+ {
+ this.ErrorResponse = response;
+ }
+ }
+}
diff --git a/Emails.Transactional.Client/Exceptions/InvalidTransactionResponseException.cs b/Emails.Transactional.Client/Exceptions/InvalidTransactionResponseException.cs
new file mode 100644
index 0000000..ed64a0c
--- /dev/null
+++ b/Emails.Transactional.Client/Exceptions/InvalidTransactionResponseException.cs
@@ -0,0 +1,40 @@
+using System;
+using RestSharp;
+
+namespace Emails.Transactional.Client.Exceptions
+{
+ /// <summary>
+ /// Raised when the results of an email transaction
+ /// failed. Inner exceptions may be set
+ /// </summary>
+ public class InvalidTransactionResponseException : TransactionExceptionBase
+ {
+ /// <summary>
+ /// An error message received from the client
+ /// </summary>
+ public TransactionResult ErrorMessage { get; init; }
+
+ ///<inheritdoc/>
+ public InvalidTransactionResponseException()
+ {}
+ ///<inheritdoc/>
+ public InvalidTransactionResponseException(string message) : base(message)
+ {}
+ ///<inheritdoc/>
+ public InvalidTransactionResponseException(string message, Exception innerException) : base(message, innerException)
+ {}
+
+ /// <summary>
+ /// Initializes a new <see cref="InvalidTransactionResponseException"/> with
+ /// the response that contains the error
+ /// </summary>
+ /// <param name="message">The base exception message</param>
+ /// <param name="response">The response that caused the error</param>
+ public InvalidTransactionResponseException(string message, RestResponse<TransactionResult> response) : base(message, response.ErrorException)
+ {
+ this.ErrorResponse = response;
+ //See if the server sent an error message
+ this.ErrorMessage = response.Data;
+ }
+ }
+}
diff --git a/Emails.Transactional.Client/Exceptions/TransactionExceptionBase.cs b/Emails.Transactional.Client/Exceptions/TransactionExceptionBase.cs
new file mode 100644
index 0000000..6cee30d
--- /dev/null
+++ b/Emails.Transactional.Client/Exceptions/TransactionExceptionBase.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Runtime.Serialization;
+
+using RestSharp;
+
+namespace Emails.Transactional.Client.Exceptions
+{
+ /// <summary>
+ /// A base exception for all client transaction excepions
+ /// </summary>
+ public class TransactionExceptionBase : Exception
+ {
+ ///<inheritdoc/>
+ public TransactionExceptionBase()
+ {}
+ ///<inheritdoc/>
+ public TransactionExceptionBase(string message) : base(message)
+ {}
+ ///<inheritdoc/>
+ public TransactionExceptionBase(string message, Exception innerException) : base(message, innerException)
+ {}
+ ///<inheritdoc/>
+ protected TransactionExceptionBase(SerializationInfo info, StreamingContext context) : base(info, context)
+ {}
+
+ /// <summary>
+ /// The response objec that caused the exception
+ /// </summary>
+ public RestResponse ErrorResponse { get; init; }
+ /// <summary>
+ /// The string represenation of the response body
+ /// </summary>
+ public string ResultMessage => ErrorResponse.Content;
+ }
+} \ No newline at end of file
diff --git a/Emails.Transactional.Client/Exceptions/ValidationFailedException.cs b/Emails.Transactional.Client/Exceptions/ValidationFailedException.cs
new file mode 100644
index 0000000..12bcc33
--- /dev/null
+++ b/Emails.Transactional.Client/Exceptions/ValidationFailedException.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Linq;
+using System.Collections.Generic;
+using System.Text;
+
+#nullable enable
+
+namespace Emails.Transactional.Client.Exceptions
+{
+ /// <summary>
+ /// Raised when server message validation failed
+ /// </summary>
+ public class ValidationFailedException : InvalidTransactionRequestException
+ {
+ ///<inheritdoc/>
+ public ValidationFailedException()
+ {}
+ ///<inheritdoc/>
+ public ValidationFailedException(string message) : base(message)
+ {}
+ ///<inheritdoc/>
+ public ValidationFailedException(string message, Exception innerException) : base(message, innerException)
+ {}
+
+ /// <summary>
+ /// A collection of validaion error messages
+ /// </summary>
+ public ICollection<ValidationErrorMessage>? ValidationErrors { get; init; }
+ /// <summary>
+ ///
+ /// </summary>
+ /// <param name="result"></param>
+ public ValidationFailedException(TransactionResult result):base("Transaction data server validation failed")
+ {
+ this.ValidationErrors = result?.ValidationErrors;
+ }
+
+ ///<inheritdoc/>
+ public override string Message
+ {
+ get
+ {
+ if(ValidationErrors == null)
+ {
+ return base.Message;
+
+ }
+ StringBuilder sb = new(base.Message);
+ sb.AppendLine();
+
+ foreach(var kvp in ValidationErrors)
+ {
+ sb.Append("Validation error: ");
+ sb.Append(kvp.PropertyName);
+ sb.Append(' ');
+ sb.AppendLine(kvp.ErrorMessage);
+ }
+ return sb.ToString();
+ }
+ }
+ }
+}
diff --git a/Emails.Transactional.Client/TransactionResult.cs b/Emails.Transactional.Client/TransactionResult.cs
new file mode 100644
index 0000000..5c97b3d
--- /dev/null
+++ b/Emails.Transactional.Client/TransactionResult.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Emails.Transactional.Client
+{
+ /// <summary>
+ /// A JSON serializable object that contains the results of the transaction
+ /// </summary>
+ public class TransactionResult
+ {
+ [JsonPropertyName("transaction_id")]
+ public string TransactionId { get; set; }
+ [JsonPropertyName("smtp_status")]
+ public string SmtpStatus { get; set; }
+ [JsonPropertyName("success")]
+ public bool Success { get; set; }
+
+ [JsonPropertyName("error_code")]
+ public string ErrorCode { get; set; }
+
+ [JsonPropertyName("error_description")]
+ public string ErrorDescription { get; set; }
+
+ [JsonPropertyName("errors")]
+ public ICollection<ValidationErrorMessage> ValidationErrors { get; set; }
+ }
+}
diff --git a/Emails.Transactional.Client/TransactionalEmailConfig.cs b/Emails.Transactional.Client/TransactionalEmailConfig.cs
new file mode 100644
index 0000000..9efeba4
--- /dev/null
+++ b/Emails.Transactional.Client/TransactionalEmailConfig.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+
+namespace Emails.Transactional.Client
+{
+ /// <summary>
+ /// A global configuration object for transactional email clients
+ /// </summary>
+ public class TransactionalEmailConfig
+ {
+ /// <summary>
+ /// The server transaction endpoint location
+ /// </summary>
+ public Uri ServiceLocation { get; private set; }
+
+ /// <summary>
+ /// An email id template/translation table for email template-ids
+ /// </summary>
+ public IReadOnlyDictionary<string, string> TemplateIdLookup { get; private set; }
+
+ /// <summary>
+ /// Adds the mail service location to the current instance
+ /// </summary>
+ /// <param name="serviceLocation">The address of the remote server transaction endpoint</param>
+ /// <returns>A referrence to the current object (fluent api)</returns>
+ /// <exception cref="ArgumentNullException"></exception>
+ public TransactionalEmailConfig WithUrl(Uri serviceLocation)
+ {
+ ServiceLocation = serviceLocation ?? throw new ArgumentNullException(nameof(serviceLocation));
+ return this;
+ }
+ /// <summary>
+ /// Sets the template lookup table for the current instance
+ /// </summary>
+ /// <param name="templates">The template-id lookup table to referrence</param>
+ /// <returns>A referrence to the current object (fluent api)</returns>
+ /// <exception cref="ArgumentNullException"></exception>
+ public TransactionalEmailConfig WithTemplates(IReadOnlyDictionary<string, string> templates)
+ {
+ TemplateIdLookup = templates ?? throw new ArgumentNullException(nameof(templates));
+ return this;
+ }
+
+ /// <summary>
+ /// Gets a new <see cref="EmailTransactionRequest"/> from the specifed
+ /// template name.
+ /// </summary>
+ /// <param name="templateName"></param>
+ /// <returns></returns>
+ /// <exception cref="KeyNotFoundException"></exception>
+ /// <exception cref="ArgumentNullException"></exception>
+ public EmailTransactionRequest GetTemplateRequest(string templateName)
+ {
+ //get the template from its template name
+ string templateId = TemplateIdLookup[templateName];
+ return new EmailTransactionRequest(templateId)
+ {
+ Endpoint = this.ServiceLocation
+ };
+ }
+ }
+}
diff --git a/Emails.Transactional.Client/ValidationErrorMessage.cs b/Emails.Transactional.Client/ValidationErrorMessage.cs
new file mode 100644
index 0000000..5e7d728
--- /dev/null
+++ b/Emails.Transactional.Client/ValidationErrorMessage.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace Emails.Transactional.Client
+{
+ /// <summary>
+ /// A json serializable server validaion error
+ /// </summary>
+ public class ValidationErrorMessage
+ {
+ /// <summary>
+ /// The name of the propery that was invalid
+ /// </summary>
+ [JsonPropertyName("property")]
+ public string PropertyName { get; set; }
+ /// <summary>
+ /// The message that
+ /// </summary>
+ [JsonPropertyName("message")]
+ public string ErrorMessage { get; set; }
+ }
+}
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;
+ }
+ }
+}
diff --git a/Transactional Emails/EmailDbCtx.cs b/Transactional Emails/EmailDbCtx.cs
new file mode 100644
index 0000000..000822f
--- /dev/null
+++ b/Transactional Emails/EmailDbCtx.cs
@@ -0,0 +1,16 @@
+using System;
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Plugins.Extensions.Data;
+
+namespace Emails.Transactional
+{
+ internal class EmailDbCtx : TransactionalDbContext
+ {
+ public DbSet<EmailTransaction> EmailTransactions { get; set; }
+
+ public EmailDbCtx(DbContextOptions options) : base(options)
+ {}
+ }
+}
diff --git a/Transactional Emails/EmailTransaction.cs b/Transactional Emails/EmailTransaction.cs
new file mode 100644
index 0000000..7a21089
--- /dev/null
+++ b/Transactional Emails/EmailTransaction.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+using VNLib.Plugins.Essentials;
+using VNLib.Plugins.Extensions.Data;
+using VNLib.Plugins.Extensions.Data.Abstractions;
+
+#nullable enable
+
+namespace Emails.Transactional
+{
+ /// <summary>
+ /// Represents an email transaction request and its status reflected
+ /// in the database
+ /// </summary>
+ internal class EmailTransaction : DbModelBase, IUserEntity
+ {
+ ///<inheritdoc/>
+ [Key]
+ [JsonPropertyName("id")]
+ public override string Id { get; set; }
+ ///<inheritdoc/>
+ [JsonIgnore]
+ public override DateTime Created { get; set; }
+ ///<inheritdoc/>
+ [JsonIgnore]
+ public override DateTime LastModified { get; set; }
+
+ /// <summary>
+ /// The sever side user-id that send the email
+ /// </summary>
+ [JsonIgnore]
+ public string? UserId { get; set; }
+
+ //To address
+ [JsonIgnore]
+ public string? To
+ {
+ //Use json to serialize/deserialze the to addresses
+ get => JsonSerializer.Serialize(ToAddresses, Statics.SR_OPTIONS);
+ set => ToAddresses = JsonSerializer.Deserialize<Dictionary<string, string>>(value!, Statics.SR_OPTIONS);
+ }
+
+ /// <summary>
+ /// A dictionary of to email address and name pairs
+ /// </summary>
+ [NotMapped]
+ [JsonPropertyName("to")]
+ public Dictionary<string, string>? ToAddresses { get; set; }
+
+ //From
+ /// <summary>
+ /// The from email address
+ /// </summary>
+ [JsonPropertyName("from")]
+ public string? From { get; set; }
+
+ /// <summary>
+ /// The optional from name (not mapped in db)
+ /// </summary>
+ [NotMapped]
+ [JsonPropertyName("from_name")]
+ public string? FromName { get; set; }
+ /// <summary>
+ /// The email subject
+ /// </summary>
+ [JsonPropertyName("subject")]
+ public string? Subject { get; set; }
+
+ //CC names
+ //ccs are stored in the db as a json serialized string
+ [JsonIgnore]
+ public string Ccs
+ {
+ //Use json to serialize/deserialze the to addresses
+ get => JsonSerializer.Serialize(CcAddresses, Statics.SR_OPTIONS);
+ set => CcAddresses = JsonSerializer.Deserialize<Dictionary<string, string>>(value, Statics.SR_OPTIONS);
+ }
+
+ [JsonPropertyName("cc")]
+ [NotMapped]
+ public Dictionary<string, string>? CcAddresses { get; set; }
+
+ //BCC Names
+
+ //bccs are stored in the db as a json serialized string
+ [JsonIgnore]
+ public string Bccs
+ {
+ //Store bccs as a comma separated list of addresses
+ get => JsonSerializer.Serialize(BccAddresses, Statics.SR_OPTIONS);
+ set => BccAddresses = JsonSerializer.Deserialize<Dictionary<string, string>>(value, Statics.SR_OPTIONS);
+ }
+
+ /// <summary>
+ /// A dictionary of bcc addresses and names
+ /// </summary>
+ [JsonPropertyName("bcc")]
+ [NotMapped]
+ public Dictionary<string, string>? BccAddresses { get; set; }
+
+ /// <summary>
+ /// The replyto email address
+ /// </summary>
+ [JsonPropertyName("reply_to")]
+ public string? ReplyTo { get; set; }
+ /// <summary>
+ /// Optional reply-to name for the address (not stored in db)
+ /// </summary>
+ [NotMapped]
+ [JsonPropertyName("reply_to_name")]
+ public string? ReplyToName { get; set; }
+
+ /// <summary>
+ /// The object id of the template to send
+ /// </summary>
+ [JsonPropertyName("template_id")]
+ public string? TemplateId { get; set; }
+
+ /// <summary>
+ /// The result of the STMP transaction
+ /// </summary>
+ [JsonIgnore]
+ public string? Result { get; set; }
+
+ /// <summary>
+ /// Variables requested from the client to embed in the template
+ /// during processing. These are not stored in the database
+ /// </summary>
+ [NotMapped]
+ [JsonPropertyName("variables")]
+ public Dictionary<string, string>? Variables { get; set; }
+ }
+}
diff --git a/Transactional Emails/SmtpProvider.cs b/Transactional Emails/SmtpProvider.cs
new file mode 100644
index 0000000..1912109
--- /dev/null
+++ b/Transactional Emails/SmtpProvider.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using MailKit.Net.Smtp;
+
+using MimeKit;
+using MimeKit.Text;
+
+namespace Emails.Transactional
+{
+ internal class SmtpProvider
+ {
+ private readonly Uri ServerAddress;
+ private readonly ICredentials ServerCreds;
+ private readonly TimeSpan Timeout;
+
+ public SmtpProvider(Uri ServerAddress, ICredentials ServerCreds, TimeSpan Timeout)
+ {
+ this.ServerCreds = ServerCreds;
+ this.ServerAddress = ServerAddress;
+ this.Timeout = Timeout;
+ }
+
+ /// <summary>
+ /// Opens a connection to the configured SMTP server and sends the specified email
+ /// request transaction on the server. When the operation completes, the transaction's
+ /// result property is populated with the result of the operation.
+ /// </summary>
+ /// <param name="transaction">The transaction data</param>
+ /// <param name="messageBody">The content of the message to send</param>
+ /// <param name="dataFromat">The format of the body content</param>
+ /// <returns>A task that resolves the status of the operation</returns>
+ public async Task<string> SendAsync(EmailTransaction transaction, string messageBody, TextFormat dataFromat, CancellationToken cancellation)
+ {
+ //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));
+
+ //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(dataFromat)
+ {
+ IsAttachment = false,
+ Text = messageBody
+ };
+ //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);
+ //Aithenticate
+ await client.AuthenticateAsync(ServerCreds, cancellation);
+ //Send the email
+ string result = await client.SendAsync(message, cancellation);
+ //Disconnect from the server
+ await client.DisconnectAsync(true, CancellationToken.None);
+ //Update the transaction
+ transaction.Result = result;
+ return result;
+ }
+ }
+}
diff --git a/Transactional Emails/TEmailEntryPoint.cs b/Transactional Emails/TEmailEntryPoint.cs
new file mode 100644
index 0000000..97b9c86
--- /dev/null
+++ b/Transactional Emails/TEmailEntryPoint.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+
+using Emails.Transactional.Endpoints;
+
+using Minio;
+using Minio.DataModel.Tracing;
+
+using VNLib.Utils.Logging;
+using VNLib.Plugins;
+using VNLib.Plugins.Extensions.Loading.Routing;
+
+namespace Emails.Transactional
+{
+ public class TEmailEntryPoint : PluginBase
+ {
+ public override string PluginName => "Emails.Transactional";
+
+ internal class ReqLogger : IRequestLogger
+ {
+ private readonly ILogProvider logProvider;
+
+ public ReqLogger(ILogProvider log)
+ {
+ logProvider = log;
+ }
+
+ public void LogRequest(RequestToLog requestToLog, ResponseToLog responseToLog, double durationMs)
+ {
+ logProvider.Debug("S3 result\n{method} {uri} HTTP {ms}ms\nHTTP {status} {message}\n{content}",
+ requestToLog.method, requestToLog.resource, durationMs,
+ responseToLog.statusCode, responseToLog.errorMessage, responseToLog.content
+ );
+ }
+ }
+
+ protected override void OnLoad()
+ {
+ try
+ {
+ //Route send oauth endpoint
+ this.Route<SendEndpoint>();
+
+ Log.Information("Plugin loaded");
+ }
+ catch (KeyNotFoundException kne)
+ {
+ Log.Warn("Missing required configuration keys {err}", kne.Message);
+ }
+ }
+
+
+ protected override void OnUnLoad()
+ {
+ Log.Information("Plugin unloaded");
+ }
+
+ protected override void ProcessHostCommand(string cmd)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/Transactional Emails/Transactional Emails.csproj b/Transactional Emails/Transactional Emails.csproj
new file mode 100644
index 0000000..4f8914d
--- /dev/null
+++ b/Transactional Emails/Transactional Emails.csproj
@@ -0,0 +1,62 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <RootNamespace>Emails.Transactional</RootNamespace>
+ <AssemblyName>TransactionalEmails</AssemblyName>
+ <Platforms>AnyCPU;x64</Platforms>
+ <Nullable></Nullable>
+ <Authors>Vaughn Nugent</Authors>
+ <Copyright>Copyright © 2022 Vaughn Nugent</Copyright>
+ <Version>1.0.0.1</Version>
+ <PackageProjectUrl>https://www.vaughnnugent.com/resources</PackageProjectUrl>
+ </PropertyGroup>
+
+ <!-- Resolve nuget dll files and store them in the output dir -->
+ <PropertyGroup>
+ <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="FluentValidation" Version="11.3.0" />
+ <PackageReference Include="Fluid.Core" Version="2.2.16" />
+ <PackageReference Include="MailKit" Version="3.4.2" />
+ <PackageReference Include="Minio" Version="4.0.6" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Update="TransactionalEmails.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+ <ItemGroup>
+ <Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="Endpoints\" />
+ <Folder Include="Meta\" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\Extensions\VNLib.Plugins.Extensions.Data\VNLib.Plugins.Extensions.Data.csproj" />
+ <ProjectReference Include="..\..\Extensions\VNLib.Plugins.Extensions.Loading.Sql\VNLib.Plugins.Extensions.Loading.Sql.csproj" />
+ <ProjectReference Include="..\..\Extensions\VNLib.Plugins.Extensions.Loading\VNLib.Plugins.Extensions.Loading.csproj" />
+ <ProjectReference Include="..\..\Extensions\VNLib.Plugins.Extensions.Validation\VNLib.Plugins.Extensions.Validation.csproj" />
+ <ProjectReference Include="..\..\PluginBase\VNLib.Plugins.PluginBase.csproj" />
+ </ItemGroup>
+
+ <Target Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;F:\Programming\Web Plugins\DevPlugins\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>
diff --git a/Transactional Emails/Transactions/EmailTransactionValidator.cs b/Transactional Emails/Transactions/EmailTransactionValidator.cs
new file mode 100644
index 0000000..09571bf
--- /dev/null
+++ b/Transactional Emails/Transactions/EmailTransactionValidator.cs
@@ -0,0 +1,109 @@
+using FluentValidation;
+
+using VNLib.Plugins.Extensions.Validation;
+
+
+namespace Emails.Transactional.Transactions
+{
+ internal class EmailTransactionValidator : AbstractValidator<EmailTransaction>
+ {
+ public const int MAX_SUBJECT_LEN = 50;
+ public EmailTransactionValidator()
+ {
+ //Catch to make sure user-id is set
+ RuleFor(static t => t.UserId)
+ .NotEmpty();
+ //validate from addres/name
+ RuleFor(static t => t.FromName)
+ .NotEmpty()
+ .WithName("from_name")
+ .AlphaNumericOnly()
+ .WithName("from_name");
+ RuleFor(static t => t.From)
+ .NotEmpty()
+ .EmailAddress()
+ .WithName("from");
+
+ //Rule names must be their json propery name, so clients can properly log errors
+ //must include a template id
+ RuleFor(static t => t.TemplateId)
+ .NotEmpty()
+ .MaximumLength(200)
+ .WithName("template_id");
+
+ //From address must not be empty
+ RuleFor(static t => t.From)
+ .NotEmpty()
+ .EmailAddress();
+ //Subject is required alpha num
+ RuleFor(static t => t.Subject)
+ .NotEmpty()
+ .AlphaNumericOnly()
+ .MaximumLength(MAX_SUBJECT_LEN)
+ .WithName("subject");
+
+ //To address must not be empty, and must be valid email addresses
+ RuleFor(static t => t.ToAddresses)
+ .NotEmpty()
+ .ChildRules(static to => {
+ //Check keys (address)
+ to.RuleForEach(static b => b.Keys)
+ .NotEmpty()
+ .EmailAddress()
+ .WithName("to");
+
+ //Check values (names)
+ to.RuleForEach(static b => b.Values)
+ .NotEmpty()
+ .AlphaNumericOnly()
+ .WithName("to_name");
+ })
+ .WithName("to");
+ //Check bcc addresses, allowed to be empty
+ RuleFor(static t => t.BccAddresses)
+ .ChildRules(static bcc => {
+ //Check keys (address)
+ bcc.RuleForEach(static b => b.Keys)
+ .NotEmpty()
+ .EmailAddress()
+ .WithName("bcc");
+
+ //Check values (names)
+ bcc.RuleForEach(static b => b.Values)
+ .NotEmpty()
+ .AlphaNumericOnly()
+ .WithName("bcc_names");
+ })
+ .When(static t => t.BccAddresses != null)
+ .WithName("bcc");
+ //Check cc, also allowed to be empty
+ RuleFor(static t => t.CcAddresses)
+ .ChildRules(static cc => {
+ //Check keys (names)
+ cc.RuleForEach(static b => b.Keys)
+ .NotEmpty()
+ .EmailAddress()
+ .WithName("cc");
+
+ //Check values (addresses)
+ cc.RuleForEach(static b => b.Values)
+ .NotEmpty()
+ .AlphaNumericOnly()
+ .WithName("cc_names");
+ })
+ .When(static t => t.BccAddresses != null)
+ .WithName("cc");
+
+ RuleFor(static t => t.ReplyTo)
+ //Allow the reply to email to be empty, if its not, then validate the address
+ .EmailAddress()
+ .When(static t => t.BccAddresses != null)
+ .WithName("reply_to");
+
+ //Make sure a variable table is defined
+ RuleFor(static t => t.Variables)
+ .NotNull()
+ .WithName("variables");
+ }
+ }
+}
diff --git a/Transactional Emails/Transactions/TransactionResult.cs b/Transactional Emails/Transactions/TransactionResult.cs
new file mode 100644
index 0000000..2f14875
--- /dev/null
+++ b/Transactional Emails/Transactions/TransactionResult.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+
+using VNLib.Plugins.Extensions.Validation;
+
+namespace Emails.Transactional.Transactions
+{
+ public class TransactionResult : ValErrWebMessage
+ {
+ [JsonPropertyName("transaction_id")]
+ public string TransactionId { get; set; }
+ [JsonPropertyName("smtp_status")]
+ public string SmtpStatus { get; set; }
+
+ [JsonPropertyName("error_code")]
+ public string ErrorCode { get; set; }
+
+ [JsonPropertyName("error_description")]
+ public string ErrorDescription { get; set; }
+ }
+}
diff --git a/Transactional Emails/Transactions/TransactionStore.cs b/Transactional Emails/Transactions/TransactionStore.cs
new file mode 100644
index 0000000..9a700ae
--- /dev/null
+++ b/Transactional Emails/Transactions/TransactionStore.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Linq;
+
+using Microsoft.EntityFrameworkCore;
+
+using VNLib.Plugins.Extensions.Data;
+
+namespace Emails.Transactional.Transactions
+{
+
+ internal class TransactionStore : DbStore<EmailTransaction>
+ {
+ private readonly DbContextOptions Options;
+
+ public TransactionStore(DbContextOptions options)
+ {
+ Options = options;
+ }
+
+ public override TransactionalDbContext NewContext() => new EmailDbCtx(Options);
+
+ public override string RecordIdBuilder => Guid.NewGuid().ToString("N");
+
+ protected override void OnRecordUpdate(EmailTransaction newRecord, EmailTransaction oldRecord)
+ {
+ oldRecord.LastModified = DateTime.UtcNow;
+ }
+
+ protected override IQueryable<EmailTransaction> GetCollectionQueryBuilder(TransactionalDbContext context, params string[] constraints)
+ {
+ string userId = constraints[0];
+ //Get the last transactions for the specifed user
+ EmailDbCtx ctx = context as EmailDbCtx;
+ return from trans in ctx.EmailTransactions
+ where trans.UserId == userId
+ orderby trans.LastModified descending
+ select trans;
+ }
+
+ protected override IQueryable<EmailTransaction> GetSingleQueryBuilder(TransactionalDbContext context, params string[] constraints)
+ {
+ string transactionid = constraints[0];
+ EmailDbCtx ctx = context as EmailDbCtx;
+ //Selet the exact transaction from its id
+ return from trans in ctx.EmailTransactions
+ where trans.Id == transactionid
+ select trans;
+ }
+ }
+}