commit c963c4d9e979a021137d9e92cfd16edf64cafccc Author: administrator Date: Sun Apr 19 22:50:54 2026 +0200 Initial commit clean history diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..997839b --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Build-Ausgaben +bin/ +obj/ +publish/ + +# Skripte aus publish/ im Root behalten +!install-service.ps1 +!uninstall-service.ps1 + +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docobj + +# Logs +logs/ +*.log + +# Secrets / Config mit Zugangsdaten +**/appsettings.json +**/appsettings.*.json + +# Temp +*.tmp +*.temp diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e4c07a6 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + + $([System.String]::Copy('1.0.0')) + + + + + + + + + $(GitTagRaw.TrimStart('v').Trim()) + $(GitTagClean) + $(GitTagClean).0 + $(GitTagClean).0 + + + + diff --git a/MailPrint.sln b/MailPrint.sln new file mode 100644 index 0000000..825db8d --- /dev/null +++ b/MailPrint.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailPrint", "MailPrint\MailPrint.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailPrintConfig", "MailPrintConfig\MailPrintConfig.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/MailPrint/ApiKeyMiddleware.cs b/MailPrint/ApiKeyMiddleware.cs new file mode 100644 index 0000000..69fe41e --- /dev/null +++ b/MailPrint/ApiKeyMiddleware.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Options; + +namespace MailPrint; + +/// +/// Schützt alle /api/* Routen mit einem statischen API-Key. +/// Header: X-Api-Key: +/// Wenn kein Key konfiguriert ist, wird die Middleware übersprungen. +/// +public class ApiKeyMiddleware +{ + private const string HeaderName = "X-Api-Key"; + private readonly RequestDelegate _next; + private readonly string _apiKey; + private readonly ILogger _logger; + + public ApiKeyMiddleware(RequestDelegate next, IOptions options, ILogger logger) + { + _next = next; + _apiKey = options.Value.WebApi.ApiKey; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + // Kein API-Key konfiguriert → durchlassen (Entwicklung / lokales Netz) + if (string.IsNullOrEmpty(_apiKey)) + { + await _next(context); + return; + } + + // Swagger UI immer erlauben + if (context.Request.Path.StartsWithSegments("/swagger")) + { + await _next(context); + return; + } + + if (!context.Request.Headers.TryGetValue(HeaderName, out var provided) || + provided != _apiKey) + { + _logger.LogWarning("Ungültiger API-Key von {IP}", context.Connection.RemoteIpAddress); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new { error = "Ungültiger oder fehlender API-Key." }); + return; + } + + await _next(context); + } +} diff --git a/MailPrint/Controllers/PrintController.cs b/MailPrint/Controllers/PrintController.cs new file mode 100644 index 0000000..96cf208 --- /dev/null +++ b/MailPrint/Controllers/PrintController.cs @@ -0,0 +1,131 @@ +using MailPrint.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; + +namespace MailPrint.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class PrintController : ControllerBase +{ + private readonly PrintService _printService; + private readonly MailPrintOptions _options; + private readonly ILogger _logger; + + public PrintController(PrintService printService, IOptions options, ILogger logger) + { + _printService = printService; + _options = options.Value; + _logger = logger; + } + + /// + /// PDF hochladen und drucken. + /// POST /api/print/upload + /// Form-Data: file, printer (opt), paperSource (opt), copies (opt) + /// + [HttpPost("upload")] + [Consumes("multipart/form-data")] + public async Task PrintUpload( + IFormFile file, + [FromForm] string? printer = null, + [FromForm] string? paperSource = null, + [FromForm] int copies = 0) + { + if (file is null || file.Length == 0) + return BadRequest(new { error = "Keine Datei." }); + + var ext = Path.GetExtension(file.FileName).ToLower(); + if (!_options.AllowedExtensions.Contains(ext)) + return BadRequest(new { error = $"Dateityp '{ext}' nicht erlaubt." }); + + Directory.CreateDirectory(_options.TempDirectory); + var tempFile = Path.Combine(_options.TempDirectory, $"{Guid.NewGuid()}{ext}"); + + try + { + await using (var fs = System.IO.File.Create(tempFile)) + await file.CopyToAsync(fs); + + _printService.PrintPdf(new PrintJob + { + FilePath = tempFile, + MailSubject = file.FileName, + MailFrom = "WebAPI", + PrinterOverride = printer, + PaperSourceOverride = paperSource, + CopiesOverride = copies > 0 ? copies : null + }); + + return Ok(new { success = true, file = file.FileName, printer, paperSource }); + } + catch (Exception ex) + { + _logger.LogError(ex, "WebAPI Druckfehler: {File}", file.FileName); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// PDF per URL drucken. + /// POST /api/print/url + /// + [HttpPost("url")] + public async Task PrintUrl([FromBody] PrintUrlRequest request) + { + if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)) + return BadRequest(new { error = "Ungültige URL." }); + + Directory.CreateDirectory(_options.TempDirectory); + var tempFile = Path.Combine(_options.TempDirectory, $"{Guid.NewGuid()}.pdf"); + + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + await System.IO.File.WriteAllBytesAsync(tempFile, await http.GetByteArrayAsync(uri)); + + _printService.PrintPdf(new PrintJob + { + FilePath = tempFile, + MailSubject = uri.Segments.LastOrDefault() ?? "download.pdf", + MailFrom = "WebAPI/URL", + PrinterOverride = request.Printer, + PaperSourceOverride = request.PaperSource, + CopiesOverride = request.Copies > 0 ? request.Copies : null + }); + + return Ok(new { success = true, url = request.Url, printer = request.Printer, paperSource = request.PaperSource }); + } + catch (Exception ex) + { + _logger.LogError(ex, "WebAPI URL-Druckfehler: {Url}", request.Url); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Alle Drucker mit Papierfächern. + /// GET /api/print/printers + /// + [HttpGet("printers")] + public IActionResult GetPrinters() + { + var infos = _printService.GetPrinterInfos(); + var defaultPrinter = _options.PrinterProfiles.FirstOrDefault()?.PrinterName + ?? infos.FirstOrDefault()?.Name; + return Ok(new { defaultPrinter, profiles = _options.PrinterProfiles, printers = infos }); + } + + [HttpGet("health")] + public IActionResult Health() => + Ok(new { status = "ok", service = "MailPrint", timestamp = DateTime.UtcNow }); +} + +public class PrintUrlRequest +{ + [Required] public string Url { get; set; } = ""; + public string? Printer { get; set; } + public string? PaperSource { get; set; } + public int Copies { get; set; } = 0; +} diff --git a/MailPrint/MailPrint.csproj b/MailPrint/MailPrint.csproj new file mode 100644 index 0000000..5496211 --- /dev/null +++ b/MailPrint/MailPrint.csproj @@ -0,0 +1,25 @@ + + + + net10.0-windows + enable + enable + mailprint-service + MailPrint + MailPrint + Exe + + + + + + + + + + + + + + + diff --git a/MailPrint/MailPrintOptions.cs b/MailPrint/MailPrintOptions.cs new file mode 100644 index 0000000..9288f01 --- /dev/null +++ b/MailPrint/MailPrintOptions.cs @@ -0,0 +1,72 @@ +namespace MailPrint; + +public class MailPrintOptions +{ + public int PollIntervalSeconds { get; set; } = 60; + public string SubjectFilter { get; set; } = ""; + public bool DeleteAfterPrint { get; set; } = true; + public bool MarkAsRead { get; set; } = true; + public string SumatraPath { get; set; } = ""; + public string TempDirectory { get; set; } = Path.Combine(Path.GetTempPath(), "MailPrint"); + public List AllowedExtensions { get; set; } = new() { ".pdf" }; + + /// Drucker-Profile: Name → Drucker + Fach + Kopien + public List PrinterProfiles { get; set; } = new(); + + /// Postfächer: jedes hat eigene Credentials + zeigt auf ein PrinterProfile + public List Accounts { get; set; } = new(); + + /// Global – wird verwendet wenn das Profil keine eigene Liste hat. + public List AllowedSenders { get; set; } = new(); + public List BlockedSenders { get; set; } = new(); + + public WebApiOptions WebApi { get; set; } = new(); + + /// Ordner-Überwachung: PDFs in diesen Ordnern werden automatisch gedruckt. + public List FolderWatchers { get; set; } = new(); +} + +public class PrinterProfile +{ + public string Name { get; set; } = ""; + public string PrinterName { get; set; } = ""; + public string PaperSource { get; set; } = ""; + public int Copies { get; set; } = 1; + /// none | long | short + public string Duplex { get; set; } = "none"; + public List AllowedSenders { get; set; } = new(); + public List BlockedSenders { get; set; } = new(); +} + +public class MailAccount +{ + public string Name { get; set; } = ""; + public string Protocol { get; set; } = "IMAP"; + public string Host { get; set; } = ""; + public int Port { get; set; } = 993; + public bool UseSsl { get; set; } = true; + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + public string Folder { get; set; } = "INBOX"; + /// Name eines PrinterProfile – leer = erstes Profil + public string PrinterProfileName { get; set; } = ""; +} + +public class WebApiOptions +{ + public int Port { get; set; } = 5100; + public bool BindAllInterfaces { get; set; } = false; + public string ApiKey { get; set; } = ""; +} + +public class FolderWatcher +{ + public string Name { get; set; } = ""; + /// Zu überwachender Ordner (vollständiger Pfad) + public string Path { get; set; } = ""; + /// Auch Unterordner überwachen + public bool IncludeSubfolders { get; set; } = false; + /// Datei nach erfolgreichem Druck löschen + public bool DeleteAfterPrint { get; set; } = true; + public string PrinterProfileName { get; set; } = ""; +} diff --git a/MailPrint/MailPrintWorker.cs b/MailPrint/MailPrintWorker.cs new file mode 100644 index 0000000..bfe5374 --- /dev/null +++ b/MailPrint/MailPrintWorker.cs @@ -0,0 +1,70 @@ +using MailPrint.Services; +using Microsoft.Extensions.Options; + +namespace MailPrint; + +public class MailPrintWorker : BackgroundService +{ + private readonly ILogger _logger; + private readonly MailFetchService _mailService; + private readonly PrintService _printService; + private readonly FolderWatcherService _folderService; + private readonly MailPrintOptions _options; + + public MailPrintWorker( + ILogger logger, + MailFetchService mailService, + PrintService printService, + FolderWatcherService folderService, + IOptions options) + { + _logger = logger; + _mailService = mailService; + _printService = printService; + _folderService = folderService; + _options = options.Value; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + // Ordner-Überwachung starten (event-basiert, läuft dauerhaft) + _folderService.Start(); + + if (_options.Accounts.Count == 0) + { + _logger.LogWarning("Keine Postfächer konfiguriert – Mail-Polling deaktiviert."); + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return; + } + + _logger.LogInformation("{Count} Postfach/Postfächer konfiguriert, Intervall: {Interval}s", + _options.Accounts.Count, _options.PollIntervalSeconds); + + var tasks = _options.Accounts.Select(account => PollAccountAsync(account, ct)); + await Task.WhenAll(tasks); + } + + private async Task PollAccountAsync(MailAccount account, CancellationToken ct) + { + _logger.LogInformation("Starte Polling für [{Account}] ({Protocol}://{Host})", + account.Name, account.Protocol, account.Host); + + while (!ct.IsCancellationRequested) + { + try + { + var jobs = await _mailService.FetchJobsForAccountAsync(account, ct); + foreach (var job in jobs) + { + if (ct.IsCancellationRequested) break; + try { _printService.PrintPdf(job); } + catch (Exception ex) { _logger.LogError(ex, "[{Account}] Druckfehler", account.Name); } + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _logger.LogError(ex, "[{Account}] Fehler beim Mail-Abruf", account.Name); } + + await Task.Delay(TimeSpan.FromSeconds(_options.PollIntervalSeconds), ct); + } + } +} diff --git a/MailPrint/Program.cs b/MailPrint/Program.cs new file mode 100644 index 0000000..3e3c5a8 --- /dev/null +++ b/MailPrint/Program.cs @@ -0,0 +1,74 @@ +using MailPrint; +using MailPrint.Services; +using Serilog; +using Serilog.Events; + +// Working directory auf EXE-Verzeichnis setzen +Directory.SetCurrentDirectory(AppContext.BaseDirectory); + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .WriteTo.File( + Path.Combine(AppContext.BaseDirectory, "logs", "mailprint-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 30) + .CreateLogger(); + +try +{ + Log.Information("MailPrint gestartet. Beenden mit Ctrl+C."); + + var builder = WebApplication.CreateBuilder(args); + + // Als Windows Service UND als Konsole nutzbar + builder.Host.UseWindowsService(options => options.ServiceName = "MailPrint"); + + builder.Host.UseSerilog(); + + builder.Configuration + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true) + .AddEnvironmentVariables(); + + builder.WebHost.ConfigureKestrel(kestrel => + { + var port = builder.Configuration.GetValue("MailPrint:WebApi:Port", 5100); + var bindAll = builder.Configuration.GetValue("MailPrint:WebApi:BindAllInterfaces", false); + if (bindAll) + kestrel.ListenAnyIP(port); + else + kestrel.ListenLocalhost(port); + }); + + builder.Services.Configure(builder.Configuration.GetSection("MailPrint")); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(c => + c.SwaggerDoc("v1", new() { Title = "MailPrint API", Version = "v1" })); + + var app = builder.Build(); + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseMiddleware(); + app.MapControllers(); + + await app.RunAsync(); +} +catch (Exception ex) when (ex is not OperationCanceledException) +{ + Log.Fatal(ex, "Unbehandelter Fehler"); + return 1; +} +finally +{ + Log.CloseAndFlush(); +} + +return 0; diff --git a/MailPrint/Properties/launchSettings.json b/MailPrint/Properties/launchSettings.json new file mode 100644 index 0000000..e53de2a --- /dev/null +++ b/MailPrint/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "MailPrint": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:56813;http://localhost:56814" + } + } +} \ No newline at end of file diff --git a/MailPrint/Services/FolderWatcherService.cs b/MailPrint/Services/FolderWatcherService.cs new file mode 100644 index 0000000..4b737e3 --- /dev/null +++ b/MailPrint/Services/FolderWatcherService.cs @@ -0,0 +1,175 @@ +using Microsoft.Extensions.Options; + +namespace MailPrint.Services; + +public class FolderWatcherService : IDisposable +{ + private readonly ILogger _logger; + private readonly PrintService _printService; + private readonly MailPrintOptions _options; + private readonly List _watchers = new(); + private readonly HashSet _printed = new(StringComparer.OrdinalIgnoreCase); + + public FolderWatcherService( + ILogger logger, + PrintService printService, + IOptions options) + { + _logger = logger; + _printService = printService; + _options = options.Value; + } + + public void Start() + { + if (_options.FolderWatchers.Count == 0) + { + _logger.LogInformation("Keine Ordner-Überwachung konfiguriert."); + return; + } + + // Gedruckte Dateien laden (damit nach Neustart nicht nochmal gedruckt wird) + LoadPrintedLog(); + + foreach (var config in _options.FolderWatchers) + { + if (string.IsNullOrEmpty(config.Path) || !Directory.Exists(config.Path)) + { + _logger.LogWarning("[{Name}] Ordner nicht gefunden: {Path}", config.Name, config.Path); + continue; + } + + // Beim Start bereits vorhandene PDFs drucken + PrintExistingFiles(config); + + var watcher = new FileSystemWatcher(config.Path) + { + Filter = "*.pdf", + IncludeSubdirectories = config.IncludeSubfolders, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite, + EnableRaisingEvents = true + }; + + watcher.Created += (_, e) => OnFileDetected(e.FullPath, config); + watcher.Renamed += (_, e) => + { + if (e.Name?.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) == true) + OnFileDetected(e.FullPath, config); + }; + + _watchers.Add(watcher); + _logger.LogInformation("[{Name}] Überwache: {Path} (Unterordner: {Sub})", + config.Name, config.Path, config.IncludeSubfolders); + } + } + + private void PrintExistingFiles(FolderWatcher config) + { + var files = Directory.GetFiles(config.Path, "*.pdf", + config.IncludeSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); + + if (files.Length == 0) return; + + _logger.LogInformation("[{Name}] {Count} vorhandene PDF(s) gefunden – werden gedruckt.", config.Name, files.Length); + foreach (var file in files) + OnFileDetected(file, config); + } + + private void OnFileDetected(string filePath, FolderWatcher config) + { + // Bereits gedruckte Dateien überspringen (nur relevant wenn DeleteAfterPrint=false) + if (_printed.Contains(filePath)) + { + _logger.LogDebug("[{Name}] Bereits gedruckt, überspringe: {File}", config.Name, filePath); + return; + } + + // Kurz warten bis Datei vollständig geschrieben ist + if (!WaitForFile(filePath)) + { + _logger.LogWarning("[{Name}] Datei nicht lesbar (gesperrt?): {File}", config.Name, filePath); + return; + } + + _logger.LogInformation("[{Name}] Neue Datei: {File}", config.Name, filePath); + + try + { + _printService.PrintPdf(new PrintJob + { + FilePath = filePath, + MailSubject = Path.GetFileName(filePath), + MailFrom = $"Ordner:{config.Name}", + PrinterProfileName = config.PrinterProfileName + }); + + if (config.DeleteAfterPrint) + { + try + { + File.Delete(filePath); + _logger.LogInformation("[{Name}] Gelöscht: {File}", config.Name, filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Name}] Löschen fehlgeschlagen: {File}", config.Name, filePath); + } + } + else + { + // Datei bleibt – merken damit sie nicht erneut gedruckt wird + _printed.Add(filePath); + SavePrintedLog(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Name}] Druckfehler: {File}", config.Name, filePath); + } + } + + private static bool WaitForFile(string path, int timeoutMs = 5000) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < timeoutMs) + { + try + { + using var fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.None); + return true; + } + catch (IOException) + { + Thread.Sleep(200); + } + } + return false; + } + + private static string PrintedLogPath => + Path.Combine(AppContext.BaseDirectory, "folder_printed.log"); + + private void LoadPrintedLog() + { + if (!File.Exists(PrintedLogPath)) return; + foreach (var line in File.ReadAllLines(PrintedLogPath)) + if (!string.IsNullOrWhiteSpace(line)) _printed.Add(line.Trim()); + _logger.LogInformation("Gedruckte-Dateien-Liste geladen: {Count} Einträge", _printed.Count); + } + + private void SavePrintedLog() + { + try { File.WriteAllLines(PrintedLogPath, _printed); } + catch (Exception ex) { _logger.LogWarning(ex, "Gedruckte-Dateien-Liste konnte nicht gespeichert werden"); } + } + + public void Dispose() + { + foreach (var w in _watchers) + { + w.EnableRaisingEvents = false; + w.Dispose(); + } + _watchers.Clear(); + } +} diff --git a/MailPrint/Services/MailFetchService.cs b/MailPrint/Services/MailFetchService.cs new file mode 100644 index 0000000..d80e3b8 --- /dev/null +++ b/MailPrint/Services/MailFetchService.cs @@ -0,0 +1,159 @@ +using MailKit; +using MailKit.Net.Imap; +using MailKit.Net.Pop3; +using MailKit.Search; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace MailPrint.Services; + +public class MailFetchService +{ + private readonly ILogger _logger; + private readonly MailPrintOptions _options; + + public MailFetchService(ILogger logger, IOptions options) + { + _logger = logger; + _options = options.Value; + } + + public async Task> FetchJobsForAccountAsync(MailAccount account, CancellationToken ct) + { + return account.Protocol.ToUpper() == "POP3" + ? await FetchViaPop3Async(account, ct) + : await FetchViaImapAsync(account, ct); + } + + private async Task> FetchViaImapAsync(MailAccount account, CancellationToken ct) + { + var jobs = new List(); + using var client = new ImapClient(); + await client.ConnectAsync(account.Host, account.Port, account.UseSsl, ct); + await client.AuthenticateAsync(account.Username, account.Password, ct); + + var folder = await client.GetFolderAsync(account.Folder, ct); + await folder.OpenAsync(FolderAccess.ReadWrite, ct); + + var uids = await folder.SearchAsync(SearchQuery.NotSeen, ct); + _logger.LogInformation("[{Account}] IMAP: {Count} ungelesene Nachrichten", account.Name, uids.Count); + + foreach (var uid in uids) + { + if (ct.IsCancellationRequested) break; + var message = await folder.GetMessageAsync(uid, ct); + var extracted = ExtractJobs(message, account); + if (extracted.Count > 0) + { + jobs.AddRange(extracted); + if (_options.MarkAsRead) + await folder.AddFlagsAsync(uid, MessageFlags.Seen, true, ct); + if (_options.DeleteAfterPrint) + await folder.AddFlagsAsync(uid, MessageFlags.Deleted, true, ct); + } + } + + if (_options.DeleteAfterPrint) await folder.ExpungeAsync(ct); + await client.DisconnectAsync(true, ct); + return jobs; + } + + private async Task> FetchViaPop3Async(MailAccount account, CancellationToken ct) + { + var jobs = new List(); + using var client = new Pop3Client(); + await client.ConnectAsync(account.Host, account.Port, account.UseSsl, ct); + await client.AuthenticateAsync(account.Username, account.Password, ct); + + int count = await client.GetMessageCountAsync(ct); + _logger.LogInformation("[{Account}] POP3: {Count} Nachrichten", account.Name, count); + + var toDelete = new List(); + for (int i = 0; i < count; i++) + { + if (ct.IsCancellationRequested) break; + var message = await client.GetMessageAsync(i, ct); + var extracted = ExtractJobs(message, account); + if (extracted.Count > 0) + { + jobs.AddRange(extracted); + if (_options.DeleteAfterPrint) toDelete.Add(i); + } + } + + foreach (var idx in toDelete) await client.DeleteMessageAsync(idx, ct); + await client.DisconnectAsync(true, ct); + return jobs; + } + + private List ExtractJobs(MimeMessage message, MailAccount account) + { + var jobs = new List(); + + // Profil für Filter-Lookup + var profile = _options.PrinterProfiles.FirstOrDefault(p => + p.Name.Equals(account.PrinterProfileName, StringComparison.OrdinalIgnoreCase)); + + var from = message.From.Mailboxes.Select(m => m.Address.ToLower()).ToList(); + + // Whitelist: Profil-Liste hat Vorrang, Fallback global + var allowed = profile?.AllowedSenders.Count > 0 + ? profile.AllowedSenders + : _options.AllowedSenders; + if (allowed.Count > 0 && !from.Any(f => allowed.Any(a => a.ToLower() == f))) + { + _logger.LogDebug("[{Account}] Absender {From} nicht in Whitelist", account.Name, string.Join(",", from)); + return jobs; + } + + // Blacklist: Profil-Liste hat Vorrang, Fallback global + var blocked = profile?.BlockedSenders.Count > 0 + ? profile.BlockedSenders + : _options.BlockedSenders; + if (blocked.Count > 0 && from.Any(f => blocked.Any(b => b.ToLower() == f))) + { + _logger.LogDebug("[{Account}] Absender {From} in Blacklist", account.Name, string.Join(",", from)); + return jobs; + } + + if (!string.IsNullOrEmpty(_options.SubjectFilter) && + !message.Subject.Contains(_options.SubjectFilter, StringComparison.OrdinalIgnoreCase)) + return jobs; + + Directory.CreateDirectory(_options.TempDirectory); + + foreach (var attachment in message.Attachments.OfType()) + { + var ext = Path.GetExtension(attachment.FileName ?? "").ToLower(); + if (!_options.AllowedExtensions.Contains(ext)) continue; + + var tempFile = Path.Combine(_options.TempDirectory, $"{Guid.NewGuid()}{ext}"); + using (var stream = File.Create(tempFile)) + attachment.Content.DecodeTo(stream); + + jobs.Add(new PrintJob + { + FilePath = tempFile, + MailSubject = message.Subject, + MailFrom = message.From.ToString(), + PrinterProfileName = account.PrinterProfileName + }); + + _logger.LogInformation("[{Account}] Job: {File}", account.Name, tempFile); + } + + return jobs; + } +} + +public record PrintJob +{ + public string FilePath { get; init; } = ""; + public string MailSubject { get; init; } = ""; + public string MailFrom { get; init; } = ""; + public string PrinterProfileName { get; init; } = ""; + // Direkte Overrides für WebAPI + public string? PrinterOverride { get; init; } + public string? PaperSourceOverride { get; init; } + public int? CopiesOverride { get; init; } +} diff --git a/MailPrint/Services/PrintService.cs b/MailPrint/Services/PrintService.cs new file mode 100644 index 0000000..e8b3492 --- /dev/null +++ b/MailPrint/Services/PrintService.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.Options; +using System.Diagnostics; +using System.Drawing.Printing; + +namespace MailPrint.Services; + +public class PrintService +{ + private readonly ILogger _logger; + private readonly MailPrintOptions _options; + + private static readonly string[] SumatraCandidates = + [ + @"C:\Program Files\SumatraPDF\SumatraPDF.exe", + @"C:\Program Files (x86)\SumatraPDF\SumatraPDF.exe", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SumatraPDF", "SumatraPDF.exe"), + Path.Combine(AppContext.BaseDirectory, "SumatraPDF.exe"), + ]; + + public PrintService(ILogger logger, IOptions options) + { + _logger = logger; + _options = options.Value; + } + + public void PrintPdf(PrintJob job) + { + // Profil auflösen: direkte Overrides > benanntes Profil > erstes Profil > Defaults + var profile = ResolveProfile(job); + var printerName = !string.IsNullOrEmpty(job.PrinterOverride) ? job.PrinterOverride : profile.PrinterName; + var paperSource = !string.IsNullOrEmpty(job.PaperSourceOverride) ? job.PaperSourceOverride : profile.PaperSource; + var copies = job.CopiesOverride ?? profile.Copies; + + if (string.IsNullOrEmpty(printerName)) + printerName = GetDefaultPrinter(); + + _logger.LogInformation("Drucke '{Subject}' → {Printer} | Fach: {Fach} | {Copies}x", + job.MailSubject, printerName, string.IsNullOrEmpty(paperSource) ? "Standard" : paperSource, copies); + + try + { + for (int i = 0; i < copies; i++) + PrintOnce(job.FilePath, printerName, paperSource, profile.Duplex); + _logger.LogInformation("Druck OK: {File}", job.FilePath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Druckfehler: {File}", job.FilePath); + throw; + } + finally + { + TryDelete(job.FilePath); + } + } + + private PrinterProfile ResolveProfile(PrintJob job) + { + if (!string.IsNullOrEmpty(job.PrinterProfileName)) + { + var p = _options.PrinterProfiles.FirstOrDefault(x => + x.Name.Equals(job.PrinterProfileName, StringComparison.OrdinalIgnoreCase)); + if (p != null) return p; + } + return _options.PrinterProfiles.FirstOrDefault() ?? new PrinterProfile(); + } + + private void PrintOnce(string pdfPath, string printerName, string paperSource, string duplex = "none") + { + var sumatra = ResolveSumatra(); + if (sumatra != null) + { + var settings = BuildPrintSettings(paperSource, duplex); + RunAndWait(sumatra, $"-print-to \"{printerName}\" -print-settings \"{settings}\" -silent \"{pdfPath}\""); + return; + } + + // Fallback Shell-Print + _logger.LogWarning("Kein SumatraPDF – Shell-Print (kein Papierfach-Support)"); + var psi = new ProcessStartInfo { FileName = pdfPath, Verb = "print", UseShellExecute = true, WindowStyle = ProcessWindowStyle.Hidden }; + using var p = Process.Start(psi)!; + p.WaitForExit(30_000); + } + + private static string BuildPrintSettings(string paperSource, string duplex) + { + var parts = new List { "noscale" }; + if (!string.IsNullOrEmpty(paperSource)) parts.Add($"bin={paperSource}"); + if (!string.IsNullOrEmpty(duplex) && duplex != "none") parts.Add($"duplex{duplex}"); + return string.Join(",", parts); + } + + private string? ResolveSumatra() + { + if (!string.IsNullOrEmpty(_options.SumatraPath) && File.Exists(_options.SumatraPath)) + return _options.SumatraPath; + return Array.Find(SumatraCandidates, File.Exists); + } + + private void RunAndWait(string exe, string args) + { + _logger.LogDebug("Exec: {Exe} {Args}", exe, args); + var psi = new ProcessStartInfo(exe, args) { UseShellExecute = false, CreateNoWindow = true }; + using var p = Process.Start(psi) ?? throw new InvalidOperationException($"Nicht startbar: {exe}"); + if (!p.WaitForExit(300_000)) { p.Kill(); throw new TimeoutException($"Timeout: {exe}"); } + if (p.ExitCode != 0) _logger.LogWarning("ExitCode {Code}: {Exe}", p.ExitCode, exe); + } + + public List GetPrinterInfos() + { + var result = new List(); + foreach (string name in PrinterSettings.InstalledPrinters) + { + var ps = new PrinterSettings { PrinterName = name }; + var sources = new List(); + foreach (PaperSource src in ps.PaperSources) + sources.Add(src.SourceName); + result.Add(new PrinterInfo(name, sources)); + } + return result; + } + + public IEnumerable GetAvailablePrinters() => GetPrinterInfos().Select(p => p.Name); + private static string GetDefaultPrinter() => new PrinterSettings().PrinterName; + private void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (Exception ex) { _logger.LogWarning(ex, "Temp nicht löschbar: {Path}", path); } + } +} + +public record PrinterInfo(string Name, List PaperSources); diff --git a/MailPrintConfig/MailPrintConfig.csproj b/MailPrintConfig/MailPrintConfig.csproj new file mode 100644 index 0000000..51a25ce --- /dev/null +++ b/MailPrintConfig/MailPrintConfig.csproj @@ -0,0 +1,17 @@ + + + + net10.0-windows + enable + enable + WinExe + true + MailPrintConfig + MailPrintConfig + + + + + + + diff --git a/MailPrintConfig/MainForm.cs b/MailPrintConfig/MainForm.cs new file mode 100644 index 0000000..aa4d363 --- /dev/null +++ b/MailPrintConfig/MainForm.cs @@ -0,0 +1,900 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Drawing.Printing; + +namespace MailPrintConfig; + +public class MainForm : Form +{ + private const int LabelW = 140; + private const int CtrlW = 240; + private const int RowH = 28; + private const int Pad = 12; + + // ── Allgemein ───────────────────────────────────────────────── + private TextBox txtInterval = null!, txtSubjectFilter = null!, txtTempDir = null!, txtSumatraPath = null!; + private CheckBox chkDelete = null!, chkMarkRead = null!; + + // ── Web API ─────────────────────────────────────────────────── + private TextBox txtApiPort = null!, txtApiKey = null!; + private CheckBox chkBindAll = null!; + + // ── Grids ───────────────────────────────────────────────────── + private DataGridView gridProfiles = null!, gridAccounts = null!, gridFolders = null!; + + // ── Filter ──────────────────────────────────────────────────── + private TextBox txtGlobalAllowed = null!, txtGlobalBlocked = null!; + + // ── Config / Steuerung ──────────────────────────────────────── + private TextBox txtConfigPath = null!; + private Button btnLoad = null!, btnSave = null!, btnStartStop = null!, btnInstall = null!, btnUninstall = null!, btnSvcStart = null!, btnSvcStop = null!; + private Label lblStatus = null!; + private System.Diagnostics.Process? _proc; + private System.Windows.Forms.Timer _timer = null!; + + public MainForm() + { + Text = "MailPrint Konfiguration"; + Width = 1100; MinimumSize = new Size(900, 700); + Font = new Font("Segoe UI", 9f); + StartPosition = FormStartPosition.CenterScreen; + + BuildUI(); + AutoDetectConfigPath(); + + Load += (_, _) => { if (File.Exists(txtConfigPath.Text)) LoadConfig(); }; + + _timer = new System.Windows.Forms.Timer { Interval = 1500 }; + _timer.Tick += (_, _) => RefreshStartStop(); + _timer.Start(); + } + + // ══════════════════════════════════════════════════════════════ + // UI + // ══════════════════════════════════════════════════════════════ + private void BuildUI() + { + var tabs = new TabControl { Dock = DockStyle.Fill }; + tabs.TabPages.Add(BuildProfilesTab()); + tabs.TabPages.Add(BuildAccountsTab()); + tabs.TabPages.Add(BuildFoldersTab()); + tabs.TabPages.Add(BuildFilterTab()); + tabs.TabPages.Add(BuildApiTab()); + tabs.TabPages.Add(BuildGeneralTab()); + tabs.TabPages.Add(BuildAboutTab()); + Controls.Add(tabs); + + var bottom = new Panel { Dock = DockStyle.Bottom, Height = 84 }; + int x = Pad; + + // Zeile 1: Laden | Speichern | [Pfad…] [____path____] + btnLoad = Btn("Laden", x, 10, 80); x += 86; + btnSave = Btn("Speichern", x, 10, 84); x += 90; + var btnBrowse = Btn("Pfad…", x, 10, 60); x += 66; + txtConfigPath = new TextBox { Left = x, Top = 12, Width = 320, Anchor = AnchorStyles.Left | AnchorStyles.Top }; + + // Zeile 2: EXE starten | EXE stoppen | Dienst installieren | Dienst deinstallieren | Dienst starten | Dienst beenden + int x2 = Pad; + btnStartStop = Btn("▶ EXE starten", x2, 44, 120, Color.LightGreen); x2 += 126; + btnInstall = Btn("⚙ Dienst installieren", x2, 44, 145, Color.LightBlue); x2 += 151; + btnUninstall = Btn("✖ Dienst deinstall.", x2, 44, 140, Color.LightSalmon); x2 += 146; + btnSvcStart = Btn("▶ Dienst starten", x2, 44, 125, Color.PaleGreen); x2 += 131; + btnSvcStop = Btn("⏹ Dienst beenden", x2, 44, 120, Color.LightCoral); + + lblStatus = new Label { Left = Pad, Top = 68, Width = 820, Height = 16, AutoSize = false, ForeColor = Color.DarkGreen }; + + btnLoad.Click += (_, _) => LoadConfig(); + btnSave.Click += (_, _) => SaveConfig(); + btnStartStop.Click += (_, _) => _ = ToggleExeAsync(); + btnBrowse.Click += (_, _) => BrowseConfig(); + btnInstall.Click += (_, _) => _ = ServiceActionAsync("install"); + btnUninstall.Click += (_, _) => _ = ServiceActionAsync("uninstall"); + btnSvcStart.Click += (_, _) => _ = ServiceActionAsync("start"); + btnSvcStop.Click += (_, _) => _ = ServiceActionAsync("stop"); + + bottom.Controls.AddRange([btnLoad, btnSave, btnBrowse, txtConfigPath, + btnStartStop, btnInstall, btnUninstall, btnSvcStart, btnSvcStop, lblStatus]); + Controls.Add(bottom); + } + + // ── Tab: Drucker-Profile ────────────────────────────────────── + private TabPage BuildProfilesTab() + { + var tab = new TabPage("Drucker-Profile"); + tab.Controls.Add(new Label + { + Text = "Profil = Drucker + Papierfach + optionale Absender-Filter (überschreibt globale Filter).", + Left = Pad, Top = Pad, Width = 1060, Height = 18, ForeColor = Color.DimGray + }); + + gridProfiles = new DataGridView + { + Left = Pad, Top = 32, Width = 1060, Height = 500, + Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right | AnchorStyles.Bottom, + AllowUserToAddRows = true, AllowUserToDeleteRows = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None, + ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize, + EditMode = DataGridViewEditMode.EditOnEnter, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + ScrollBars = ScrollBars.Both + }; + + gridProfiles.Columns.Add(new DataGridViewTextBoxColumn { Name = "PName", HeaderText = "Profil-Name", Width = 120 }); + + var colPrinter = new DataGridViewComboBoxColumn { Name = "Printer", HeaderText = "Drucker", FlatStyle = FlatStyle.Flat, Width = 280 }; + colPrinter.Items.Add(""); + foreach (string p in PrinterSettings.InstalledPrinters) colPrinter.Items.Add(p); + gridProfiles.Columns.Add(colPrinter); + + var colSource = new DataGridViewComboBoxColumn { Name = "Source", HeaderText = "Papierfach", FlatStyle = FlatStyle.Flat, Width = 150 }; + colSource.Items.Add(""); + gridProfiles.Columns.Add(colSource); + + gridProfiles.Columns.Add(new DataGridViewTextBoxColumn { Name = "Copies", HeaderText = "Kopien", Width = 55 }); + + var colDuplex = new DataGridViewComboBoxColumn { Name = "Duplex", HeaderText = "Duplex", FlatStyle = FlatStyle.Flat, Width = 110 }; + colDuplex.Items.AddRange(["Aus", "Lange Seite", "Kurze Seite"]); + gridProfiles.Columns.Add(colDuplex); + + gridProfiles.Columns.Add(new DataGridViewTextBoxColumn { Name = "Allowed", HeaderText = "E-Mail-Whitelist (Komma)", Width = 220 }); + gridProfiles.Columns.Add(new DataGridViewTextBoxColumn { Name = "Blocked", HeaderText = "E-Mail-Blacklist (Komma)", Width = 220 }); + + gridProfiles.DataError += (_, e) => e.ThrowException = false; + gridProfiles.CellValueChanged += GridProfiles_CellValueChanged; + gridProfiles.CurrentCellDirtyStateChanged += (_, _) => + { + if (gridProfiles.IsCurrentCellDirty) gridProfiles.CommitEdit(DataGridViewDataErrorContexts.Commit); + }; + + AttachContextMenu(gridProfiles); + tab.Controls.Add(gridProfiles); + return tab; + } + + // ── Tab: Postfächer ─────────────────────────────────────────── + private TabPage BuildAccountsTab() + { + var tab = new TabPage("Postfächer"); + tab.Controls.Add(new Label + { + Text = "Jedes Postfach wird unabhängig abgerufen und druckt auf das zugeordnete Drucker-Profil.", + Left = Pad, Top = Pad, Width = 1060, Height = 18, ForeColor = Color.DimGray + }); + + gridAccounts = new DataGridView + { + Left = Pad, Top = 32, Width = 1060, Height = 500, + Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right | AnchorStyles.Bottom, + AllowUserToAddRows = true, AllowUserToDeleteRows = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None, + ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize, + EditMode = DataGridViewEditMode.EditOnEnter, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + ScrollBars = ScrollBars.Both + }; + + gridAccounts.Columns.Add(new DataGridViewTextBoxColumn { Name = "AName", HeaderText = "Name", Width = 100 }); + + var colProto = new DataGridViewComboBoxColumn { Name = "Protocol", HeaderText = "Protokoll", FlatStyle = FlatStyle.Flat, Width = 70 }; + colProto.Items.AddRange(["IMAP", "POP3"]); + gridAccounts.Columns.Add(colProto); + + gridAccounts.Columns.Add(new DataGridViewTextBoxColumn { Name = "Host", HeaderText = "Host", Width = 200 }); + gridAccounts.Columns.Add(new DataGridViewTextBoxColumn { Name = "Port", HeaderText = "Port", Width = 52 }); + gridAccounts.Columns.Add(new DataGridViewCheckBoxColumn { Name = "Ssl", HeaderText = "SSL", Width = 38 }); + gridAccounts.Columns.Add(new DataGridViewTextBoxColumn { Name = "User", HeaderText = "Benutzername", Width = 180 }); + var colPass = new DataGridViewTextBoxColumn { Name = "Pass", HeaderText = "Passwort", Width = 180 }; + colPass.DefaultCellStyle.NullValue = null; + colPass.DefaultCellStyle.Font = new Font("Courier New", 9f); + gridAccounts.Columns.Add(colPass); + gridAccounts.Columns.Add(new DataGridViewTextBoxColumn { Name = "Folder", HeaderText = "Ordner", Width = 80 }); + + var colProfile = new DataGridViewComboBoxColumn { Name = "Profile", HeaderText = "Drucker-Profil", FlatStyle = FlatStyle.Flat, Width = 150 }; + colProfile.Items.Add(""); + gridAccounts.Columns.Add(colProfile); + + gridAccounts.DataError += (_, e) => e.ThrowException = false; + // Passwort maskieren + gridAccounts.CellFormatting += (_, e) => + { + if (e.RowIndex >= 0 && gridAccounts.Columns[e.ColumnIndex].Name == "Pass" && e.Value is string pw) + e.Value = new string('●', pw.Length); + }; + gridAccounts.CellBeginEdit += (_, e) => + { + if (e.RowIndex >= 0 && gridAccounts.Columns[e.ColumnIndex].Name == "Pass") + { + var cell = gridAccounts.Rows[e.RowIndex].Cells["Pass"]; + // beim Bearbeiten Klartext zeigen – Value ist bereits der echte Wert + } + }; + gridProfiles.CellValueChanged += (_, _) => RefreshProfileDropdowns(); + + AttachContextMenu(gridAccounts); + tab.Controls.Add(gridAccounts); + return tab; + } + + // ── Tab: Ordner-Überwachung ──────────────────────────────────── + private TabPage BuildFoldersTab() + { + var tab = new TabPage("Ordner-Überwachung"); + tab.Controls.Add(new Label + { + Text = "PDFs in diesen Ordnern werden automatisch erkannt und gedruckt.", + Left = Pad, Top = Pad, Width = 1060, Height = 18, ForeColor = Color.DimGray + }); + + gridFolders = new DataGridView + { + Left = Pad, Top = 32, Width = 1060, Height = 500, + Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right | AnchorStyles.Bottom, + AllowUserToAddRows = true, AllowUserToDeleteRows = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None, + ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize, + EditMode = DataGridViewEditMode.EditOnEnter, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + ScrollBars = ScrollBars.Both + }; + + gridFolders.Columns.Add(new DataGridViewTextBoxColumn { Name = "FName", HeaderText = "Name", Width = 120 }); + gridFolders.Columns.Add(new DataGridViewTextBoxColumn { Name = "FPath", HeaderText = "Pfad", Width = 400 }); + gridFolders.Columns.Add(new DataGridViewButtonColumn { Name = "FBrowse", HeaderText = "", Text = "…", Width = 30 }); + gridFolders.Columns.Add(new DataGridViewCheckBoxColumn { Name = "FSubfolders",HeaderText = "Unterordner", Width = 90 }); + gridFolders.Columns.Add(new DataGridViewCheckBoxColumn { Name = "FDelete", HeaderText = "Nach Druck löschen",Width = 120 }); + + var colFProfile = new DataGridViewComboBoxColumn { Name = "FProfile", HeaderText = "Drucker-Profil", FlatStyle = FlatStyle.Flat, Width = 180 }; + colFProfile.Items.Add(""); + gridFolders.Columns.Add(colFProfile); + + gridFolders.DataError += (_, e) => e.ThrowException = false; + gridFolders.CurrentCellDirtyStateChanged += (_, _) => + { + if (gridFolders.IsCurrentCellDirty) gridFolders.CommitEdit(DataGridViewDataErrorContexts.Commit); + }; + + // "…"-Button → Ordner auswählen + gridFolders.CellClick += (_, e) => + { + if (e.RowIndex < 0 || gridFolders.Columns[e.ColumnIndex].Name != "FBrowse") return; + var rowIndex = e.RowIndex; + BeginInvoke(() => BrowseFolder(rowIndex)); + }; + + gridProfiles.CellValueChanged += (_, _) => RefreshProfileDropdowns(); + AttachContextMenu(gridFolders); + tab.Controls.Add(gridFolders); + return tab; + } + + // ── Tab: Filter ─────────────────────────────────────────────── + private TabPage BuildFilterTab() + { + var tab = new TabPage("E-Mail-Filter (Global)"); + int y = Pad; + + tab.Controls.Add(new Label + { + Text = "Globale Listen gelten für alle Profile, sofern das Profil keine eigene Liste definiert.\r\n" + + "Format: eine E-Mail-Adresse pro Zeile. Leer = kein Filter.", + Left = Pad, Top = y, Width = 800, Height = 34, ForeColor = Color.DimGray + }); + y += 40; + + tab.Controls.Add(new Label { Text = "E-Mail-Whitelist (nur diese Absender drucken):", Left = Pad, Top = y, Width = 390, AutoSize = false }); + tab.Controls.Add(new Label { Text = "E-Mail-Blacklist (diese Absender blockieren):", Left = Pad + 420, Top = y, Width = 390, AutoSize = false }); + y += 20; + + txtGlobalAllowed = new TextBox + { + Left = Pad, Top = y, Width = 390, Height = 400, + Multiline = true, ScrollBars = ScrollBars.Vertical, + Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Bottom + }; + txtGlobalBlocked = new TextBox + { + Left = Pad + 420, Top = y, Width = 390, Height = 400, + Multiline = true, ScrollBars = ScrollBars.Vertical, + Anchor = AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Bottom + }; + + tab.Controls.Add(txtGlobalAllowed); + tab.Controls.Add(txtGlobalBlocked); + return tab; + } + + // ── Tab: Web API ────────────────────────────────────────────── + private TabPage BuildApiTab() + { + var tab = new TabPage("Web API"); + int y = Pad; + txtApiPort = AddText(tab, "Port", ref y, "5100"); + chkBindAll = AddCheck(tab, "Alle Interfaces (0.0.0.0)", ref y, false); + txtApiKey = AddText(tab, "API-Key", ref y); + + var btnGen = Btn("🔑 Generieren", LabelW + Pad, y, 130); + btnGen.Click += (_, _) => txtApiKey.Text = Guid.NewGuid().ToString("N"); + tab.Controls.Add(btnGen); y += RowH + 4; + + tab.Controls.Add(new Label + { + Text = "Header: X-Api-Key: | Leer = kein Schutz\r\n\r\n" + + "POST /api/print/upload (multipart: file, printer, paperSource, copies)\r\n" + + "POST /api/print/url { url, printer, paperSource, copies }\r\n" + + "GET /api/print/printers\r\nGET /api/print/health\r\nGET /swagger", + Left = Pad, Top = y, Width = 700, Height = 110, ForeColor = Color.DimGray + }); + return tab; + } + + // ── Tab: Allgemein ──────────────────────────────────────────── + private TabPage BuildGeneralTab() + { + var tab = new TabPage("Allgemein"); + int y = Pad; + txtInterval = AddText(tab, "Intervall (Sek.)", ref y, "60"); + txtSubjectFilter = AddText(tab, "Betreff-Filter", ref y); + chkDelete = AddCheck(tab, "Nach Druck löschen", ref y, true); + chkMarkRead = AddCheck(tab, "Als gelesen markieren", ref y, true); + txtSumatraPath = AddText(tab, "SumatraPDF Pfad", ref y); + + var btnS = Btn("…", LabelW + Pad + CtrlW + 4, y - RowH - 2, 28); + btnS.Click += (_, _) => + { + using var d = new OpenFileDialog { Filter = "SumatraPDF.exe|SumatraPDF.exe|Alle|*.*" }; + if (d.ShowDialog() == DialogResult.OK) txtSumatraPath.Text = d.FileName; + }; + tab.Controls.Add(btnS); + txtTempDir = AddText(tab, "Temp-Verzeichnis", ref y, Path.Combine(Path.GetTempPath(), "MailPrint")); + return tab; + } + + // ── Tab: Über ───────────────────────────────────────────────── + private TabPage BuildAboutTab() + { + var tab = new TabPage("Über"); + int y = Pad + 10; + + void AddLine(string text, bool link = false, string url = "") + { + if (link) + { + var lbl = new LinkLabel + { + Text = text, Left = Pad, Top = y, AutoSize = true, + Font = new Font("Segoe UI", 10f) + }; + lbl.LinkClicked += (_, _) => System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true }); + tab.Controls.Add(lbl); + } + else + { + tab.Controls.Add(new Label + { + Text = text, Left = Pad, Top = y, AutoSize = true, + Font = new Font("Segoe UI", 10f) + }); + } + y += 28; + } + + var version = System.Reflection.Assembly.GetExecutingAssembly() + .GetName().Version?.ToString(3) ?? "?"; + + AddLine("MailPrint"); + y += 4; + AddLine($"Version {version}"); + AddLine("Automatischer PDF-Druck per E-Mail und REST API."); + AddLine("Kostenlos und quelloffen (MIT-Lizenz)."); + y += 16; + + AddLine("Quellcode & Dokumentation:"); + AddLine("https://www.dimedtec.net/dimedtec/MailPrint", link: true, url: "https://www.dimedtec.net/dimedtec/MailPrint"); + y += 16; + + AddLine("Verwendet:"); + AddLine("SumatraPDF – freier PDF-Viewer (GPLv3)"); + AddLine("https://www.sumatrapdfreader.org", link: true, url: "https://www.sumatrapdfreader.org"); + y += 8; + AddLine("MailKit – IMAP/POP3 Bibliothek (MIT)"); + AddLine("https://github.com/jstedfast/MailKit", link: true, url: "https://github.com/jstedfast/MailKit"); + y += 8; + AddLine(".NET – Microsoft (MIT)"); + AddLine("https://dotnet.microsoft.com", link: true, url: "https://dotnet.microsoft.com"); + + return tab; + } + + // ── Kontextmenü für Grids ────────────────────────────────────── + private static void AttachContextMenu(DataGridView grid) + { + var menu = new ContextMenuStrip(); + var itemDelete = new ToolStripMenuItem("🗑 Zeile löschen"); + itemDelete.Click += (_, _) => + { + foreach (DataGridViewRow row in grid.SelectedRows) + if (!row.IsNewRow) grid.Rows.Remove(row); + }; + menu.Items.Add(itemDelete); + + grid.MouseDown += (_, e) => + { + if (e.Button != MouseButtons.Right) return; + var hit = grid.HitTest(e.X, e.Y); + if (hit.RowIndex >= 0) + { + grid.ClearSelection(); + grid.Rows[hit.RowIndex].Selected = true; + menu.Show(grid, e.Location); + } + }; + } + + // ══════════════════════════════════════════════════════════════ + // Papierfächer + Profil-Dropdown sync + // ══════════════════════════════════════════════════════════════ + private void GridProfiles_CellValueChanged(object? sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex < 0 || gridProfiles.Columns[e.ColumnIndex].Name != "Printer") return; + var printerName = gridProfiles.Rows[e.RowIndex].Cells["Printer"].Value?.ToString() ?? ""; + var sc = (DataGridViewComboBoxCell)gridProfiles.Rows[e.RowIndex].Cells["Source"]; + sc.Items.Clear(); sc.Items.Add(""); + if (!string.IsNullOrEmpty(printerName)) + { + var ps = new PrinterSettings { PrinterName = printerName }; + foreach (PaperSource src in ps.PaperSources) sc.Items.Add(src.SourceName); + } + } + + private void RefreshProfileDropdowns() + { + var names = gridProfiles.Rows.Cast() + .Where(r => !r.IsNewRow) + .Select(r => r.Cells["PName"].Value?.ToString() ?? "") + .Where(n => n.Length > 0).ToList(); + + // Postfächer + var colA = (DataGridViewComboBoxColumn)gridAccounts.Columns["Profile"]; + colA.Items.Clear(); colA.Items.Add(""); + foreach (var n in names) colA.Items.Add(n); + foreach (DataGridViewRow row in gridAccounts.Rows) + { + if (row.IsNewRow) continue; + var val = row.Cells["Profile"].Value?.ToString() ?? ""; + if (!string.IsNullOrEmpty(val) && !colA.Items.Contains(val)) colA.Items.Add(val); + } + + // Ordner-Überwachung + var colF = (DataGridViewComboBoxColumn)gridFolders.Columns["FProfile"]; + colF.Items.Clear(); colF.Items.Add(""); + foreach (var n in names) colF.Items.Add(n); + foreach (DataGridViewRow row in gridFolders.Rows) + { + if (row.IsNewRow) continue; + var val = row.Cells["FProfile"].Value?.ToString() ?? ""; + if (!string.IsNullOrEmpty(val) && !colF.Items.Contains(val)) colF.Items.Add(val); + } + } + + // ══════════════════════════════════════════════════════════════ + // Config laden / speichern + // ══════════════════════════════════════════════════════════════ + private void AutoDetectConfigPath() + { + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "appsettings.json"), + Path.Combine(AppContext.BaseDirectory, "..", "publish", "appsettings.json"), + @"C:\Services\MailPrint\appsettings.json", + }; + txtConfigPath.Text = candidates.FirstOrDefault(File.Exists) + ?? Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + } + + private void BrowseFolder(int rowIndex) + { + string? result = null; + var current = gridFolders.Rows[rowIndex].Cells["FPath"].Value?.ToString() ?? ""; + + // Eigener STA-Thread verhindert Blockierung durch COM-Dialog + var t = new Thread(() => + { + using var d = new FolderBrowserDialog + { + Description = "Ordner wählen", + UseDescriptionForTitle = true, + ShowNewFolderButton = true + }; + if (Directory.Exists(current)) d.SelectedPath = current; + result = d.ShowDialog() == DialogResult.OK ? d.SelectedPath : null; + }); + t.SetApartmentState(ApartmentState.STA); + t.Start(); + t.Join(); + + if (result != null) + gridFolders.Rows[rowIndex].Cells["FPath"].Value = result; + } + + private void BrowseConfig() + { + using var d = new OpenFileDialog { Filter = "appsettings.json|appsettings.json|JSON|*.json" }; + if (d.ShowDialog() == DialogResult.OK) txtConfigPath.Text = d.FileName; + } + + private void LoadConfig() + { + if (!File.Exists(txtConfigPath.Text)) + { SetStatus($"Nicht gefunden: {txtConfigPath.Text}", Color.Red); return; } + + try + { + var mp = (JObject.Parse(File.ReadAllText(txtConfigPath.Text))["MailPrint"] as JObject) ?? new JObject(); + + txtInterval.Text = mp["PollIntervalSeconds"]?.ToString() ?? "60"; + txtSubjectFilter.Text = mp["SubjectFilter"]?.ToString() ?? ""; + chkDelete.Checked = mp["DeleteAfterPrint"]?.Value() ?? true; + chkMarkRead.Checked = mp["MarkAsRead"]?.Value() ?? true; + txtSumatraPath.Text = mp["SumatraPath"]?.ToString() ?? ""; + txtTempDir.Text = mp["TempDirectory"]?.ToString() ?? ""; + + txtGlobalAllowed.Text = string.Join(Environment.NewLine, + (mp["AllowedSenders"] as JArray ?? new JArray()).Select(t => t.ToString())); + txtGlobalBlocked.Text = string.Join(Environment.NewLine, + (mp["BlockedSenders"] as JArray ?? new JArray()).Select(t => t.ToString())); + + var api = mp["WebApi"] as JObject ?? new JObject(); + txtApiPort.Text = api["Port"]?.ToString() ?? "5100"; + chkBindAll.Checked = api["BindAllInterfaces"]?.Value() ?? false; + txtApiKey.Text = api["ApiKey"]?.ToString() ?? ""; + + // Drucker-Profile + gridProfiles.Rows.Clear(); + var printerCol = (DataGridViewComboBoxColumn)gridProfiles.Columns["Printer"]; + foreach (var p in mp["PrinterProfiles"] as JArray ?? new JArray()) + { + var printer = p["PrinterName"]?.ToString() ?? ""; + var source = p["PaperSource"]?.ToString() ?? ""; + var allowed = string.Join(", ", (p["AllowedSenders"] as JArray ?? new JArray()).Select(t => t.ToString())); + var blocked = string.Join(", ", (p["BlockedSenders"] as JArray ?? new JArray()).Select(t => t.ToString())); + + if (!printerCol.Items.Contains(printer) && !string.IsNullOrEmpty(printer)) + printerCol.Items.Add(printer); + + int ri = gridProfiles.Rows.Add( + p["Name"]?.ToString() ?? "", printer, "", p["Copies"]?.ToString() ?? "1", + DuplexToDisplay(p["Duplex"]?.ToString()), allowed, blocked); + + var sc = (DataGridViewComboBoxCell)gridProfiles.Rows[ri].Cells["Source"]; + sc.Items.Clear(); sc.Items.Add(""); + if (!string.IsNullOrEmpty(printer)) + { + var ps = new PrinterSettings { PrinterName = printer }; + foreach (PaperSource s in ps.PaperSources) sc.Items.Add(s.SourceName); + } + if (!sc.Items.Contains(source) && !string.IsNullOrEmpty(source)) sc.Items.Add(source); + sc.Value = source; + } + + RefreshProfileDropdowns(); + + // Postfächer + gridAccounts.Rows.Clear(); + foreach (var a in mp["Accounts"] as JArray ?? new JArray()) + { + var proto = a["Protocol"]?.ToString() ?? "IMAP"; + var profName = a["PrinterProfileName"]?.ToString() ?? ""; + + var protoCol = (DataGridViewComboBoxColumn)gridAccounts.Columns["Protocol"]; + if (!protoCol.Items.Contains(proto)) protoCol.Items.Add(proto); + + var profCol = (DataGridViewComboBoxColumn)gridAccounts.Columns["Profile"]; + if (!string.IsNullOrEmpty(profName) && !profCol.Items.Contains(profName)) + profCol.Items.Add(profName); + + gridAccounts.Rows.Add( + a["Name"]?.ToString() ?? "", + proto, + a["Host"]?.ToString() ?? "", + a["Port"]?.ToString() ?? "993", + a["UseSsl"]?.Value() ?? true, + a["Username"]?.ToString() ?? "", + a["Password"]?.ToString() ?? "", + a["Folder"]?.ToString() ?? "INBOX", + profName); + } + + // Ordner-Überwachung + gridFolders.Rows.Clear(); + foreach (var f in mp["FolderWatchers"] as JArray ?? new JArray()) + { + var profName = f["PrinterProfileName"]?.ToString() ?? ""; + var profCol = (DataGridViewComboBoxColumn)gridFolders.Columns["FProfile"]; + if (!string.IsNullOrEmpty(profName) && !profCol.Items.Contains(profName)) + profCol.Items.Add(profName); + + gridFolders.Rows.Add( + f["Name"]?.ToString() ?? "", + f["Path"]?.ToString() ?? "", + "…", + f["IncludeSubfolders"]?.Value() ?? false, + f["DeleteAfterPrint"]?.Value() ?? true, + profName); + } + + SetStatus($"Geladen: {txtConfigPath.Text}", Color.DarkGreen); + } + catch (Exception ex) { SetStatus($"Fehler: {ex.Message}", Color.Red); } + } + + private void SaveConfig() + { + gridProfiles.EndEdit(); + gridAccounts.EndEdit(); + gridFolders.EndEdit(); + + var path = txtConfigPath.Text; + var root = File.Exists(path) ? JObject.Parse(File.ReadAllText(path)) : new JObject(); + + var profiles = new JArray(); + foreach (DataGridViewRow r in gridProfiles.Rows) + { + if (r.IsNewRow) continue; + profiles.Add(new JObject + { + ["Name"] = r.Cells["PName"].Value?.ToString() ?? "", + ["PrinterName"] = r.Cells["Printer"].Value?.ToString() ?? "", + ["PaperSource"] = r.Cells["Source"].Value?.ToString() ?? "", + ["Copies"] = int.TryParse(r.Cells["Copies"].Value?.ToString(), out int c) ? c : 1, + ["Duplex"] = DuplexToJson(r.Cells["Duplex"].Value?.ToString()), + ["AllowedSenders"] = ToJArray(r.Cells["Allowed"].Value?.ToString()), + ["BlockedSenders"] = ToJArray(r.Cells["Blocked"].Value?.ToString()) + }); + } + + var accounts = new JArray(); + foreach (DataGridViewRow r in gridAccounts.Rows) + { + if (r.IsNewRow) continue; + accounts.Add(new JObject + { + ["Name"] = r.Cells["AName"].Value?.ToString() ?? "", + ["Protocol"] = r.Cells["Protocol"].Value?.ToString() ?? "IMAP", + ["Host"] = r.Cells["Host"].Value?.ToString() ?? "", + ["Port"] = int.TryParse(r.Cells["Port"].Value?.ToString(), out int p) ? p : 993, + ["UseSsl"] = r.Cells["Ssl"].Value is true, + ["Username"] = r.Cells["User"].Value?.ToString() ?? "", + ["Password"] = r.Cells["Pass"].Value?.ToString() ?? "", + ["Folder"] = r.Cells["Folder"].Value?.ToString() ?? "INBOX", + ["PrinterProfileName"] = r.Cells["Profile"].Value?.ToString() ?? "" + }); + } + + var folders = new JArray(); + foreach (DataGridViewRow r in gridFolders.Rows) + { + if (r.IsNewRow) continue; + folders.Add(new JObject + { + ["Name"] = r.Cells["FName"].Value?.ToString() ?? "", + ["Path"] = r.Cells["FPath"].Value?.ToString() ?? "", + ["IncludeSubfolders"] = r.Cells["FSubfolders"].Value is true, + ["DeleteAfterPrint"] = r.Cells["FDelete"].Value is true, + ["PrinterProfileName"] = r.Cells["FProfile"].Value?.ToString() ?? "" + }); + } + + root["MailPrint"] = new JObject + { + ["PollIntervalSeconds"] = int.TryParse(txtInterval.Text, out int iv) ? iv : 60, + ["SubjectFilter"] = txtSubjectFilter.Text, + ["DeleteAfterPrint"] = chkDelete.Checked, + ["MarkAsRead"] = chkMarkRead.Checked, + ["SumatraPath"] = txtSumatraPath.Text, + ["TempDirectory"] = txtTempDir.Text, + ["AllowedExtensions"] = new JArray(".pdf"), + ["AllowedSenders"] = ToJArray(txtGlobalAllowed.Text, multiline: true), + ["BlockedSenders"] = ToJArray(txtGlobalBlocked.Text, multiline: true), + ["PrinterProfiles"] = profiles, + ["Accounts"] = accounts, + ["FolderWatchers"] = folders, + ["WebApi"] = new JObject + { + ["Port"] = int.TryParse(txtApiPort.Text, out int ap) ? ap : 5100, + ["BindAllInterfaces"] = chkBindAll.Checked, + ["ApiKey"] = txtApiKey.Text + } + }; + + if (root["Serilog"] == null) + root["Serilog"] = JObject.Parse("{\"MinimumLevel\":{\"Default\":\"Information\",\"Override\":{\"Microsoft\":\"Warning\",\"Microsoft.AspNetCore\":\"Warning\"}}}"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, root.ToString(Formatting.Indented)); + SetStatus($"Gespeichert: {path}", Color.DarkGreen); + } + catch (Exception ex) { SetStatus($"Fehler: {ex.Message}", Color.Red); } + } + + private static string DuplexToDisplay(string? json) => json switch + { + "long" => "Lange Seite", + "short" => "Kurze Seite", + _ => "Aus" + }; + + private static string DuplexToJson(string? display) => display switch + { + "Lange Seite" => "long", + "Kurze Seite" => "short", + _ => "none" + }; + + private static JArray ToJArray(string? input, bool multiline = false) + { + if (string.IsNullOrWhiteSpace(input)) return new JArray(); + var sep = multiline ? new[] { "\r\n", "\n" } : new[] { "," }; + return new JArray(input.Split(sep, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()).Where(s => s.Length > 0)); + } + + // ══════════════════════════════════════════════════════════════ + // EXE starten / stoppen + // ══════════════════════════════════════════════════════════════ + private async Task ToggleExeAsync() + { + btnStartStop.Enabled = false; + try + { + if (_proc is { HasExited: false }) + { + _proc.Kill(entireProcessTree: true); + await _proc.WaitForExitAsync(); + _proc = null; + SetStatus("MailPrint EXE gestoppt.", Color.DarkOrange); + } + else + { + var exePath = Path.Combine( + Path.GetDirectoryName(txtConfigPath.Text) ?? AppContext.BaseDirectory, + "MailPrint.exe"); + if (!File.Exists(exePath)) + { SetStatus($"MailPrint.exe nicht gefunden: {exePath}", Color.Red); return; } + + _proc = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo(exePath) + { + UseShellExecute = true, + WorkingDirectory = Path.GetDirectoryName(exePath)! + }, + EnableRaisingEvents = true + }; + _proc.Exited += (_, _) => BeginInvoke(RefreshStartStop); + _proc.Start(); + SetStatus($"MailPrint EXE gestartet (PID {_proc.Id})", Color.DarkGreen); + } + } + finally { btnStartStop.Enabled = true; RefreshStartStop(); } + } + + // ── Dienst-Aktionen ─────────────────────────────────────────── + private const string ServiceName = "MailPrint"; + + private async Task ServiceActionAsync(string action) + { + var publishDir = Path.GetDirectoryName(txtConfigPath.Text) ?? AppContext.BaseDirectory; + var exePath = Path.Combine(publishDir, "MailPrint.exe"); + var installPs = Path.Combine(publishDir, "..", "install-service.ps1"); + var uninstallPs= Path.Combine(publishDir, "..", "uninstall-service.ps1"); + + // Bestätigung nur bei Install/Deinstall + if (action is "install" or "uninstall") + { + var msg = action == "install" + ? "Dienst 'MailPrint' jetzt installieren?" + : "Dienst 'MailPrint' wirklich deinstallieren?"; + if (MessageBox.Show(msg, "Bestätigung", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) + return; + } + + SetStatus($"Führe aus: {action}…", Color.DarkBlue); + DisableServiceButtons(true); + + try + { + string cmd = action switch + { + "install" => $"-ExecutionPolicy Bypass -File \"{Path.GetFullPath(installPs)}\"", + "uninstall" => $"-ExecutionPolicy Bypass -File \"{Path.GetFullPath(uninstallPs)}\"", + "start" => $"-Command Start-Service -Name '{ServiceName}'", + "stop" => $"-Command Stop-Service -Name '{ServiceName}' -Force", + _ => throw new ArgumentException(action) + }; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell", + Arguments = $"-NoProfile -NonInteractive {cmd}", + UseShellExecute = true, + Verb = "runas", + WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden + }; + + using var p = System.Diagnostics.Process.Start(psi)!; + await p.WaitForExitAsync(); + + if (p.ExitCode == 0) + { + var successMsg = action switch + { + "install" => "Dienst 'MailPrint' wurde erfolgreich installiert.", + "uninstall" => "Dienst 'MailPrint' wurde erfolgreich deinstalliert.", + "start" => "Dienst 'MailPrint' wurde gestartet.", + "stop" => "Dienst 'MailPrint' wurde beendet.", + _ => "Aktion erfolgreich." + }; + SetStatus(successMsg, Color.DarkGreen); + MessageBox.Show(successMsg, "Erfolgreich", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + else + SetStatus($"'{action}' fehlgeschlagen (Code {p.ExitCode})", Color.Red); + } + catch (Exception ex) { SetStatus($"Fehler: {ex.Message}", Color.Red); } + finally { DisableServiceButtons(false); RefreshStartStop(); } + } + + private void DisableServiceButtons(bool disable) + { + if (InvokeRequired) { BeginInvoke(() => DisableServiceButtons(disable)); return; } + btnInstall.Enabled = btnUninstall.Enabled = btnSvcStart.Enabled = btnSvcStop.Enabled = !disable; + } + + private void RefreshStartStop() + { + if (InvokeRequired) { BeginInvoke(RefreshStartStop); return; } + bool running = _proc is { HasExited: false }; + btnStartStop.Text = running ? "⏹ EXE stoppen" : "▶ EXE starten"; + btnStartStop.BackColor = running ? Color.LightCoral : Color.LightGreen; + } + + // ══════════════════════════════════════════════════════════════ + // Hilfsmethoden + // ══════════════════════════════════════════════════════════════ + private void SetStatus(string msg, Color color) + { + if (InvokeRequired) { BeginInvoke(() => SetStatus(msg, color)); return; } + lblStatus.Text = msg; lblStatus.ForeColor = color; + } + + private static Button Btn(string text, int x, int y, int w, Color? bg = null) + { + var b = new Button { Text = text, Left = x, Top = y, Width = w, Height = 26 }; + if (bg.HasValue) b.BackColor = bg.Value; + return b; + } + + private TextBox AddText(Control p, string label, ref int y, string def = "") + { + AddLabel(p, label, y); + var tb = new TextBox { Left = LabelW + Pad, Top = y, Width = CtrlW, Text = def }; + p.Controls.Add(tb); y += RowH; return tb; + } + + private CheckBox AddCheck(Control p, string label, ref int y, bool def) + { + var cb = new CheckBox { Text = label, Left = LabelW + Pad, Top = y, Width = CtrlW + 40, Checked = def }; + p.Controls.Add(cb); y += RowH; return cb; + } + + private static void AddLabel(Control p, string text, int y) + => p.Controls.Add(new Label { Text = text + ":", Left = Pad, Top = y + 4, Width = LabelW, AutoSize = false }); + + protected override void OnFormClosing(FormClosingEventArgs e) + { + _timer.Stop(); + base.OnFormClosing(e); + } +} diff --git a/MailPrintConfig/Program.cs b/MailPrintConfig/Program.cs new file mode 100644 index 0000000..eec6102 --- /dev/null +++ b/MailPrintConfig/Program.cs @@ -0,0 +1,5 @@ +using MailPrintConfig; + +Application.EnableVisualStyles(); +Application.SetCompatibleTextRenderingDefault(false); +Application.Run(new MainForm()); diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0ddd4b --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# MailPrint + +**Windows-Dienst zum automatischen Drucken von PDF-Anhängen aus E-Mails, per REST API und per Ordner-Überwachung.** + +Kostenlos und quelloffen. Version 1.0.0 + +--- + +## Features + +- 📧 **IMAP / POP3** – Postfächer werden automatisch abgerufen, PDF-Anhänge sofort gedruckt +- 📂 **Ordner-Überwachung** – PDFs in überwachten Ordnern werden automatisch gedruckt +- 🖨️ **Mehrere Drucker-Profile** – je Profil eigener Drucker, Papierfach, Duplex und Kopienanzahl +- 📬 **Mehrere Postfächer** – jedes Postfach zeigt auf ein Drucker-Profil +- 🌐 **REST API** – PDF per HTTP-Upload oder URL drucken (z.B. aus einem Webshop) +- 🔒 **API-Key Absicherung** – optionaler Schutz für den HTTP-Endpunkt +- 🗂️ **Papierfach-Steuerung** – SumatraPDF-basiert, stiller Druck ohne Fenster +- ↔️ **Duplex-Druck** – einseitig, lange Seite oder kurze Seite +- ✉️ **E-Mail-Whitelist / Blacklist** – global und pro Drucker-Profil +- ⚙️ **Config-Tool** – WinForms GUI zum Konfigurieren ohne JSON-Bearbeitung +- 🔄 **Windows Service** – läuft ohne Anmeldung im Hintergrund + +--- + +## Voraussetzungen + +- Windows 10 / Server 2019 oder neuer +- [.NET 10 Runtime (Windows)](https://dotnet.microsoft.com/download/dotnet/10.0) +- [SumatraPDF](https://www.sumatrapdfreader.org/download-free-pdf-viewer) (wird automatisch erkannt wenn installiert oder neben der EXE) +- Installierter Windows-Drucker + +--- + +## Installation + +### 1. Release entpacken + +Inhalt des `publish`-Ordners in ein Zielverzeichnis kopieren, z.B.: +``` +C:\Services\MailPrint\ +``` + +### 2. Konfigurieren + +`MailPrintConfig.exe` starten und konfigurieren: + +- **Drucker-Profile** – Drucker + Papierfach + optionale Absender-Filter +- **Postfächer** – IMAP/POP3 Zugangsdaten + zugeordnetes Drucker-Profil +- **Web API** – Port, API-Key, FritzBox-Portmapping +- **Filter** – globale Whitelist/Blacklist +- **Allgemein** – Intervall, SumatraPDF-Pfad, Temp-Verzeichnis + +Auf **Speichern** klicken. + +### 3. Als Windows Service installieren + +```powershell +# Als Administrator ausführen: +.\install-service.ps1 +``` + +### 4. Deinstallieren + +```powershell +# Als Administrator ausführen: +.\uninstall-service.ps1 +``` + +--- + +## Web API + +| Methode | Endpunkt | Beschreibung | +|---|---|---| +| `POST` | `/api/print/upload` | PDF hochladen (multipart/form-data) | +| `POST` | `/api/print/url` | PDF-URL übergeben | +| `GET` | `/api/print/printers` | Drucker + Papierfächer auflesen | +| `GET` | `/api/print/health` | Healthcheck | +| `GET` | `/swagger` | Swagger UI | + +### Authentifizierung + +```http +X-Api-Key: +``` + +### Beispiel Upload (curl) + +```bash +curl -X POST http://localhost:5100/api/print/upload \ + -H "X-Api-Key: DEIN_KEY" \ + -F "file=@rechnung.pdf" \ + -F "printer=HP LaserJet" \ + -F "paperSource=Fach 2" \ + -F "copies=1" +``` + +### Beispiel URL (PowerShell) + +```powershell +Invoke-RestMethod -Uri "http://localhost:5100/api/print/url" ` + -Method Post ` + -Headers @{"X-Api-Key" = "DEIN_KEY"; "Content-Type" = "application/json"} ` + -Body '{"url":"https://example.com/rechnung.pdf","copies":1}' +``` + +--- + +## Papierfach-Steuerung + +SumatraPDF wird automatisch gefunden wenn: +- Installiert unter `C:\Program Files\SumatraPDF\` +- Installiert unter `%LOCALAPPDATA%\SumatraPDF\` +- `SumatraPDF.exe` liegt neben `MailPrint.exe` + +Ohne SumatraPDF: Fallback auf Windows Shell-Print (kein Papierfach-Support). + +--- + +## Logs + +- Konsole (wenn als EXE gestartet) +- `logs\mailprint-YYYYMMDD.log` (neben der EXE) +- Windows EventLog unter „MailPrint" (wenn als Service) + +--- + +## Lizenz + +MIT diff --git a/install-service.ps1 b/install-service.ps1 new file mode 100644 index 0000000..0457138 --- /dev/null +++ b/install-service.ps1 @@ -0,0 +1,42 @@ +#Requires -RunAsAdministrator + +$ServiceName = "MailPrint" +$DisplayName = "MailPrint - E-Mail & WebAPI zu Drucker" +$Description = "Druckt PDF-Anhaenge automatisch aus E-Mails (IMAP/POP3) und per REST API auf Windows-Drucker." +$ExePath = Join-Path $PSScriptRoot "publish\MailPrint.exe" + +if (-not (Test-Path $ExePath)) { + Write-Error "MailPrint.exe nicht gefunden: $ExePath" + exit 1 +} + +$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($existing) { + Write-Host "Dienst '$ServiceName' ist bereits installiert. Stoppe zuerst..." -ForegroundColor Yellow + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 +} + +Write-Host "Installiere Dienst '$ServiceName'..." -ForegroundColor Cyan + +New-Service ` + -Name $ServiceName ` + -DisplayName $DisplayName ` + -Description $Description ` + -BinaryPathName $ExePath ` + -StartupType Automatic | Out-Null + +sc.exe failure $ServiceName reset= 3600 actions= restart/5000/restart/10000/restart/30000 | Out-Null + +Write-Host "Starte Dienst..." -ForegroundColor Cyan +Start-Service -Name $ServiceName + +$svc = Get-Service -Name $ServiceName +Write-Host "" +Write-Host "Ergebnis:" -ForegroundColor Green +Write-Host " Name: $($svc.Name)" +Write-Host " Status: $($svc.Status)" +Write-Host " Start: $($svc.StartType)" +Write-Host "" +Write-Host "Fertig. MailPrint laeuft jetzt als Windows-Dienst." -ForegroundColor Green +Write-Host "Logs: $(Join-Path $PSScriptRoot 'publish\logs')" diff --git a/uninstall-service.ps1 b/uninstall-service.ps1 new file mode 100644 index 0000000..76268bf --- /dev/null +++ b/uninstall-service.ps1 @@ -0,0 +1,29 @@ +#Requires -RunAsAdministrator + +$ServiceName = "MailPrint" + +$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if (-not $svc) { + Write-Host "Dienst '$ServiceName' ist nicht installiert." -ForegroundColor Yellow + exit 0 +} + +if ($svc.Status -ne "Stopped") { + Write-Host "Stoppe Dienst '$ServiceName'..." -ForegroundColor Cyan + Stop-Service -Name $ServiceName -Force + Start-Sleep -Seconds 3 +} + +Write-Host "Entferne Dienst '$ServiceName'..." -ForegroundColor Cyan +sc.exe delete $ServiceName | Out-Null + +Start-Sleep -Seconds 2 + +$check = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($check) { + Write-Error "Dienst konnte nicht entfernt werden. Bitte manuell pruefen." + exit 1 +} + +Write-Host "" +Write-Host "Dienst '$ServiceName' erfolgreich deinstalliert." -ForegroundColor Green