Skip to content

Commit

Permalink
feature(relay): Implement Javascript expression to make auto relay ru…
Browse files Browse the repository at this point in the history
…les more flexible. (#1347)



This allows you to write an expression to control if a message is relayed or not based on its metadata or info from the session.

It also allows the recipients to be dynamically modified when relaying. For example replacing @real.com with @test.com.

See the comments in appsettings.json for full details on how to use this.

This has also been exposed in the settings dialog.
  • Loading branch information
rnwood authored Mar 16, 2024
1 parent a68ee4a commit 5ea400f
Show file tree
Hide file tree
Showing 14 changed files with 295 additions and 123 deletions.
1 change: 1 addition & 0 deletions Rnwood.Smtp4dev/ApiModel/ServerRelayOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ public class ServerRelayOptions
public string Password { get; set; }

public string TlsMode { get; set;}
public string AutomaticRelayExpression { get; set; }
}
}
4 changes: 3 additions & 1 deletion Rnwood.Smtp4dev/ClientApp/src/ApiClient/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import ServerRelayOptions from './ServerRelayOptions';
export default class Server {

constructor(isRunning: boolean, exception: string, portNumber: number, hostName: string, allowRemoteConnections: boolean, numberOfMessagesToKeep: number, numberOfSessionsToKeep: number, relayOptions: ServerRelayOptions, imapPortNumber: number, settingsAreEditable: boolean, disableMessageSanitisation: boolean,) {
constructor(isRunning: boolean, exception: string, portNumber: number, hostName: string, allowRemoteConnections: boolean, numberOfMessagesToKeep: number, numberOfSessionsToKeep: number, relayOptions: ServerRelayOptions, imapPortNumber: number, settingsAreEditable: boolean, disableMessageSanitisation: boolean, automaticRelayExpression: string) {

this.isRunning = isRunning;
this.exception = exception;
Expand All @@ -16,6 +16,7 @@ export default class Server {
this.imapPortNumber = imapPortNumber;
this.settingsAreEditable = settingsAreEditable;
this.disableMessageSanitisation = disableMessageSanitisation;
this.automaticRelayExpression = automaticRelayExpression
}


Expand All @@ -30,4 +31,5 @@ export default class Server {
imapPortNumber: number;
settingsAreEditable: boolean;
disableMessageSanitisation: boolean;
automaticRelayExpression: string;
}
4 changes: 4 additions & 0 deletions Rnwood.Smtp4dev/ClientApp/src/components/settingsdialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
<el-input v-model="server.relayOptions.senderAddress" />
</el-form-item>

<el-form-item label="Auto relay expression (see comments in appsettings.json)" prop="server.relayOptions.automaticRelayExpression" v-show="isRelayEnabled">
<el-input v-model="server.relayOptions.automaticRelayExpression" />
</el-form-item>

<el-form-item label="Auto-Relay Recipients" v-show="isRelayEnabled" prop="isRelayEnabled">
<div v-for="(email, index) in server.relayOptions.automaticEmails" :key="index">
<el-form-item :prop="'relayOptionsAutomaticEmails[' + index + '].value'" :rules="{required: true, message: 'Required'}">
Expand Down
9 changes: 6 additions & 3 deletions Rnwood.Smtp4dev/Controllers/ServerController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Rnwood.Smtp4dev.Server;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -51,8 +52,9 @@ public ApiModel.Server GetServer()
SmtpPort = relayOptions.CurrentValue.SmtpPort,
Login = relayOptions.CurrentValue.Login,
Password = relayOptions.CurrentValue.Password,
AutomaticEmails = relayOptions.CurrentValue.AutomaticEmails,
SenderAddress = relayOptions.CurrentValue.SenderAddress
AutomaticEmails = relayOptions.CurrentValue.AutomaticEmails.Where(s => !String.IsNullOrWhiteSpace(s)).ToArray(),
SenderAddress = relayOptions.CurrentValue.SenderAddress,
AutomaticRelayExpression = relayOptions.CurrentValue.AutomaticRelayExpression
},
SettingsAreEditable = hostingEnvironmentHelper.SettingsAreEditable,
DisableMessageSanitisation = serverOptions.CurrentValue.DisableMessageSanitisation
Expand Down Expand Up @@ -84,7 +86,8 @@ public ActionResult UpdateServer(ApiModel.Server serverUpdate)
newRelaySettings.SenderAddress = serverUpdate.RelayOptions.SenderAddress;
newRelaySettings.Login = serverUpdate.RelayOptions.Login;
newRelaySettings.Password = serverUpdate.RelayOptions.Password;
newRelaySettings.AutomaticEmails = serverUpdate.RelayOptions.AutomaticEmails;
newRelaySettings.AutomaticEmails = serverUpdate.RelayOptions.AutomaticEmails.Where(s=> !String.IsNullOrWhiteSpace(s)).ToArray();
newRelaySettings.AutomaticRelayExpression = serverUpdate.RelayOptions.AutomaticRelayExpression;

System.IO.File.WriteAllText(hostingEnvironmentHelper.GetEditableSettingsFilePath(),
JsonSerializer.Serialize(new SettingsFile{ ServerOptions = newSettings, RelayOptions = newRelaySettings },
Expand Down
2 changes: 1 addition & 1 deletion Rnwood.Smtp4dev/Controllers/UseEtagFilterAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public void OnActionExecuted(ActionExecutedContext context)
{
context.Result = new StatusCodeResult(304);
}
context.HttpContext.Response.Headers.Add("ETag", new[] { etag });
context.HttpContext.Response.Headers["ETag"] = new[] { etag };
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageReference Include="Ardalis.GuardClauses" Version="4.5.0" />
<PackageReference Include="CommandLiners.MonoOptions" Version="1.0.36" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.59" />
<PackageReference Include="Jint" Version="3.0.1" />
<PackageReference Include="MailKit" Version="4.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Hosting.WindowsServices" Version="8.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="8.0.2" />
Expand Down Expand Up @@ -54,7 +55,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Rnwood.SmtpServer" Version="3.1.0-ci0831" />
<PackageReference Include="Rnwood.SmtpServer" Version="3.1.0-ci0837" />
</ItemGroup>

<ItemGroup>
Expand Down
70 changes: 70 additions & 0 deletions Rnwood.Smtp4dev/Server/CertificateHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using Serilog;

namespace Rnwood.Smtp4dev.Server
{
Expand Down Expand Up @@ -33,6 +34,75 @@ public static X509Certificate2 LoadCertificateWithKey(string certificatePath, st
return new X509Certificate2(pfxData, password);
}
}

public static X509Certificate2 GetTlsCertificate(ServerOptions options, ILogger logger)
{
X509Certificate2 cert = null;

logger.Information("TLS mode: {TLSMode}", options.TlsMode);

if (options.TlsMode != TlsMode.None)
{
if (!string.IsNullOrEmpty(options.TlsCertificate))
{
var pfxPassword = options.TlsCertificatePassword ?? "";

if (string.IsNullOrEmpty(options.TlsCertificatePrivateKey))
{
cert = CertificateHelper.LoadCertificate(options.TlsCertificate, pfxPassword);
}
else
{
cert = CertificateHelper.LoadCertificateWithKey(options.TlsCertificate,
options.TlsCertificatePrivateKey, pfxPassword);
}

logger.Information("Using provided certificate with Subject {SubjectName}, expiry {ExpiryDate}", cert.SubjectName.Name,
cert.GetExpirationDateString());
}
else
{
string pfxPath = Path.GetFullPath("selfsigned-certificate.pfx");
string cerPath = Path.GetFullPath("selfsigned-certificate.cer");

if (File.Exists(pfxPath))
{
cert = new X509Certificate2(File.ReadAllBytes(pfxPath), "",
X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable);

if (cert.Subject != $"CN={options.HostName}" ||
DateTime.Parse(cert.GetExpirationDateString()) < DateTime.Now.AddDays(30))
{
cert = null;
}
else
{
logger.Information(
"Using existing self-signed certificate with subject name {Hostname} and expiry date {ExpirationDate}",
options.HostName,
cert.GetExpirationDateString());
}
}

if (cert == null)
{
cert = SSCertGenerator.CreateSelfSignedCertificate(options.HostName);
File.WriteAllBytes(pfxPath, cert.Export(X509ContentType.Pkcs12));
File.WriteAllBytes(cerPath, cert.Export(X509ContentType.Cert));
logger.Information("Generated new self-signed certificate with subject name '{Hostname} and expiry date {ExpirationDate}",
options.HostName,
cert.GetExpirationDateString());
}


logger.Information(
"Ensure that the hostname you enter into clients and '{Hostname}' from ServerOptions:HostName configuration match exactly and trust the issuer certificate at {cerPath} in your client/OS to avoid certificate validation errors.",
options.HostName, cerPath);
}
}

return cert;
}

public static X509Certificate2 LoadCertificate(string certificatePath, string password)
{
Expand Down
2 changes: 1 addition & 1 deletion Rnwood.Smtp4dev/Server/ImapServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public void TryStart()
{
if (!errorTcs.Task.IsCompleted)
{
errorTcs.SetResult(ea);
errorTcs.TrySetResult(ea);
}
};
var startedTcs = new TaskCompletionSource<EventArgs>();
Expand Down
2 changes: 2 additions & 0 deletions Rnwood.Smtp4dev/Server/RelayOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public int SmtpPort
public SecureSocketOptions TlsMode { get; set; } = SecureSocketOptions.Auto;

public string[] AutomaticEmails { get; set; } = System.Array.Empty<string>();

public string AutomaticRelayExpression { get; set; }

public string SenderAddress { get; set; } = "";

Expand Down
107 changes: 107 additions & 0 deletions Rnwood.Smtp4dev/Server/ScriptingHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Esprima;
using Esprima.Ast;
using Jint;
using Jint.Native;
using Jint.Runtime;
using Microsoft.Extensions.Options;
using Rnwood.SmtpServer;
using Serilog;

namespace Rnwood.Smtp4dev.Server;

internal class ScriptingHost
{
private readonly ILogger log = Log.ForContext<ScriptingHost>();



private IOptionsMonitor<RelayOptions> _relayOptions;

public ScriptingHost(IOptionsMonitor<RelayOptions> relayOptions)
{
_relayOptions = relayOptions;
_relayOptions.OnChange(o => ParseScripts(o));
ParseScripts(relayOptions.CurrentValue);
}

private void ParseScripts(RelayOptions relayOptionsCurrentValue)
{
if (shouldRelaySource != relayOptionsCurrentValue.AutomaticRelayExpression)
{
var autoRelayExpression = relayOptionsCurrentValue.AutomaticRelayExpression ?? "";
log.Information("Parsing AutomaticRelayExpression {autoRelayExpression}", autoRelayExpression);

if (string.IsNullOrWhiteSpace(autoRelayExpression))
{
shouldRelayScript = null;
}
else
{
var parser = new JavaScriptParser();
shouldRelayScript = parser.ParseScript(autoRelayExpression);
}

shouldRelaySource = autoRelayExpression;
}
}

private string shouldRelaySource;
private Script shouldRelayScript;

public IReadOnlyCollection<string> GetAutoRelayRecipients(ApiModel.Message message, string recipient, ApiModel.Session session)
{
if (shouldRelayScript == null)
{
return Array.Empty<string>();
}

Engine jsEngine = new Engine();

jsEngine.SetValue("recipient", recipient);
jsEngine.SetValue("message", message);
jsEngine.SetValue("session", session);

try
{
JsValue result = jsEngine.Evaluate(shouldRelayScript);

List<string> recpients = new List<string>();
if (result.IsString())
{
if (result.AsString() != String.Empty)
{
recpients.Add(result.AsString());
}
}
else if (result.IsArray())
{
recpients.AddRange(result.AsArray().Select(v => v.AsString()));
}
else if (result.AsBoolean())
{
recpients.Add(recipient);
}

log.Information("AutomaticRelayExpression: (message: {message.Id}, recipient: {recipient}, session: {session.Id}) => {result} => {recipients}", message.Id, recipient,
session.Id, result, recpients);

return recpients;

}
catch (JavaScriptException ex)
{
log.Error("Error executing AutomaticRelayExpression : {error}", ex.Error);
return Array.Empty<string>();
}
catch (Exception ex)
{
log.Error("Error executing AutomaticRelayExpression : {error}", ex.ToString());
return Array.Empty<string>();
}

}
}
Loading

0 comments on commit 5ea400f

Please sign in to comment.