Skip to content

Commit c12e7c4

Browse files
Copilotrnwoodweb-flow
authored
feat: add deliver to stdout feature for automated testing and CI/CD integration (#1909)
* Initial plan * feat: add deliver to stdout feature with configurable mailboxes and exit after messages Co-authored-by: rnwood <1327895+rnwood@users.noreply.github.com> * docs: add documentation for deliver to stdout feature Co-authored-by: rnwood <1327895+rnwood@users.noreply.github.com> * Document --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rnwood <1327895+rnwood@users.noreply.github.com> Co-authored-by: smtp4dev-automation <noreply@github.com>
1 parent 2d7228f commit c12e7c4

8 files changed

Lines changed: 273 additions & 6 deletions

File tree

Rnwood.Smtp4dev/CommandLineParser.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ public static MapOptions<CommandLineOptions> TryParseCommandLine(IEnumerable<str
7070
{ "sslprotocols=", "Specifies the SSL/TLS protocol version(s) that will be allowed. Separate with commas. See https://learn.microsoft.com/en-us/dotnet/api/system.security.authentication.sslprotocols?view=net-9.0", data => map.Add(data, x => x.ServerOptions.SslProtocols) },
7171
{ "tlsciphersuites=", "Specifies the TLS cipher suites to be allowed. Not supported on Windows. Separate with commas. See https://learn.microsoft.com/en-us/dotnet/api/system.net.security.tlsciphersuite?view=net-9.0", data => map.Add(data, x => x.ServerOptions.TlsCipherSuites) },
7272
{ "HtmlValidateConfigfile=", "Defines path to a config file used for HTML validation. See https://html-validate.org/usage/index.html#configuration", data => map.Add(File.ReadAllText(data), x => x.ServerOptions.HtmlValidateConfig) },
73-
{ "maxmessagesize=", "Defines the maximum message size in bytes accepted by the SMTP server", data => map.Add(data, x => x.ServerOptions.MaxMessageSize) }
73+
{ "maxmessagesize=", "Defines the maximum message size in bytes accepted by the SMTP server", data => map.Add(data, x => x.ServerOptions.MaxMessageSize) },
74+
{ "delivertostdout=", "Specifies mailboxes (comma-separated) or '*' to output received raw message content to stdout", data => map.Add(data, x => x.ServerOptions.DeliverToStdout) },
75+
{ "exitafter=", "Specifies the number of messages to receive before exiting the application (used with delivertostdout)", data => map.Add(data, x => x.ServerOptions.ExitAfterMessages) }
7476
};
7577

7678
if (!isDesktopApp)
@@ -112,9 +114,9 @@ public static MapOptions<CommandLineOptions> TryParseCommandLine(IEnumerable<str
112114
}
113115
else
114116
{
115-
Console.WriteLine();
116-
Console.WriteLine(" > For help use argument --help");
117-
Console.WriteLine();
117+
Console.Error.WriteLine();
118+
Console.Error.WriteLine(" > For help use argument --help");
119+
Console.Error.WriteLine();
118120
}
119121

120122
return map;

Rnwood.Smtp4dev/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ private static IHost BuildWebHost(string[] args, CommandLineOptions cmdLineOptio
184184

185185
if (cmdLineOptions.DebugSettings)
186186
{
187-
Console.WriteLine(JsonSerializer.Serialize(new SettingsDebugInfo
187+
Console.Error.WriteLine(JsonSerializer.Serialize(new SettingsDebugInfo
188188
{
189189
CmdLineArgs = Environment.GetCommandLineArgs(),
190190
CmdLineOptions = cmdLineOptions,

Rnwood.Smtp4dev/Server/Settings/ServerOptions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ public record ServerOptions
8787
public bool ValidateBareLineFeed { get; set; } = false;
8888

8989
public bool Pop3SecureConnectionRequired { get; set; } = false;
90+
91+
public string DeliverToStdout { get; set; } = "";
92+
93+
public int? ExitAfterMessages { get; set; }
9094
}
9195

9296
}

Rnwood.Smtp4dev/Server/Settings/ServerOptionsSource.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,9 @@ public record ServerOptionsSource
7777
public bool? DisableHtmlCompatibilityCheck { get; set; }
7878

7979
public long? MaxMessageSize { get; set; }
80+
81+
public string DeliverToStdout { get; set; }
82+
83+
public int? ExitAfterMessages { get; set; }
8084
}
8185
}

Rnwood.Smtp4dev/Server/Smtp4devServer.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,28 @@ private ILookup<MailboxOptions, string> GetTargetMailboxes(IEnumerable<string> r
565565
return targetMailboxesWithMatchedRecipient.ToLookup(t => t.Item1, t=> t.Item2);
566566
}
567567

568+
private bool ShouldDeliverToStdout(string mailboxName)
569+
{
570+
var deliverToStdout = serverOptions.CurrentValue.DeliverToStdout;
571+
572+
if (string.IsNullOrWhiteSpace(deliverToStdout))
573+
{
574+
return false;
575+
}
576+
577+
// If "*", deliver all mailboxes to stdout
578+
if (deliverToStdout.Trim() == "*")
579+
{
580+
return true;
581+
}
582+
583+
// Check if mailbox name is in the comma-separated list
584+
var mailboxNames = deliverToStdout.Split(',', StringSplitOptions.RemoveEmptyEntries)
585+
.Select(m => m.Trim());
586+
587+
return mailboxNames.Contains(mailboxName, StringComparer.OrdinalIgnoreCase);
588+
}
589+
568590
void ProcessMessage(Message message, ISession session, IGrouping<MailboxOptions, string> targetMailboxWithRecipients)
569591
{
570592
log.Information("Processing received message for mailbox '{mailbox}' for recipients '{recipients}'", targetMailboxWithRecipients.Key.Name, targetMailboxWithRecipients.ToArray());
@@ -613,6 +635,44 @@ void ProcessMessage(Message message, ISession session, IGrouping<MailboxOptions,
613635
dbContext.SaveChanges();
614636
notificationsHub.OnMessagesChanged(targetMailboxWithRecipients.Key.Name).Wait();
615637
log.Information("Processing received message DONE");
638+
639+
// Deliver to stdout if configured for this mailbox
640+
if (ShouldDeliverToStdout(targetMailboxWithRecipients.Key.Name))
641+
{
642+
lock (stdoutLock)
643+
{
644+
// Output the raw message content to stdout
645+
// Use a delimiter that is very unlikely to appear in email messages
646+
Console.WriteLine("--- BEGIN SMTP4DEV MESSAGE ---");
647+
if (message.Data != null)
648+
{
649+
// Write raw message bytes to stdout
650+
using (var stdout = Console.OpenStandardOutput())
651+
{
652+
stdout.Write(message.Data, 0, message.Data.Length);
653+
stdout.Flush();
654+
}
655+
Console.WriteLine(); // Ensure delimiter is on new line
656+
}
657+
Console.WriteLine("--- END SMTP4DEV MESSAGE ---");
658+
Console.Out.Flush();
659+
660+
messagesDeliveredToStdoutCount++;
661+
662+
// Check if we should exit after delivering this message
663+
var exitAfter = serverOptions.CurrentValue.ExitAfterMessages;
664+
if (exitAfter.HasValue && messagesDeliveredToStdoutCount >= exitAfter.Value)
665+
{
666+
log.Information("Delivered {count} messages to stdout. Exiting as configured by ExitAfterMessages.", messagesDeliveredToStdoutCount);
667+
// Schedule application exit on a background thread to allow this method to complete
668+
Task.Run(async () =>
669+
{
670+
await Task.Delay(100); // Give time for logs to flush
671+
Environment.Exit(0);
672+
});
673+
}
674+
}
675+
}
616676
}
617677

618678
public RelayResult TryRelayMessage(Message message, MailboxAddress[] overrideRecipients)
@@ -706,6 +766,8 @@ private void TrimSessions(Smtp4devDbContext dbContext)
706766
private readonly NotificationsHub notificationsHub;
707767
private readonly IServiceScopeFactory serviceScopeFactory;
708768
private Settings.ServerOptions lastStartOptions;
769+
private int messagesDeliveredToStdoutCount = 0;
770+
private readonly object stdoutLock = new object();
709771

710772
public Exception Exception { get; private set; }
711773

Rnwood.Smtp4dev/appsettings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@
354354
{
355355
"Name": "Console",
356356
"args": {
357-
"outputTemplate": "{Message:lj}{NewLine}{Exception}"
357+
"outputTemplate": "{Message:lj}{NewLine}{Exception}",
358+
"standardErrorFromLevel": "Verbose"
358359
}
359360
}
360361
],

docs/Configuration.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,79 @@ If you want to customize the default mailbox behavior, you can explicitly config
161161

162162
**Regex not working**: Ensure the pattern is surrounded by forward slashes (`/pattern/`) and test the regex with an online regex tester.
163163

164+
## Deliver to Stdout Feature
165+
166+
smtp4dev can output raw message content to stdout for automated processing or testing scenarios. This feature is useful for CI/CD pipelines, automated testing, or integrating with other tools.
167+
168+
### Configuration Options
169+
170+
#### DeliverToStdout
171+
172+
Specifies which mailboxes should have their messages output to stdout:
173+
174+
- **`*`** - Deliver all messages from all mailboxes to stdout
175+
- **`mailbox1,mailbox2`** - Deliver only messages from specified mailboxes (comma-separated)
176+
- **Empty string** (default) - Disable stdout delivery
177+
178+
**Command Line**: `--delivertostdout="*"` or `--delivertostdout="Sales,Support"`
179+
180+
**Configuration File**:
181+
```json
182+
{
183+
"ServerOptions": {
184+
"DeliverToStdout": "*"
185+
}
186+
}
187+
```
188+
189+
#### ExitAfterMessages
190+
191+
Automatically exit the application after delivering a specified number of messages to stdout. Useful for automated testing scenarios.
192+
193+
**Command Line**: `--exitafter=5`
194+
195+
**Configuration File**:
196+
```json
197+
{
198+
"ServerOptions": {
199+
"ExitAfterMessages": 5
200+
}
201+
}
202+
```
203+
204+
### Message Format
205+
206+
Messages delivered to stdout are wrapped with delimiters to separate multiple messages:
207+
208+
```
209+
--- BEGIN SMTP4DEV MESSAGE ---
210+
<raw message content>
211+
--- END SMTP4DEV MESSAGE ---
212+
```
213+
214+
### Logging Behavior
215+
216+
When using deliver to stdout, all diagnostic and application logs are automatically redirected to stderr, ensuring stdout contains only message content.
217+
218+
### Example Usage
219+
220+
**Capture all messages and exit after 10:**
221+
```bash
222+
smtp4dev --delivertostdout="*" --exitafter=10 --smtpport=2525 > messages.txt 2> logs.txt
223+
```
224+
225+
**Capture only Sales mailbox messages:**
226+
```bash
227+
smtp4dev --mailbox="Sales=*sales*@*" --delivertostdout="Sales" --smtpport=2525 > sales-messages.txt
228+
```
229+
230+
**Use in CI/CD pipeline:**
231+
```bash
232+
# Start smtp4dev in background, send test emails, capture output
233+
smtp4dev --delivertostdout="*" --exitafter=3 --smtpport=2525 --imapport=0 --pop3port=0 > captured-emails.txt 2>&1 &
234+
# ... send test emails ...
235+
# Process captured-emails.txt
236+
```
237+
238+
164239
**Want messages in multiple mailboxes**: This is not supported due to "first match wins" logic. Consider using a single mailbox with multiple recipient patterns instead.

docs/Testing.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,125 @@ public class SmtpTestcontainersTests : IAsyncLifetime
406406
await smtp4devContainer.DisposeAsync();
407407
}
408408
}
409+
410+
## Capturing raw messages via stdout (--delivertostdout)
411+
412+
For CI and automated tests you can have smtp4dev write the raw message bytes it receives to stdout so your test harness can capture and inspect them directly.
413+
414+
How it works
415+
- Use `--delivertostdout` to specify either `*` (all mailboxes) or a comma-separated list of mailbox names (case-insensitive). The server checks the configured mailbox name for each delivered message and, when matched, writes the message's raw bytes to stdout.
416+
- Each message written to stdout is wrapped with clear ASCII delimiters to make parsing simple:
417+
418+
```
419+
--- BEGIN SMTP4DEV MESSAGE ---
420+
<raw message content (bytes)>
421+
--- END SMTP4DEV MESSAGE ---
422+
```
423+
424+
- The server writes the message bytes directly to the stdout stream (so attachments and binary content are preserved). The delimiters are written using normal Console.WriteLine calls.
425+
- When `--delivertostdout` is used the application logs and diagnostics should be captured from stderr (the default Serilog console configuration writes application logs to stderr), ensuring stdout contains only message content.
426+
427+
Exit after N messages
428+
- Use `--exitafter=<n>` to make smtp4dev automatically exit once it has delivered `n` messages to stdout. This is intended for short-lived test runs where you start smtp4dev, send test emails, and wait for the process to exit.
429+
- The server tracks the number of messages written to stdout and, when the configured threshold is reached, schedules an orderly exit (Environment.Exit(0)) after a short delay to allow output to flush.
430+
431+
Important notes and examples
432+
- `--delivertostdout` accepts `*` or a list of mailbox names: `--delivertostdout="*"` or `--delivertostdout="Sales,Support"`.
433+
- `--exitafter` only counts messages that were actually delivered to stdout (i.e. matched by `--delivertostdout`).
434+
- Because the raw bytes are written to stdout, redirect stdout to a file to capture messages and keep stderr for logs:
435+
436+
```powershell
437+
# Capture all messages and logs separately (Windows PowerShell syntax)
438+
dotnet run --project Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj --delivertostdout="*" --exitafter=3 --smtpport=2525 --imapport=0 --pop3port=0 > captured-emails.txt 2> logs.txt
439+
```
440+
441+
- Example for a single mailbox only:
442+
443+
```powershell
444+
dotnet run --project Rnwood.Smtp4dev/Rnwood.Smtp4dev.csproj --mailbox="Sales=*sales*@*" --delivertostdout="Sales" --smtpport=2525 > sales-messages.txt 2> logs.txt
445+
```
446+
447+
- When parsing the captured file, split on the delimiter lines (`--- BEGIN SMTP4DEV MESSAGE ---` / `--- END SMTP4DEV MESSAGE ---`) to extract each message's raw content.
448+
- The server uses a short delay before exiting to allow output buffers to flush; exit code will be `0` on normal shutdown triggered by `--exitafter`.
449+
450+
Concurrency and reliability
451+
- Writing to stdout is synchronized internally to avoid interleaving when multiple messages arrive concurrently.
452+
- The count used by `--exitafter` is updated under the same lock that writes the message, so the exit threshold is reliable even under concurrent deliveries.
453+
454+
Use cases
455+
- CI jobs that send a fixed number of test emails and then examine the captured files
456+
- Scripts that need to extract raw MIME messages for downstream processing or assertions
457+
458+
Parsing captured output
459+
-----------------------
460+
461+
Here are a few small examples that show how to remove the delimiters and split the captured output into individual raw messages. Each example writes each message to a separate `.eml` file.
462+
463+
PowerShell (simple, text-oriented):
464+
465+
```powershell
466+
# Reads the captured file as text and splits on the begin delimiter.
467+
$content = Get-Content -Raw -Encoding UTF8 'captured-emails.txt'
468+
$parts = $content -split '--- BEGIN SMTP4DEV MESSAGE ---' | Where-Object { $_.Trim() -ne '' }
469+
$i = 1
470+
foreach ($part in $parts) {
471+
$msg = ($part -split '--- END SMTP4DEV MESSAGE ---')[0].TrimStart("`r`n")
472+
[System.IO.File]::WriteAllText("message_$i.eml", $msg, [System.Text.Encoding]::UTF8)
473+
$i++
474+
}
475+
476+
# Note: this approach treats the captured file as UTF-8 text. For fully binary-safe handling use the Python or Node.js examples below.
477+
```
478+
479+
Python (binary-safe):
480+
481+
```python
482+
begin = b"--- BEGIN SMTP4DEV MESSAGE ---\n"
483+
end = b"--- END SMTP4DEV MESSAGE ---\n"
484+
485+
with open('captured-emails.txt', 'rb') as f:
486+
data = f.read()
487+
488+
messages = []
489+
start = 0
490+
while True:
491+
i = data.find(begin, start)
492+
if i == -1:
493+
break
494+
j = data.find(end, i + len(begin))
495+
if j == -1:
496+
break
497+
msg = data[i + len(begin):j]
498+
messages.append(msg)
499+
start = j + len(end)
500+
501+
for idx, msg in enumerate(messages, start=1):
502+
with open(f'message_{idx}.eml', 'wb') as out:
503+
out.write(msg)
504+
```
505+
506+
Node.js (binary-safe):
507+
508+
```javascript
509+
const fs = require('fs');
510+
const begin = Buffer.from('--- BEGIN SMTP4DEV MESSAGE ---\n');
511+
const end = Buffer.from('--- END SMTP4DEV MESSAGE ---\n');
512+
const data = fs.readFileSync('captured-emails.txt');
513+
514+
let start = 0;
515+
let idx = 1;
516+
while (true) {
517+
const i = data.indexOf(begin, start);
518+
if (i === -1) break;
519+
const j = data.indexOf(end, i + begin.length);
520+
if (j === -1) break;
521+
const msg = data.slice(i + begin.length, j);
522+
fs.writeFileSync(`message_${idx}.eml`, msg);
523+
idx++;
524+
start = j + end.length;
525+
}
526+
```
527+
409528
}
410529
```
411530

0 commit comments

Comments
 (0)