SharpAutomation is a small library for automation scripts and scheduled jobs. It runs an action with retries and collects the exceptions it throws, turns exceptions into HTML, JSON or log text, appends entries to log files, and sends HTML mail through an SMTP server.
dotnet add package SharpAutomation
Everything is in the SharpAutomation namespace. The package targets net8.0, net9.0 and net10.0 and has no dependencies. .NET 8 leaves Microsoft support on 2026-11-10, and a later release will drop net8.0.
using SharpAutomation;
var errors = new List<Exception>();
int attempts = 0;
bool ok = TryAction.Run(() =>
{
attempts++;
if (attempts < 3) throw new TimeoutException($"Attempt {attempts} timed out");
}, retries: 4, waitBetweenTriesSeconds: 1, _exceptionList: errors);
Console.WriteLine(ok); // True
Console.WriteLine(attempts); // 3
Console.WriteLine(errors.FlattenMessages());
// Attempt 1 timed out
// Attempt 2 timed outTryAction.RunAsync does the same on the thread pool and waits with Task.Delay instead of blocking the thread:
using SharpAutomation;
var errors = new List<Exception>();
bool copied = await TryAction.RunAsync(
() => File.Copy("report.csv", "archive.csv", overwrite: true),
retries: 2,
waitBetweenTriesSeconds: 1,
_exceptionList: errors);
Console.WriteLine(copied); // True, or False after three failed attempts
Console.WriteLine(errors.Count); // 0, or one exception per failed attemptAn async lambda, or any lambda that returns a task, goes to the RunAsync overload for a Func<Task>. It awaits each attempt, catches what the task ends with, and takes a cancellation token:
using SharpAutomation;
var errors = new List<Exception>();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5));
bool saved = await TryAction.RunAsync(async () =>
{
await File.WriteAllTextAsync("status.txt", "Import finished", timeout.Token);
}, retries: 3, waitBetweenTriesSeconds: 10, _exceptionList: errors, cancellationToken: timeout.Token);
Console.WriteLine(saved); // Trueusing SharpAutomation;
var errors = new List<Exception>
{
new TimeoutException("The server did not answer"),
new IOException("The disk is full"),
new TimeoutException("The server did not answer again"),
};
Console.WriteLine(errors.ContainsType<TimeoutException>()); // True
Console.WriteLine(errors.ContainsType<Exception>()); // False: the type must match exactly
Console.WriteLine(errors.FilterByType<TimeoutException>().Count); // 2
foreach (var (typeName, count) in errors.CountByType())
{
Console.WriteLine($"{typeName}: {count}");
}
// System.TimeoutException: 2
// System.IO.IOException: 1
Console.WriteLine(errors.FlattenMessages());
// The server did not answer
// The disk is full
// The server did not answer again
Console.WriteLine(new IOException("The disk is full").ToJSON());
// [
// {
// "Message": "The disk is full",
// "StackTrace": null,
// "TypeName": "System.IO.IOException"
// }
// ]using SharpAutomation;
var errors = new List<Exception>();
TryAction.Run(() => File.ReadAllText("missing.txt"), _exceptionList: errors);
await errors.ToLogAsync("errors.log"); // appends one block per exception
await errors[0].ToLogAsync("errors.log"); // the same for a single exception
await Log.AppendEntryAsync("Import finished", "import.log");
Console.WriteLine(File.ReadAllText("import.log"));
// Timestamp: 10/2/2026 3:04:05 PM (local time, in the current culture)
// Entry: Import finished
//
// ----------------------------------------------------------------------------using SharpAutomation;
var errors = new List<Exception>();
TryAction.Run(() => File.ReadAllText("missing.txt"), _exceptionList: errors);
var smtp = new SMTPServerConfiguration("smtp.example.com", "[email protected]")
{
Port = 25, // the default
EnableSsl = true, // STARTTLS, with the server's certificate checked
};
var mail = new NotificationConfiguration(
toAddresses: ["[email protected]"],
subject: "Nightly import failed",
htmlBody: "<p>The nightly import stopped.</p>" + errors.ToHTML(),
ccAddresses: ["[email protected]"],
attachments: ["errors.log"],
replyTo: ["[email protected]"]);
mail.SendNotification(smtp); // the same as Notification.Send(smtp, mail)TryAction.Run(action, retries, waitBetweenTriesSeconds, _exceptionList)callsactionup toretries + 1times and returnstrueas soon as one call returns. It catches every exception the action throws, of any type, and adds it to_exceptionListwhen you pass one; without a list the exceptions are dropped. It returnsfalsewhen every attempt threw.- After an attempt that throws,
Runblocks the thread forwaitBetweenTriesSecondsseconds withThread.Sleep, andRunAsyncwaits withTask.Delay, before the next attempt. There is no wait after the last attempt. RunAsync(Action)runs each attempt withTask.Run, on a thread-pool thread.RunAsync(Func<Task>)calls the delegate and awaits its task; the compiler picks it for anasynclambda. ItscancellationTokenstops the retries: once it is cancelled no further attempt starts, a wait ends early, and the returned task ends withOperationCanceledException, which is not added to the list. Code compiled against 1.0.6.3 that passes anasynclambda calls theActionoverload, which does not await it, until it is compiled again.retriesandwaitBetweenTriesSecondsdefault to 0, which means one attempt and no wait. A negative value of either, or a wait above 2,147,483 seconds, throwsArgumentOutOfRangeExceptionbefore the action runs, and a null action throwsArgumentNullException.
ToHTML,ToJSONandToLogAsyncwork on one exception or aList<Exception>, and write the type's full name, theMessageand theStackTraceof each exception. They do not includeInnerException, the inner exceptions of anAggregateException, orData.ToHTMLreturns an HTML fragment, a heading and a table, for the body of a mail. It HTML-encodes the type name, message and stack trace, so a message such as<b>Disk full</b>shows as written, tags and all, and cannot add markup to the mail.ToJSONreturns an indented JSON array of objects withMessage,StackTraceandTypeName.StackTraceisnullfor an exception that was never thrown.System.Text.Jsonescapes<,>and&in the strings.FilterByType<T>()andContainsType<T>()match the exact type: aFileNotFoundExceptionis not counted as anIOException.CountByType()returns a dictionary from each type's full name to its count.FlattenMessages()joins the messages withEnvironment.NewLine.- These methods throw
ArgumentNullExceptionfor a null list or a null exception, andArgumentExceptionfor a list that holds a null exception, before they use any of it.
ToLogAsync(logFilePath)appends one block per exception:Timestamp:,Exception:,Message:andStackTrace:lines, then a line of dashes. An empty list writes nothing. Without a path it appends toExceptions.loginAppDomain.CurrentDomain.BaseDirectory, the application's folder.Log.AppendEntryAsync(entry, logFilePath, cancellationToken)appends aTimestamp:line, anEntry:line and a line of dashes, and returns a task to await.Log.WriteEntryAsync(entry, logFilePath)does the same synchronously: despite its name it returnsvoidonce the entry is written, and throws to the caller if it cannot be. Without a path both create a new file in the application's folder for each second, named from the local time asyyyyMMdd_HHmmss.logwith a 24-hour clock, so each call usually writes its own file.- A line break in an entry, a message or a stack trace is followed by two spaces in the file. Only the library starts a line at the first column, so logged text cannot pass for a separator or another entry.
- A relative path is resolved against the current directory, not the application's folder. The file is created when it does not exist, but its folder is not. Files are written as UTF-8 with a byte order mark.
- Timestamps are the local time, formatted in the current culture.
- Nothing rotates or deletes log files; they grow until you remove them.
- Writes to one file from the same process take turns. The file is shared only with readers while it is written; when another process has it open, a write waits and tries again for about two seconds, then throws
IOException.
Notification.Send(smtp, mail)andmail.SendNotification(smtp)send one message throughSystem.Net.Mail.SmtpClienttoSMTPServerAddressonPort, 25 unless you set it. There is no setting for a user name and password, so the server has to accept mail from the machine without them.- With
EnableSsl = truethe connection switches to TLS with STARTTLS before anything is sent. The server's certificate must be trusted by the machine and matchSMTPServerAddress; a server that does not offer STARTTLS ends the send withSmtpException, and a certificate that fails the check withAuthenticationException, before any address or content is sent.EnableSslis off by default, as it was in 1.0.6.3, and then the message, its attachments and any exception details in it cross the network unencrypted. Turn it on wherever the server offers STARTTLS. - The message is HTML (
IsBodyHtml), fromFromAddress, to every address inToAddresses, withCCAddressesas CC (empty strings are skipped),ReplyToas reply-to addresses, and each path inAttachmentsattached as a file. Sendcloses the attachment files and ends the session withQUITbefore it returns, whether the mail went or not, so the files can be moved or deleted straight away.SendthrowsArgumentNullExceptionfor a null configuration or a nullToAddresses, andArgumentExceptionwhenToAddressesis empty. An address that is not a mail address throwsFormatException, an attachment that cannot be read throws theIOExceptionfrom opening it, and a failed send throwsSmtpException.SmtpClientgives up on a server that does not answer after 100 seconds.System.Net.Mailrefuses a subject or an address that holds a line break, withArgumentExceptionorFormatException, so mail headers cannot be added through them.NotificationConfigurationkeeps the lists you pass rather than copies, so adding toToAddressesafter construction adds a recipient.
- Exception messages and stack traces can hold file paths, server names, user names, and sometimes connection strings or other secrets.
ToHTML,ToJSONand the log methods copy them unchanged, so send the mail and keep the log files only where those details may be read. - An attachment path is read with the permissions of the process. Do not build one from input you do not control.
NuGet icon "SharpAutomation.png" designed by user Itim2101 on Freepik Company S.L. https://support.freepik.com/