using Quadarax.Application.TemporarySharedStorage.Client.Dtos.Request; using Quadarax.Application.TemporarySharedStorage.Client.Services; using Quadarax.Foundation.Core.Logging; using System.IO.Abstractions; using System.IO.Compression; using System.Net.Mail; using System.Text.Json; using System.Text.RegularExpressions; namespace qdr.app.studiou.orders2printpack.EmailStorage { /// /// Delegate for handling email item state change events. /// Allows subscribers to track processing progress and state transitions. /// /// The processor instance that triggered the event /// Email address of the recipient being processed /// New state of the email item /// Progress percentage (0-100) public delegate void EmailItemStateChangeHandler(object sender, string recipient, EmailStateEnum status, int progress); /// /// Main processor class responsible for handling the complete email workflow: /// 1. Scanning directories for photo files /// 2. Compressing files into ZIP archives /// 3. Uploading to Temporary Shared Storage /// 4. Sending personalized emails with download links /// 5. Managing file lifecycle and cleanup /// /// This class implements IDisposable to properly clean up resources like SMTP connections. /// public class EmailStorageProcessor : IDisposable { #region *** Private fields *** /// /// Base input directory where recipient folders containing JPG files are located /// private readonly string _inputPath; /// /// Email template used for generating personalized emails /// private readonly EmailTemplate _emailTemplate; /// /// Configuration containing API keys, SMTP settings, and other operational parameters /// private readonly EmailProcessorConfiguration _configuration; /// /// Logger instance for this processor /// private readonly ILogger _logger; /// /// Typed log instance for structured logging /// private readonly ILog _log; /// /// File system abstraction for testable file operations /// private readonly IFileSystem _fileSystem; /// /// Service for uploading files to Temporary Shared Storage /// private readonly FileUploadService _uploadService; /// /// SMTP client for sending emails to recipients /// private readonly SmtpClient _smtpClient; /// /// Cancellation token source for graceful shutdown of processing operations /// private CancellationTokenSource _cancellationTokenSource; /// /// Lock object for thread-safe metadata file operations /// private readonly object _metadataLock = new object(); /// /// Flag to track if this instance has been disposed /// private bool _disposed = false; // Required directory paths for file management /// /// Directory for files currently being processed (compressed but not yet uploaded) /// private readonly string _processingDir; /// /// Directory for files ready to be sent (uploaded and email being sent) /// private readonly string _sendingDir; /// /// Directory for files that have been successfully sent /// private readonly string _sentDir; /// /// Path to the metadata.json file that tracks all processing state /// private readonly string _metadataFile; #endregion #region *** Properties *** /// /// Current metadata containing all email items and their processing state /// public Metadata Metadata { get; set; } = new Metadata(); /// /// Total number of email items being tracked /// public int CountTotal => Metadata.Items.Count; /// /// Number of items currently being processed or uploaded /// public int CountProcessing => Metadata.Items.Count(x => x.State == EmailStateEnum.Processing || x.State == EmailStateEnum.Uploading); /// /// Number of items currently being sent (email transmission in progress) /// public int CountSending => Metadata.Items.Count(x => x.State == EmailStateEnum.Sending); /// /// Number of items that have been successfully sent /// public int CountSent => Metadata.Items.Count(x => x.State == EmailStateEnum.Sent); #endregion #region *** Events *** /// /// Event fired when an email item's state changes during processing. /// Useful for UI updates, progress tracking, and monitoring. /// public event EmailItemStateChangeHandler? EmailItemStateChanged; #endregion #region *** Constructor *** /// /// Initializes a new instance of the EmailStorageProcessor. /// Sets up all required services, directory structure, and loads existing metadata. /// /// File system abstraction for file operations /// Base path where recipient directories are located /// Template for generating personalized emails /// Configuration with API keys and SMTP settings /// Logger for operational logging /// Thrown when any required parameter is null public EmailStorageProcessor(IFileSystem fs, string path, EmailTemplate emailTemplate, EmailProcessorConfiguration configuration, ILogger logger) { // Validate all required dependencies _fileSystem = fs ?? throw new ArgumentNullException(nameof(fs)); _inputPath = path ?? throw new ArgumentNullException(nameof(path)); _emailTemplate = emailTemplate ?? throw new ArgumentNullException(nameof(emailTemplate)); _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // Initialize logging _log = _logger.GetLogger(typeof(EmailStorageProcessor)); _cancellationTokenSource = new CancellationTokenSource(); // Setup directory paths using IFileSystem for testability _processingDir = _fileSystem.Path.Combine(_inputPath, ".processing"); _sendingDir = _fileSystem.Path.Combine(_inputPath, ".sending"); _sentDir = _fileSystem.Path.Combine(_inputPath, ".sent"); _metadataFile = _fileSystem.Path.Combine(_inputPath, "metadata.json"); // Initialize external services _uploadService = new FileUploadService(configuration.TssUrl, configuration.TssAPIKey, logger); // Setup SMTP client with provided configuration _smtpClient = new SmtpClient(configuration.SmtpServer, configuration.SmtpServerPort) { Credentials = new System.Net.NetworkCredential(configuration.SmtpServerUser, configuration.SmtpServerPassword), EnableSsl = configuration.SmtpEnableSsl }; // Initialize the processing environment InitializeDirectoryStructure(); LoadOrCreateMetadata(); } #endregion #region *** Public Methods *** /// /// Processes a single email item through the complete workflow: /// 1. Compress JPG files into ZIP archive /// 2. Upload to Temporary Shared Storage /// 3. Generate personalized email with download link /// 4. Send email to recipient /// 5. Move files to appropriate directory based on success/failure /// /// Email address of the recipient to process public async Task ProcessItemAsync(string recipient) { try { // Find the metadata item for this recipient var item = Metadata.Items.FirstOrDefault(x => x.EmailAddress == recipient); if (item == null) { _log.Log(LogSeverityEnum.Error, $"Item with recipient '{recipient}' not found."); return; } _log.Log(LogSeverityEnum.Info, $"Starting processing for recipient: {recipient}"); // Execute the complete workflow await CompressFilesAsync(item); // Step 1: Create ZIP archive await UploadCompressedFileAsync(item); // Step 2: Upload to TSS await PrepareEmailAndMoveAsync(item); // Step 3: Prepare email content await SendEmailAsync(item); // Step 4: Send email await HandleSendingResultAsync(item, true); // Step 5: Handle success _log.Log(LogSeverityEnum.Info, $"Successfully processed recipient: {recipient}"); } catch (Exception ex) { // Log error and mark item as failed _log.Log(LogSeverityEnum.Error, $"Error processing recipient '{recipient}': {ex.Message}"); var item = Metadata.Items.FirstOrDefault(x => x.EmailAddress == recipient); if (item != null) { item.ErrorMessage = ex.Message; await HandleSendingResultAsync(item, false); } } } /// /// Processes all email items that are in Ready state sequentially. /// Can be cancelled using the Stop() method which sets the cancellation token. /// public async Task ProcessAllAsync() { // Get all items that are ready for processing var readyItems = Metadata.Items.Where(x => x.State == EmailStateEnum.Ready).ToList(); foreach (var item in readyItems) { // Check for cancellation request if (_cancellationTokenSource.Token.IsCancellationRequested) break; await ProcessItemAsync(item.EmailAddress); } } /// /// Resets an email item back to Ready state and cleans up associated files. /// This allows reprocessing of items that failed or need to be sent again. /// Items cannot be reset while actively being processed or uploaded. /// /// Email address of the recipient to reset public void ResetItem(string recipient) { try { var item = Metadata.Items.FirstOrDefault(x => x.EmailAddress == recipient); if (item == null) { _log.Log(LogSeverityEnum.Warn, $"Item with recipient '{recipient}' not found for reset."); return; } // Clean up any existing processed files CleanupItemFiles(item); // Reset all processing-related fields to initial state item.State = EmailStateEnum.Ready; item.FileNameAttachment = string.Empty; item.SizeAttachment = 0; item.UploadPermalink = string.Empty; item.DownloadPassword = string.Empty; item.ProcessedDate = null; item.SentDate = null; item.ErrorMessage = null; // Notify listeners and save changes UpdateItemState(item, EmailStateEnum.Ready, 0); SaveMetadata(); _log.Log(LogSeverityEnum.Info, $"Reset item for recipient: {recipient}"); } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error resetting item for recipient '{recipient}': {ex.Message}"); } } /// /// Gracefully stops all processing operations by setting the cancellation token. /// Currently running operations will complete, but no new ones will start. /// public void Stop() { _cancellationTokenSource?.Cancel(); _log.Log(LogSeverityEnum.Info, "Processing stop requested."); } #endregion #region Private Methods /// /// Creates the required directory structure for file management. /// Sets up .processing, .sending, and .sent directories if they don't exist. /// private void InitializeDirectoryStructure() { try { _fileSystem.Directory.CreateDirectory(_processingDir); _fileSystem.Directory.CreateDirectory(_sendingDir); _fileSystem.Directory.CreateDirectory(_sentDir); _log.Log(LogSeverityEnum.Info, "Directory structure initialized."); } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error initializing directory structure: {ex.Message}"); throw; } } /// /// Loads existing metadata from metadata.json file or creates new metadata structure. /// After loading, scans the input directory for new or changed recipient folders. /// private void LoadOrCreateMetadata() { try { lock (_metadataLock) { if (_fileSystem.File.Exists(_metadataFile)) { // Load existing metadata from file var json = _fileSystem.File.ReadAllText(_metadataFile); Metadata = JsonSerializer.Deserialize(json) ?? new Metadata(); _log.Log(LogSeverityEnum.Info, "Metadata loaded from file."); } else { // Create new metadata structure Metadata = new Metadata(); _log.Log(LogSeverityEnum.Info, "New metadata created."); } // Scan directories and update metadata with current state ScanAndUpdateMetadata(); } } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error loading/creating metadata: {ex.Message}"); Metadata = new Metadata(); } } /// /// Scans the input directory for recipient folders and updates metadata accordingly. /// - Adds new items for newly discovered directories /// - Updates file counts and order information for existing items /// - Removes items for directories that no longer exist /// private void ScanAndUpdateMetadata() { try { // Get all directories that don't start with "." (hidden/system directories) var directories = _fileSystem.Directory.GetDirectories(_inputPath) .Where(d => !_fileSystem.Path.GetFileName(d).StartsWith(".")) .ToList(); foreach (var dir in directories) { var dirName = _fileSystem.Path.GetFileName(dir); var emailAddress = dirName; // Assume directory name is the email address var existingItem = Metadata.Items.FirstOrDefault(x => x.DirectoryName == dirName); if (existingItem == null) { // Create new metadata item for this directory var newItem = CreateMetadataItem(dir, dirName, emailAddress); Metadata.Items.Add(newItem); _log.Log(LogSeverityEnum.Info, $"Added new item for directory: {dirName}"); } else { // Update existing item with current file information UpdateMetadataItem(existingItem, dir); } } // Clean up metadata for directories that no longer exist var existingDirs = directories.Select(d => _fileSystem.Path.GetFileName(d)).ToHashSet(); Metadata.Items.RemoveAll(item => !existingDirs.Contains(item.DirectoryName)); // Save updated metadata Metadata.Modified = DateTime.Now; SaveMetadata(); } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error scanning and updating metadata: {ex.Message}"); } } /// /// Creates a new MetadataItem for a discovered directory. /// Scans JPG files and extracts order numbers from filenames. /// /// Full path to the directory /// Directory name (typically email address) /// Email address for this item /// Newly created MetadataItem private MetadataItem CreateMetadataItem(string directoryPath, string dirName, string emailAddress) { var jpgFiles = _fileSystem.Directory.GetFiles(directoryPath, "*.jpg"); var orders = ExtractOrderNumbers(jpgFiles); return new MetadataItem { DirectoryName = dirName, EmailAddress = emailAddress, State = EmailStateEnum.Ready, Subject = _emailTemplate.Subject, Body = _emailTemplate.Body, InternalFilesCount = jpgFiles.Length, Orders = string.Join(",", orders.OrderBy(x => x)) }; } /// /// Updates an existing MetadataItem with current file information. /// Refreshes file count and order numbers without changing processing state. /// /// Existing metadata item to update /// Path to the directory to scan private void UpdateMetadataItem(MetadataItem item, string directoryPath) { var jpgFiles = _fileSystem.Directory.GetFiles(directoryPath, "*.jpg"); var orders = ExtractOrderNumbers(jpgFiles); item.InternalFilesCount = jpgFiles.Length; item.Orders = string.Join(",", orders.OrderBy(x => x)); } /// /// Extracts order numbers from JPG filenames using regex pattern matching. /// Expects filenames in format: "{orderNumber}_{description}.jpg" /// Example: "1001_photo1.jpg" extracts order "1001" /// /// Array of file paths to process /// List of unique order numbers found private List ExtractOrderNumbers(string[] filePaths) { var orders = new List(); var pattern = @"^(\d+)_.*\.jpg$"; // Match: digits_anything.jpg foreach (var filePath in filePaths) { var fileName = _fileSystem.Path.GetFileName(filePath); var match = Regex.Match(fileName, pattern); if (match.Success) { orders.Add(match.Groups[1].Value); } } return orders.Distinct().ToList(); } /// /// Compresses all JPG files in the item's directory into a ZIP archive. /// Updates processing state and progress throughout the operation. /// Generated ZIP filename includes normalized recipient and timestamp. /// /// Metadata item to process private async Task CompressFilesAsync(MetadataItem item) { UpdateItemState(item, EmailStateEnum.Processing, 10); var sourceDir = _fileSystem.Path.Combine(_inputPath, item.DirectoryName); var normalizedRecipient = NormalizeRecipientForFileName(item.EmailAddress); var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); var zipFileName = $"studioufoto_{normalizedRecipient}_{timestamp}.zip"; var zipPath = _fileSystem.Path.Combine(_processingDir, zipFileName); try { var jpgFiles = _fileSystem.Directory.GetFiles(sourceDir, "*.jpg"); // Create ZIP archive using FileSystem abstraction for testability using (var zipFileStream = _fileSystem.File.Create(zipPath)) using (var zip = new ZipArchive(zipFileStream, ZipArchiveMode.Create)) { for (int i = 0; i < jpgFiles.Length; i++) { var file = jpgFiles[i]; var entryName = _fileSystem.Path.GetFileName(file); var entry = zip.CreateEntry(entryName); // Copy file content to ZIP entry using (var entryStream = entry.Open()) using (var fileStream = _fileSystem.File.OpenRead(file)) { await fileStream.CopyToAsync(entryStream); } // Update progress (10-40% for compression phase) var progress = 10 + (int)((i + 1.0) / jpgFiles.Length * 30); UpdateItemState(item, EmailStateEnum.Processing, progress); } } // Update metadata with compression results var fileInfo = _fileSystem.FileInfo.New(zipPath); item.FileNameAttachment = zipFileName; item.SizeAttachment = (int)fileInfo.Length; item.ProcessedDate = DateTime.Now; SaveMetadata(); _log.Log(LogSeverityEnum.Info, $"Compressed {jpgFiles.Length} files to: {zipFileName}"); } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error compressing files for {item.EmailAddress}: {ex.Message}"); throw; } } /// /// Uploads the compressed ZIP file to Temporary Shared Storage service. /// Generates download password and retrieves permalink for recipient access. /// /// Metadata item with compressed file to upload private async Task UploadCompressedFileAsync(MetadataItem item) { UpdateItemState(item, EmailStateEnum.Uploading, 50); var zipPath = _fileSystem.Path.Combine(_processingDir, item.FileNameAttachment); try { // Prepare upload request with file details and security settings var uploadRequest = new UploadFileRequest { FilePath = zipPath, Description = $"Studio photos for {item.EmailAddress}", Password = GenerateDownloadPassword(), Message = "Studio photos ready for download", Reference = $"STUDIO_{NormalizeRecipientForFileName(item.EmailAddress)}", ActiveFrom = DateTime.Now, ActiveTo = DateTime.Now.AddDays(30), // 30-day expiration ChunkSize = 1048576 // 1MB chunks for reliable upload }; // Execute upload and capture result var result = await _uploadService.UploadFileAsync(uploadRequest); // Store upload results in metadata item.UploadPermalink = result.Item1; item.DownloadPassword = uploadRequest.Password; UpdateItemState(item, EmailStateEnum.Uploading, 80); SaveMetadata(); _log.Log(LogSeverityEnum.Info, $"Successfully uploaded file for {item.EmailAddress}. Permalink: {result.Item1}"); } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error uploading file for {item.EmailAddress}: {ex.Message}"); throw; } } /// /// Personalizes the email template with recipient-specific information and /// moves the ZIP file from processing to sending directory. /// Replaces template placeholders with actual values. /// /// Metadata item to prepare for sending private async Task PrepareEmailAndMoveAsync(MetadataItem item) { try { // Personalize email body by replacing template placeholders var personalizedBody = _emailTemplate.Body .Replace("{{DOWNLOAD_LINK}}", item.UploadPermalink) .Replace("{{PASSWORD}}", item.DownloadPassword) .Replace("{{RECIPIENT}}", item.EmailAddress) .Replace("{{ORDERS}}", item.Orders); item.Body = personalizedBody; // Move ZIP file from processing to sending directory var sourceFile = _fileSystem.Path.Combine(_processingDir, item.FileNameAttachment); var targetFile = _fileSystem.Path.Combine(_sendingDir, item.FileNameAttachment); if (_fileSystem.File.Exists(sourceFile)) { _fileSystem.File.Move(sourceFile, targetFile); } UpdateItemState(item, EmailStateEnum.Sending, 90); SaveMetadata(); _log.Log(LogSeverityEnum.Info, $"Prepared email and moved file to sending for {item.EmailAddress}"); } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error preparing email for {item.EmailAddress}: {ex.Message}"); throw; } } /// /// Sends the personalized email to the recipient using SMTP. /// Email contains download link, password, and order information. /// /// Metadata item with email details to send private async Task SendEmailAsync(MetadataItem item) { try { using (var message = new MailMessage()) { message.To.Add(item.EmailAddress); message.Subject = item.Subject; message.Body = item.Body; message.IsBodyHtml = true; // Support HTML formatting in email body await _smtpClient.SendMailAsync(message); } item.SentDate = DateTime.Now; _log.Log(LogSeverityEnum.Info, $"Email sent successfully to {item.EmailAddress}"); } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error sending email to {item.EmailAddress}: {ex.Message}"); throw; } } /// /// Handles the final result of email sending operation. /// Moves files to appropriate directory and updates item state based on success/failure. /// /// Metadata item that was processed /// Whether the entire process completed successfully private async Task HandleSendingResultAsync(MetadataItem item, bool success) { try { var sourceFile = _fileSystem.Path.Combine(_sendingDir, item.FileNameAttachment); if (success) { // Move to sent folder for successful completions var targetFile = _fileSystem.Path.Combine(_sentDir, item.FileNameAttachment); if (_fileSystem.File.Exists(sourceFile)) { _fileSystem.File.Move(sourceFile, targetFile); } UpdateItemState(item, EmailStateEnum.Sent, 100); _log.Log(LogSeverityEnum.Info, $"Successfully completed processing for {item.EmailAddress}"); } else { // Keep in sending folder for failed attempts, set error state UpdateItemState(item, EmailStateEnum.Error, 0); _log.Log(LogSeverityEnum.Error, $"Failed to complete processing for {item.EmailAddress}"); } SaveMetadata(); } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error handling sending result for {item.EmailAddress}: {ex.Message}"); } } /// /// Updates an item's state and fires the state change event for subscribers. /// Provides a centralized way to track state transitions. /// /// Item whose state is changing /// New state value /// Progress percentage (0-100) private void UpdateItemState(MetadataItem item, EmailStateEnum state, int progress) { item.State = state; EmailItemStateChanged?.Invoke(this, item.EmailAddress, state, progress); } /// /// Saves the current metadata to the metadata.json file in a thread-safe manner. /// Updates the modification timestamp and formats JSON for readability. /// private void SaveMetadata() { try { lock (_metadataLock) { Metadata.Modified = DateTime.Now; var json = JsonSerializer.Serialize(Metadata, new JsonSerializerOptions { WriteIndented = true // Pretty-print for human readability }); _fileSystem.File.WriteAllText(_metadataFile, json); } } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error saving metadata: {ex.Message}"); } } /// /// Removes all processed files associated with an email item. /// Cleans up ZIP files from processing, sending, and sent directories. /// Used during item reset operations. /// /// Item whose files should be cleaned up private void CleanupItemFiles(MetadataItem item) { try { var filesToClean = new[] { _fileSystem.Path.Combine(_processingDir, item.FileNameAttachment), _fileSystem.Path.Combine(_sendingDir, item.FileNameAttachment), _fileSystem.Path.Combine(_sentDir, item.FileNameAttachment) }; foreach (var file in filesToClean) { if (_fileSystem.File.Exists(file)) { _fileSystem.File.Delete(file); _log.Log(LogSeverityEnum.Info, $"Deleted file: {file}"); } } } catch (Exception ex) { _log.Log(LogSeverityEnum.Error, $"Error cleaning up files for {item.EmailAddress}: {ex.Message}"); } } /// /// Normalizes an email address for use in filenames by replacing problematic characters. /// Converts @ to _at_ and . to _dot_ to create filesystem-safe names. /// /// Email address to normalize /// Filesystem-safe version of the email address private string NormalizeRecipientForFileName(string email) { return email.Replace("@", "_at_").Replace(".", "_dot_"); } /// /// Generates a cryptographically secure random password for file downloads. /// Creates a 12-character password using uppercase, lowercase, and numeric characters. /// /// Randomly generated password string private string GenerateDownloadPassword() { var random = new Random(); const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; return new string(Enumerable.Repeat(chars, 12) .Select(s => s[random.Next(s.Length)]).ToArray()); } #endregion #region IDisposable /// /// Disposes of the processor and all managed resources. /// Implements the Dispose pattern for proper resource cleanup. /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Protected implementation of Dispose pattern. /// Properly disposes of cancellation token and SMTP client resources. /// /// True if disposing managed resources protected virtual void Dispose(bool disposing) { if (!_disposed) { if (disposing) { // Dispose managed resources _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); _smtpClient?.Dispose(); } _disposed = true; } } #endregion } }