diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2a58503
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,39 @@
+# If this file is renamed, the incrementing run attempt number will be reset.
+
+name: CI
+
+on:
+ push:
+ branches: [ "dev", "main" ]
+ pull_request:
+ branches: [ "dev", "main" ]
+
+env:
+ CI_BUILD_NUMBER_BASE: ${{ github.run_number }}
+ CI_TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
+
+jobs:
+ build:
+
+ runs-on: ubuntu-24.04
+
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+ - name: Compute build number
+ run: |
+ echo "CI_BUILD_NUMBER=$(($CI_BUILD_NUMBER_BASE+90))" >> $GITHUB_ENV
+ - name: Build and Publish
+ env:
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: pwsh
+ run: |
+ ./Build.ps1
diff --git a/Build.ps1 b/Build.ps1
index 64a5e37..61448e8 100644
--- a/Build.ps1
+++ b/Build.ps1
@@ -1,63 +1,55 @@
-# This script originally (c) 2016 Serilog Contributors - license Apache 2.0
-
-echo "build: Build started"
+Write-Output "build: Build started"
Push-Location $PSScriptRoot
+Write-Output "build: Tool versions follow"
+
+dotnet --version
+dotnet --list-sdks
+
if(Test-Path .\artifacts) {
- echo "build: Cleaning .\artifacts"
- Remove-Item .\artifacts -Force -Recurse
+ Write-Output "build: Cleaning ./artifacts"
+ Remove-Item ./artifacts -Force -Recurse
}
& dotnet restore --no-cache
-if($LASTEXITCODE -ne 0) { exit 1 }
-$branch = @{ $true = $env:APPVEYOR_REPO_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$env:APPVEYOR_REPO_BRANCH -ne $NULL];
-$revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:APPVEYOR_BUILD_NUMBER, 10); $false = "local" }[$env:APPVEYOR_BUILD_NUMBER -ne $NULL];
-$suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)))-$revision"}[$branch -eq "main" -and $revision -ne "local"]
+$dbp = [Xml] (Get-Content .\Directory.Build.props)
+$versionPrefix = $dbp.Project.PropertyGroup.VersionPrefix
-echo "build: Version suffix is $suffix"
+Write-Output "build: Package version prefix is $versionPrefix"
-foreach ($src in ls src/Seq.App.*) {
+$branch = @{ $true = $env:CI_TARGET_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$NULL -ne $env:CI_TARGET_BRANCH];
+$revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:CI_BUILD_NUMBER, 10); $false = "local" }[$NULL -ne $env:CI_BUILD_NUMBER];
+$suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)) -replace '([^a-zA-Z0-9\-]*)', '')-$revision"}[$branch -eq "main" -and $revision -ne "local"]
+
+Write-Output "build: Package version suffix is $suffix"
+
+foreach ($src in Get-ChildItem src/*) {
Push-Location $src
- echo "build: Packaging app project in $src"
+ Write-Output "build: Packaging project in $src"
- if (Test-Path ./obj/publish) {
- Remove-Item -Recurse -Force ./obj/publish
- }
-
if ($suffix) {
& dotnet publish -c Release -o ./obj/publish --version-suffix=$suffix
- & dotnet pack -c Release -o ..\..\artifacts --no-build --version-suffix=$suffix
+ & dotnet pack -c Release -o ../../artifacts --no-build --version-suffix=$suffix
} else {
& dotnet publish -c Release -o ./obj/publish
- & dotnet pack -c Release -o ..\..\artifacts --no-build
+ & dotnet pack -c Release -o ../../artifacts --no-build
}
- if($LASTEXITCODE -ne 0) { throw "Build failed" }
+ if($LASTEXITCODE -ne 0) { throw "Packaging failed" }
Pop-Location
}
-foreach ($src in @("src/Seq.Syntax", "src/Seq.Mail", "src/Seq.Apps.Testing")) {
- Push-Location $src
-
- echo "build: Packaging library in $src"
-
- if ($suffix) {
- & dotnet pack -c Release -o ..\..\artifacts --version-suffix=$suffix
- } else {
- & dotnet pack -c Release -o ..\..\artifacts
- }
- if($LASTEXITCODE -ne 0) { throw "Build failed" }
-
- Pop-Location
-}
+Write-Output "build: Checking complete solution builds"
+& dotnet build
+if($LASTEXITCODE -ne 0) { throw "Solution build failed" }
-foreach ($test in ls test/*.Tests) {
+foreach ($test in Get-ChildItem test/*.Tests) {
Push-Location $test
- echo "build: Testing project in $test"
+ Write-Output "build: Testing project in $test"
& dotnet test -c Release
if($LASTEXITCODE -ne 0) { throw "Testing failed" }
@@ -65,4 +57,22 @@ foreach ($test in ls test/*.Tests) {
Pop-Location
}
-Pop-Location
\ No newline at end of file
+Pop-Location
+
+if ($env:NUGET_API_KEY) {
+ # GitHub Actions will only supply this to branch builds and not PRs. We publish
+ # builds from any branch this action targets (i.e. main and dev).
+
+ Write-Output "build: Publishing NuGet packages"
+
+ foreach ($nupkg in Get-ChildItem artifacts/*.nupkg) {
+ & dotnet nuget push -k $env:NUGET_API_KEY -s https://api.nuget.org/v3/index.json "$nupkg"
+ if($LASTEXITCODE -ne 0) { throw "Publishing failed" }
+ }
+
+ if (!($suffix)) {
+ Write-Output "build: Creating release for version $versionPrefix"
+
+ iex "gh release create v$versionPrefix --title v$versionPrefix --generate-notes $(get-item ./artifacts/*.nupkg) $(get-item ./artifacts/*.snupkg)"
+ }
+}
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..e348342
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,5 @@
+
+
+ 1.1.0
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index fb8ea9e..d1cabf6 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,15 @@
-# Seq Mail Apps [](https://ci.appveyor.com/project/datalust/seq-app-mail/branch/dev)
+# Seq Mail Apps
This repository contains the Seq output apps for various email services, built on a shared email templating system.
| Package id | Description |
|-------------------------------------------------------------------------------------|-----------------------------------------------------------------|
+| [`Seq.App.Mail.AmazonSes`](https://nuget.org/packages/seq.app.mail.amazonses) | Send email using the Amazon Simple Email Service (SES) API. |
| [`Seq.App.Mail.Microsoft365`](https://nuget.org/packages/seq.app.mail.microsoft365) | Send email through Microsoft 365 using the Microsoft Graph API. |
| [`Seq.App.Mail.Smtp`](https://nuget.org/packages/seq.app.mail.smtp) | Send email using the SMTP protocol. |
> Need to send email using a protocol or API not listed here? Let us know!
-
## Getting started
Install the app under _Settings > Apps_, using one of the package ids from the table above.
@@ -30,9 +30,16 @@ When starting an instance of the app in Seq, the following parameters can be sup
| **Body** | The email body. | Yes | See `src/Seq.Mail/Resources` |
| **Body is plain text** | If checked, the body template will be interpreted as plain text, rather than HTML. | | |
+### `Seq.App.Mail.AmazonSes`
+
+| Property | Description | Template? | Default |
+|-------------------|------------------------------|---|---|
+| **Access key id** | An Amazon SES access key id. | | |
+| **Client id** | An Amazon SES secret key. | | |
+
### `Seq.App.Mail.Microsoft365`
-To send mail using the Microsoft 365 app, first create an app registration in Azure. The app must have the `Mail.Send` permission
+To send mail using the Microsoft 365 app, first create an app registration in Azure. The app must have the `Mail.Send` permission
for the Microsoft Graph API.
| Property | Description | Template? | Default |
@@ -54,6 +61,8 @@ for the Microsoft Graph API.
## Templates
+The Seq mail apps support the [Seq template language](https://docs.datalust.co/docs/template-syntax).
+
Event and notification properties can be inserted dynamically into many of the settings listed above, by surrounding them
with braces:
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index 96f0571..0000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-version: '{build}'
-skip_tags: true
-image: Visual Studio 2022
-build_script:
- - pwsh: ./Build.ps1
-test: off
-artifacts:
- - path: artifacts/Seq.*.nupkg
-deploy:
- - provider: NuGet
- api_key:
- secure: yA6yDHx8xQNbvvX+L4Aqc1yVcd77wEzrgeEsqF+UaBQqMXdvhJT236wEUX2voTjq
- skip_symbols: true
- artifact: /Seq.*\.nupkg/
- on:
- branch: /^(main|dev)$/
- - provider: GitHub
- auth_token:
- secure: hX+cZmW+9BCXy7vyH8myWsYdtQHyzzil9K5yvjJv7dK9XmyrGYYDj/DPzMqsXSjo
- artifact: /Seq.*\.nupkg/
- tag: v$(appveyor_build_version)
- on:
- branch: main
diff --git a/harness/Seq.Mail.TestHarness/Seq.Mail.TestHarness.csproj b/harness/Seq.Mail.TestHarness/Seq.Mail.TestHarness.csproj
index 2f966a6..49801ad 100644
--- a/harness/Seq.Mail.TestHarness/Seq.Mail.TestHarness.csproj
+++ b/harness/Seq.Mail.TestHarness/Seq.Mail.TestHarness.csproj
@@ -8,7 +8,7 @@
-
+
diff --git a/seq-app-mail.sln b/seq-app-mail.sln
index c17f7fb..e555869 100644
--- a/seq-app-mail.sln
+++ b/seq-app-mail.sln
@@ -11,11 +11,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sln", "sln", "{0F8E84A5-952
ProjectSection(SolutionItems) = preProject
.gitattributes = .gitattributes
.gitignore = .gitignore
- appveyor.yml = appveyor.yml
- Build.ps1 = Build.ps1
LICENSE = LICENSE
README.md = README.md
RunLocalSmtp.ps1 = RunLocalSmtp.ps1
+ Directory.Build.props = Directory.Build.props
+ Build.ps1 = Build.ps1
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "asset", "asset", "{AC60371C-2888-45BB-9770-2A690DEA80FA}"
@@ -43,6 +43,15 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "harness", "harness", "{E684
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seq.Mail.TestHarness", "harness\Seq.Mail.TestHarness\Seq.Mail.TestHarness.csproj", "{53B48FC5-E76D-4442-9259-DB57CDB53232}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seq.App.Mail.AmazonSes", "src\Seq.App.Mail.AmazonSes\Seq.App.Mail.AmazonSes.csproj", "{D124E851-B1CE-4DAD-8C1A-45697701D190}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{A5CC96EF-1E35-4836-9EE6-11F114D3E41D}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{6D4A81AD-7209-4848-AFF5-232E4E3FDF41}"
+ ProjectSection(SolutionItems) = preProject
+ .github\workflows\ci.yml = .github\workflows\ci.yml
+ EndProjectSection
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -81,6 +90,10 @@ Global
{53B48FC5-E76D-4442-9259-DB57CDB53232}.Debug|Any CPU.Build.0 = Debug|Any CPU
{53B48FC5-E76D-4442-9259-DB57CDB53232}.Release|Any CPU.ActiveCfg = Release|Any CPU
{53B48FC5-E76D-4442-9259-DB57CDB53232}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D124E851-B1CE-4DAD-8C1A-45697701D190}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D124E851-B1CE-4DAD-8C1A-45697701D190}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D124E851-B1CE-4DAD-8C1A-45697701D190}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D124E851-B1CE-4DAD-8C1A-45697701D190}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -94,6 +107,8 @@ Global
{A836BDA9-335E-49E1-AA68-DA3D8B6DABA0} = {B03B3086-D197-4B32-9AE2-8536C345EA2D}
{7E46171B-8E2F-42EB-8C10-526578F9BED0} = {91E482DE-E1E7-4CE1-9511-C0AF07F3648A}
{53B48FC5-E76D-4442-9259-DB57CDB53232} = {E684AE47-A861-44F3-A648-A512652ED9C5}
+ {D124E851-B1CE-4DAD-8C1A-45697701D190} = {91E482DE-E1E7-4CE1-9511-C0AF07F3648A}
+ {6D4A81AD-7209-4848-AFF5-232E4E3FDF41} = {A5CC96EF-1E35-4836-9EE6-11F114D3E41D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {EB6672D6-318E-493E-8B60-77F5A7A90E66}
diff --git a/seq-app-mail.sln.DotSettings b/seq-app-mail.sln.DotSettings
index e40a888..0dfd52c 100644
--- a/seq-app-mail.sln.DotSettings
+++ b/seq-app-mail.sln.DotSettings
@@ -1,5 +1,6 @@
True
True
+ True
True
True
\ No newline at end of file
diff --git a/src/Seq.App.Mail.AmazonSes/AmazonSesMailApp.cs b/src/Seq.App.Mail.AmazonSes/AmazonSesMailApp.cs
new file mode 100644
index 0000000..f15cf91
--- /dev/null
+++ b/src/Seq.App.Mail.AmazonSes/AmazonSesMailApp.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.SimpleEmail.Model;
+using MimeKit;
+using Seq.Apps;
+using Seq.Mail;
+
+// ReSharper disable UnusedAutoPropertyAccessor.Global
+
+namespace Seq.App.Mail.AmazonSes;
+
+[SeqApp("Amazon Simple Email Service (SES) Mail",
+ Description = "Send events and notifications by email, using the Amazon Simple Email Service (SES) API.")]
+public class AmazonSesMailApp: MailApp
+{
+ readonly IAmazonSesMailGateway _mailGateway;
+ AmazonSesOptions? _options;
+
+ internal AmazonSesMailApp(IAmazonSesMailGateway mailGateway)
+ {
+ _mailGateway = mailGateway;
+ }
+
+ public AmazonSesMailApp()
+ : this (new AmazonSimpleEmailServiceClientMailGateway())
+ {
+ }
+
+ [SeqAppSetting(
+ DisplayName = "Access key id",
+ HelpText = "An AWS access key id.")]
+ public string? AccessKeyId { get; set; }
+
+ [SeqAppSetting(
+ DisplayName = "Secret key",
+ HelpText = "An AWS secret key.",
+ InputType = SettingInputType.Password)]
+ public string? SecretKey { get; set; }
+
+ protected override void OnAttached()
+ {
+ base.OnAttached();
+
+ _options = new AmazonSesOptions(
+ NormalizeOption(AccessKeyId) ?? throw new InvalidOperationException("An access key id is required."),
+ NormalizeOption(SecretKey) ?? throw new InvalidOperationException("A secret key is required."));
+ }
+
+ protected override async Task SendAsync(MimeMessage message, CancellationToken cancel)
+ {
+ const string charset = "UTF-8";
+ var subject = new Content { Data = message.Subject, Charset = charset };
+
+ var body = new Body
+ {
+ Html = message.HtmlBody != null ? new Content { Data = message.HtmlBody, Charset = charset } : null,
+ Text = message.TextBody != null ? new Content { Data = message.TextBody, Charset = charset } : null
+ };
+
+ var sesMessage = new Message
+ {
+ Subject = subject,
+ Body = body
+ };
+
+ var destination = new Destination { ToAddresses = message.To.Select(addr => addr.ToString()).ToList() };
+
+ var request = new SendEmailRequest
+ {
+ Source = message.From.Single().ToString(),
+ Destination = destination,
+ Message = sesMessage
+ };
+
+ await _mailGateway.SendAsync(_options!, request, cancel);
+ }
+}
diff --git a/src/Seq.App.Mail.AmazonSes/AmazonSesOptions.cs b/src/Seq.App.Mail.AmazonSes/AmazonSesOptions.cs
new file mode 100644
index 0000000..1ac7f3d
--- /dev/null
+++ b/src/Seq.App.Mail.AmazonSes/AmazonSesOptions.cs
@@ -0,0 +1,13 @@
+namespace Seq.App.Mail.AmazonSes;
+
+class AmazonSesOptions
+{
+ public string AccessKeyId { get; }
+ public string SecretKey { get; }
+
+ public AmazonSesOptions(string accessKeyId, string secretKey)
+ {
+ AccessKeyId = accessKeyId;
+ SecretKey = secretKey;
+ }
+}
diff --git a/src/Seq.App.Mail.AmazonSes/AmazonSimpleEmailServiceClientMailGateway.cs b/src/Seq.App.Mail.AmazonSes/AmazonSimpleEmailServiceClientMailGateway.cs
new file mode 100644
index 0000000..f930cf0
--- /dev/null
+++ b/src/Seq.App.Mail.AmazonSes/AmazonSimpleEmailServiceClientMailGateway.cs
@@ -0,0 +1,15 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.SimpleEmail;
+using Amazon.SimpleEmail.Model;
+
+namespace Seq.App.Mail.AmazonSes;
+
+class AmazonSimpleEmailServiceClientMailGateway : IAmazonSesMailGateway
+{
+ public async Task SendAsync(AmazonSesOptions options, SendEmailRequest request, CancellationToken cancel)
+ {
+ using var client = new AmazonSimpleEmailServiceClient(options.AccessKeyId, options.SecretKey);
+ await client.SendEmailAsync(request, cancel);
+ }
+}
diff --git a/src/Seq.App.Mail.AmazonSes/IAmazonSesMailGateway.cs b/src/Seq.App.Mail.AmazonSes/IAmazonSesMailGateway.cs
new file mode 100644
index 0000000..343d4d4
--- /dev/null
+++ b/src/Seq.App.Mail.AmazonSes/IAmazonSesMailGateway.cs
@@ -0,0 +1,10 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.SimpleEmail.Model;
+
+namespace Seq.App.Mail.AmazonSes;
+
+interface IAmazonSesMailGateway
+{
+ Task SendAsync(AmazonSesOptions options, SendEmailRequest request, CancellationToken cancel);
+}
diff --git a/src/Seq.App.Mail.AmazonSes/Seq.App.Mail.AmazonSes.csproj b/src/Seq.App.Mail.AmazonSes/Seq.App.Mail.AmazonSes.csproj
new file mode 100644
index 0000000..8f14b78
--- /dev/null
+++ b/src/Seq.App.Mail.AmazonSes/Seq.App.Mail.AmazonSes.csproj
@@ -0,0 +1,36 @@
+
+
+
+ net6.0
+ enable
+ Send events and notifications from Seq via the Amazon Simple Email Service (SES) API.
+ Datalust, Serilog Contributors
+ seq-app amazon aws ses simple email service
+ https://github.com/datalust/seq-app-mail
+ seq-app-mail.png
+ Apache-2.0
+ https://github.com/datalust/seq-app-mail
+ git
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Seq.App.Mail.Microsoft365/Seq.App.Mail.Microsoft365.csproj b/src/Seq.App.Mail.Microsoft365/Seq.App.Mail.Microsoft365.csproj
index 5ec53cb..5c171c9 100644
--- a/src/Seq.App.Mail.Microsoft365/Seq.App.Mail.Microsoft365.csproj
+++ b/src/Seq.App.Mail.Microsoft365/Seq.App.Mail.Microsoft365.csproj
@@ -4,7 +4,6 @@
net6.0
enable
Send events and notifications from Seq via Microsoft 365 mail.
- 1.0.0
Datalust, Serilog Contributors
seq-app microsoft-365 email
https://github.com/datalust/seq-app-mail
@@ -25,8 +24,8 @@
-
-
+
+
diff --git a/src/Seq.App.Mail.Smtp/Seq.App.Mail.Smtp.csproj b/src/Seq.App.Mail.Smtp/Seq.App.Mail.Smtp.csproj
index 1219a07..9f29b17 100644
--- a/src/Seq.App.Mail.Smtp/Seq.App.Mail.Smtp.csproj
+++ b/src/Seq.App.Mail.Smtp/Seq.App.Mail.Smtp.csproj
@@ -4,7 +4,6 @@
net6.0
enable
Send events and notifications from Seq via SMTP mail.
- 1.0.0
Datalust, Serilog Contributors
seq-app smtp email
https://github.com/datalust/seq-app-mail
diff --git a/src/Seq.Apps.Testing/Seq.Apps.Testing.csproj b/src/Seq.Apps.Testing/Seq.Apps.Testing.csproj
index 67b43be..5b70c97 100644
--- a/src/Seq.Apps.Testing/Seq.Apps.Testing.csproj
+++ b/src/Seq.Apps.Testing/Seq.Apps.Testing.csproj
@@ -6,7 +6,6 @@
true
true
Helper types for development and debugging of Seq plug-in apps.
- 1.0.0
Datalust
Apache-2.0
seq-apps-testing.png
@@ -14,8 +13,8 @@
-
-
+
+
diff --git a/src/Seq.Mail/MailApp.cs b/src/Seq.Mail/MailApp.cs
index 03b6805..76f8d8d 100644
--- a/src/Seq.Mail/MailApp.cs
+++ b/src/Seq.Mail/MailApp.cs
@@ -83,9 +83,12 @@ protected override void OnAttached()
protected abstract Task SendAsync(MimeMessage message, CancellationToken cancel);
+ protected virtual void PrepareMessage(LogEvent logEvent, MimeMessage message) { }
+
public async Task OnAsync(Event evt)
{
using var message = _mailMessageFactory!.FromEvent(evt.Data);
+ PrepareMessage(evt.Data, message);
await SendAsync(message, default);
}
diff --git a/src/Seq.Mail/Seq.Mail.csproj b/src/Seq.Mail/Seq.Mail.csproj
index 8309ace..01c796e 100644
--- a/src/Seq.Mail/Seq.Mail.csproj
+++ b/src/Seq.Mail/Seq.Mail.csproj
@@ -4,7 +4,6 @@
net6.0
enable
Shared infrastructure for Seq apps that integrate with email and similar messaging services.
- 1.0.0
Datalust
https://github.com/datalust/seq-app-mail
seq-mail.png
@@ -23,7 +22,7 @@
-
+
diff --git a/src/Seq.Syntax/BuiltIns/SeqBuiltInPropertyNameResolver.cs b/src/Seq.Syntax/BuiltIns/SeqBuiltInPropertyNameResolver.cs
index d7febca..9cb0554 100644
--- a/src/Seq.Syntax/BuiltIns/SeqBuiltInPropertyNameResolver.cs
+++ b/src/Seq.Syntax/BuiltIns/SeqBuiltInPropertyNameResolver.cs
@@ -24,8 +24,9 @@ public override bool TryResolveBuiltInPropertyName(string alias, [NotNullWhen(tr
"TraceId" or "tr" => "@p['@tr']",
"SpanId" or "sp" => "@p['@sp']",
"Resource" or "ra" => "@p['@ra']",
- "Start" or "st" => "@p['@st']",
+ "Start" or "st" => "_AsDateTimeOffset(@p['@st'])",
"ParentId" or "ps" => "@p['@ps']",
+ "SpanKind" or "sk" => "@p['@sk']",
"Scope" or "sa" => "@p['@sa']",
"Elapsed" => "_Elapsed(@st, @t)",
"Arrived" or "Document" or "Data" => "undefined()",
@@ -37,7 +38,7 @@ public override bool TryResolveBuiltInPropertyName(string alias, [NotNullWhen(tr
public override bool TryResolveFunctionName(string name, [NotNullWhen(true)] out MethodInfo? implementation)
{
- if (name == nameof(_Elapsed))
+ if (name == nameof(_Elapsed) || name == nameof(_AsDateTimeOffset))
{
implementation = typeof(SeqBuiltInPropertyNameResolver).GetMethod(name)!;
return true;
@@ -55,6 +56,14 @@ public override bool TryResolveFunctionName(string name, [NotNullWhen(true)] out
return null;
}
+ public static LogEventPropertyValue? _AsDateTimeOffset(LogEventPropertyValue? value)
+ {
+ if (AsDateTimeOffset(value) is { } dto)
+ return new ScalarValue(dto);
+
+ return null;
+ }
+
static DateTimeOffset? AsDateTimeOffset(LogEventPropertyValue? value)
{
if (value is ScalarValue { Value: DateTime dt })
diff --git a/src/Seq.Syntax/Expressions/Compilation/Linq/LinqExpressionCompiler.cs b/src/Seq.Syntax/Expressions/Compilation/Linq/LinqExpressionCompiler.cs
index 442c8e2..35d7dd2 100644
--- a/src/Seq.Syntax/Expressions/Compilation/Linq/LinqExpressionCompiler.cs
+++ b/src/Seq.Syntax/Expressions/Compilation/Linq/LinqExpressionCompiler.cs
@@ -104,6 +104,10 @@ protected override ExpressionBody Transform(CallExpression call)
if (!_nameResolver.TryResolveFunctionName(call.OperatorName, out var m))
throw new ArgumentException($"The function name `{call.OperatorName}` was not recognized.");
+ if (m == null!)
+ throw new InvalidOperationException(
+ $"The name resolver {_nameResolver} failed to return a valid `MethodInfo` for function `{call.OperatorName}`.");
+
var methodParameters = m.GetParameters()
.Select(info => (pi: info, optional: info.GetCustomAttribute() != null))
.ToList();
diff --git a/src/Seq.Syntax/Expressions/Operators.cs b/src/Seq.Syntax/Expressions/Operators.cs
index adbd4a5..874af92 100644
--- a/src/Seq.Syntax/Expressions/Operators.cs
+++ b/src/Seq.Syntax/Expressions/Operators.cs
@@ -16,9 +16,7 @@
using System.Collections.Generic;
using Seq.Syntax.Expressions.Ast;
-// ReSharper disable UnusedMember.Global
-
-// ReSharper disable InconsistentNaming, MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global, InconsistentNaming, MemberCanBePrivate.Global
namespace Seq.Syntax.Expressions;
@@ -37,8 +35,10 @@ static class Operators
public const string OpEndsWith = "EndsWith";
public const string OpIndexOf = "IndexOf";
public const string OpIndexOfMatch = "IndexOfMatch";
- public const string OpIsMatch = "IsMatch";
public const string OpIsDefined = "IsDefined";
+ public const string OpIsMatch = "IsMatch";
+ public const string OpIsRootSpan = "IsRootSpan";
+ public const string OpIsSpan = "IsSpan";
public const string OpLastIndexOf = "LastIndexOf";
public const string OpLength = "Length";
public const string OpNow = "Now";
@@ -49,6 +49,7 @@ static class Operators
public const string OpToLower = "ToLower";
public const string OpToUpper = "ToUpper";
public const string OpToString = "ToString";
+ public const string OpTotalMilliseconds = "TotalMilliseconds";
public const string OpTypeOf = "TypeOf";
public const string OpUndefined = "Undefined";
public const string OpUriEncode = "UriEncode";
diff --git a/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs b/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs
index f72878b..1597707 100644
--- a/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs
+++ b/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs
@@ -29,6 +29,8 @@ static class RuntimeOperators
static readonly LogEventPropertyValue ConstantTrue = new ScalarValue(true),
ConstantFalse = new ScalarValue(false);
+ const string SpanStartTimestampPropertyName = "@st", ParentSpanIdPropertyName = "@ps";
+
internal static LogEventPropertyValue ScalarBoolean(bool value)
{
return value ? ConstantTrue : ConstantFalse;
@@ -505,6 +507,10 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v
var toString = sv.Value switch
{
+ bool boolean => boolean ? "true" : "false",
+ DateTimeOffset dto when dto.Offset == TimeSpan.Zero && fmt == null => dto.UtcDateTime.ToString("O"),
+ DateTimeOffset dto when fmt == null => dto.ToString("O"),
+ DateTime dt when fmt == null => dt.ToString("O"),
LogEventLevel level => LevelRenderer.GetLevelMoniker(level, fmt),
IFormattable formattable => formattable.ToString(fmt, formatProvider),
_ => sv.Value.ToString()
@@ -557,4 +563,39 @@ public static LogEventPropertyValue _Internal_IsNotNull(LogEventPropertyValue? v
return null;
}
+
+ public static LogEventPropertyValue? IsSpan(LogEvent logEvent)
+ {
+ return new ScalarValue(logEvent is { TraceId: not null, SpanId: not null } &&
+ logEvent.Properties.ContainsKey(SpanStartTimestampPropertyName));
+ }
+
+ public static LogEventPropertyValue? IsRootSpan(LogEvent logEvent)
+ {
+ return new ScalarValue(logEvent is { TraceId: not null, SpanId: not null } &&
+ logEvent.Properties.ContainsKey(SpanStartTimestampPropertyName) &&
+ !logEvent.Properties.ContainsKey(ParentSpanIdPropertyName));
+ }
+
+ public static LogEventPropertyValue? FromUnixEpoch(LogEventPropertyValue? dateTime)
+ {
+ if (dateTime is ScalarValue sv)
+ {
+ if (sv.Value is DateTimeOffset dto)
+ return new ScalarValue(dto.UtcDateTime - DateTime.UnixEpoch);
+
+ if (sv.Value is DateTime dt)
+ return new ScalarValue(dt.ToUniversalTime() - DateTime.UnixEpoch);
+ }
+
+ return null;
+ }
+
+ public static LogEventPropertyValue? TotalMilliseconds(LogEventPropertyValue? timeSpan)
+ {
+ if (timeSpan is ScalarValue { Value: TimeSpan ts })
+ return new ScalarValue(ts.Ticks / (decimal)TimeSpan.TicksPerMillisecond);
+
+ return null;
+ }
}
\ No newline at end of file
diff --git a/src/Seq.Syntax/Seq.Syntax.csproj b/src/Seq.Syntax/Seq.Syntax.csproj
index a1a0cd6..6656735 100644
--- a/src/Seq.Syntax/Seq.Syntax.csproj
+++ b/src/Seq.Syntax/Seq.Syntax.csproj
@@ -4,7 +4,6 @@
net6.0
enable
Template and expression evaluation using a syntax based on the Seq query language.
- 1.0.0
Datalust
https://github.com/datalust/seq-app-mail
seq-syntax.png
@@ -12,6 +11,7 @@
https://github.com/datalust/seq-app-mail
git
true
+ latest
@@ -21,7 +21,7 @@
-
+
diff --git a/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs b/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs
index 8e21a45..e7670da 100644
--- a/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs
+++ b/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs
@@ -74,16 +74,49 @@ void EvaluateUnaligned(EvaluationContext ctx, TextWriter output)
}
}
}
-
- void EvaluateProperty(IReadOnlyDictionary properties, PropertyToken pt,
- TextWriter output)
+
+ void EvaluateProperty(IReadOnlyDictionary properties, PropertyToken pt, TextWriter output)
{
- if (!properties.TryGetValue(pt.PropertyName, out var value))
+ var rest = pt.PropertyName.AsSpan();
+ if (!TryGetNextStep(rest, out var name, out rest))
+ {
+ output.Write(pt);
+ return;
+ }
+
+ if (!properties.TryGetValue(name.ToString(), out var value))
{
output.Write(pt.ToString());
return;
}
+
+ while (TryGetNextStep(rest, out name, out rest))
+ {
+ if (value is not StructureValue obj)
+ {
+ output.Write(pt);
+ return;
+ }
+
+ var nameString = name.ToString();
+ var found = false;
+ foreach (var property in obj.Properties)
+ {
+ if (property.Name == nameString)
+ {
+ value = property.Value;
+ found = true;
+ break;
+ }
+ }
+ if (!found)
+ {
+ output.Write(pt);
+ return;
+ }
+ }
+
if (pt.Alignment is null)
{
EvaluatePropertyUnaligned(value, output, pt.Format);
@@ -159,4 +192,27 @@ void EvaluatePropertyUnaligned(LogEventPropertyValue propertyValue, TextWriter o
output.Write(value);
}
+
+ static bool TryGetNextStep(ReadOnlySpan path, out ReadOnlySpan name, out ReadOnlySpan rest)
+ {
+ if (path.Length == 0)
+ {
+ name = [];
+ rest = [];
+ return false;
+ }
+
+ var i = path.IndexOf('.');
+ if (i == -1)
+ {
+ name = path;
+ rest = [];
+ return true;
+ }
+
+ name = path[..i];
+ rest = i == name.Length - 1 ? [] : path[(i + 1)..];
+
+ return true;
+ }
}
\ No newline at end of file
diff --git a/test/Seq.Mail.Tests/AmazonSesMailAppTests.cs b/test/Seq.Mail.Tests/AmazonSesMailAppTests.cs
new file mode 100644
index 0000000..a3c7733
--- /dev/null
+++ b/test/Seq.Mail.Tests/AmazonSesMailAppTests.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Threading.Tasks;
+using Seq.App.Mail.AmazonSes;
+using Seq.Apps;
+using Seq.Apps.Testing.Hosting;
+using Seq.Mail.Tests.Support;
+using Serilog.Events;
+using Xunit;
+
+namespace Seq.Mail.Tests;
+
+public class AmazonSesMailAppTests
+{
+ [Fact]
+ public async Task EventsAreSentAsMessages()
+ {
+ var gateway = new TestAmazonSesMailGateway();
+
+ var app = new AmazonSesMailApp(gateway)
+ {
+ AccessKeyId = "aki",
+ SecretKey = "sk",
+ From = "f@localhost",
+ To = "t@localhost,r@localhost",
+ Subject = "s",
+ Body = "b",
+ BodyIsPlainText = false,
+ TimeZoneName = "Australia/Brisbane",
+ DateTimeFormat = "R"
+ };
+
+ app.Attach(new TestAppHost());
+
+ var evt = Some.InformationEvent();
+
+ await app.OnAsync(new Event("event-1", 123, DateTime.UtcNow, evt));
+
+ var (options, request) = Assert.Single(gateway.Received);
+ Assert.Equal("aki", options.AccessKeyId);
+ Assert.Equal("sk", options.SecretKey);
+ Assert.Equal("f@localhost", request.Source);
+ Assert.Equal(new[] { "t@localhost", "r@localhost" }, request.Destination.ToAddresses);
+ Assert.Equal("s", request.Message.Subject.Data);
+ Assert.Equal("b", request.Message.Body.Html.Data.Trim());
+ Assert.Null(request.Message.Body.Text);
+ }
+}
diff --git a/test/Seq.Mail.Tests/Seq.Mail.Tests.csproj b/test/Seq.Mail.Tests/Seq.Mail.Tests.csproj
index 93e7fd5..a139b68 100644
--- a/test/Seq.Mail.Tests/Seq.Mail.Tests.csproj
+++ b/test/Seq.Mail.Tests/Seq.Mail.Tests.csproj
@@ -5,16 +5,17 @@
true
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
+
diff --git a/test/Seq.Mail.Tests/Support/TestAmazonSesMailGateway.cs b/test/Seq.Mail.Tests/Support/TestAmazonSesMailGateway.cs
new file mode 100644
index 0000000..a7ce9cb
--- /dev/null
+++ b/test/Seq.Mail.Tests/Support/TestAmazonSesMailGateway.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.SimpleEmail.Model;
+using Seq.App.Mail.AmazonSes;
+
+namespace Seq.Mail.Tests.Support;
+
+class TestAmazonSesMailGateway : IAmazonSesMailGateway
+{
+ public List<(AmazonSesOptions, SendEmailRequest)> Received { get; } = [];
+
+ public Task SendAsync(AmazonSesOptions options, SendEmailRequest request, CancellationToken cancel)
+ {
+ Received.Add((options, request));
+ return Task.CompletedTask;
+ }
+}
diff --git a/test/Seq.Mail.Tests/Support/TestMicrosoftGraphMailGateway.cs b/test/Seq.Mail.Tests/Support/TestMicrosoftGraphMailGateway.cs
index d8ac9a6..a8fc92d 100644
--- a/test/Seq.Mail.Tests/Support/TestMicrosoftGraphMailGateway.cs
+++ b/test/Seq.Mail.Tests/Support/TestMicrosoftGraphMailGateway.cs
@@ -8,7 +8,7 @@ namespace Seq.Mail.Tests.Support;
class TestMicrosoftGraphMailGateway : IMicrosoftGraphMailGateway
{
- public List<(Microsoft365Options, Message)> Received { get; } = new();
+ public List<(Microsoft365Options, Message)> Received { get; } = [];
public Task SendAsync(Microsoft365Options options, Message message, CancellationToken cancel)
{
diff --git a/test/Seq.Mail.Tests/Support/TestSmtpMailGateway.cs b/test/Seq.Mail.Tests/Support/TestSmtpMailGateway.cs
index c82f497..973b92a 100644
--- a/test/Seq.Mail.Tests/Support/TestSmtpMailGateway.cs
+++ b/test/Seq.Mail.Tests/Support/TestSmtpMailGateway.cs
@@ -9,7 +9,7 @@ namespace Seq.Mail.Tests.Support;
class TestSmtpMailGateway : ISmtpMailGateway
{
- public List<(SmtpOptions, MimeMessage)> Received { get; } = new();
+ public List<(SmtpOptions, MimeMessage)> Received { get; } = [];
public Task SendAsync(SmtpOptions options, MimeMessage message, CancellationToken cancel)
{
diff --git a/test/Seq.Syntax.Tests/Cases/app-host-compatibility-case.json b/test/Seq.Syntax.Tests/Cases/app-host-compatibility-case.json
new file mode 100644
index 0000000..ecd7e27
--- /dev/null
+++ b/test/Seq.Syntax.Tests/Cases/app-host-compatibility-case.json
@@ -0,0 +1 @@
+{"@t":"2025-09-30T00:56:42.4868884Z","@mt":"Run demo app","@m":"Run demo app","@i":"6047df22","@tr":"f42777ef7fcfa457aa7126d57da507fe","@sp":"a6c09c6e0fa0d607","@st":"2025-09-30T00:56:41.4493934Z","@sk":"Internal","Application":"Demo"}
diff --git a/test/Seq.Syntax.Tests/Expressions/AppHostCompatibilityTests.cs b/test/Seq.Syntax.Tests/Expressions/AppHostCompatibilityTests.cs
new file mode 100644
index 0000000..b76bd8f
--- /dev/null
+++ b/test/Seq.Syntax.Tests/Expressions/AppHostCompatibilityTests.cs
@@ -0,0 +1,32 @@
+using System.IO;
+using System.Linq;
+using Seq.Syntax.Templates;
+using Seq.Syntax.Tests.Support;
+using Serilog.Formatting.Compact.Reader;
+using Xunit;
+
+namespace Seq.Syntax.Tests.Expressions;
+
+public class AppHostCompatibilityTests
+{
+ [Theory]
+ [InlineData("TotalMilliseconds(@Elapsed)", "1037.495")]
+ [InlineData("Round(TotalMilliseconds(@Elapsed), 0)", "1037")]
+ [InlineData("IsSpan()", "true")]
+ [InlineData("IsRootSpan()", "true")]
+ [InlineData("@SpanKind", "Internal")]
+ [InlineData("@Start", "2025-09-30T00:56:41.4493934Z")]
+ [InlineData("FromUnixEpoch(@Start)", "20361.00:56:41.4493934")]
+ [InlineData("TotalMilliseconds(FromUnixEpoch(@Start))", "1759193801449.3934")]
+ [InlineData("FromUnixEpoch(@Timestamp)", "20361.00:56:42.4868884")]
+ [InlineData("@EventType", "1615322914")]
+ public void AppHostJsonProcessingIsReasonable(string expression, string expected)
+ {
+ var json = TestCases.ReadNDJsonCases("app-host-compatibility-case.json").Single();
+ var evt = LogEventReader.ReadFromString(json);
+ var template = $"{{ {expression} }}";
+ var output = new StringWriter();
+ new ExpressionTemplate(template).Format(evt, output);
+ Assert.Equal(expected, output.ToString());
+ }
+}
diff --git a/test/Seq.Syntax.Tests/Expressions/ExpressionEvaluationTests.cs b/test/Seq.Syntax.Tests/Expressions/ExpressionEvaluationTests.cs
index 3de6ec5..b70bbd2 100644
--- a/test/Seq.Syntax.Tests/Expressions/ExpressionEvaluationTests.cs
+++ b/test/Seq.Syntax.Tests/Expressions/ExpressionEvaluationTests.cs
@@ -5,6 +5,7 @@
using Seq.Syntax.Expressions.Runtime;
using Seq.Syntax.Tests.Support;
using Serilog.Events;
+using Serilog.Parsing;
using Xunit;
namespace Seq.Syntax.Tests.Expressions;
@@ -12,7 +13,7 @@ namespace Seq.Syntax.Tests.Expressions;
public class ExpressionEvaluationTests
{
public static IEnumerable