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
}
}