|
|
@@ -0,0 +1,834 @@
|
|
|
+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
|
|
|
+{
|
|
|
+ /// <summary>
|
|
|
+ /// Delegate for handling email item state change events.
|
|
|
+ /// Allows subscribers to track processing progress and state transitions.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="sender">The processor instance that triggered the event</param>
|
|
|
+ /// <param name="recipient">Email address of the recipient being processed</param>
|
|
|
+ /// <param name="status">New state of the email item</param>
|
|
|
+ /// <param name="progress">Progress percentage (0-100)</param>
|
|
|
+ public delegate void EmailItemStateChangeHandler(object sender, string recipient, EmailStateEnum status, int progress);
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 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.
|
|
|
+ /// </summary>
|
|
|
+ public class EmailStorageProcessor : IDisposable
|
|
|
+ {
|
|
|
+ #region *** Private fields ***
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Base input directory where recipient folders containing JPG files are located
|
|
|
+ /// </summary>
|
|
|
+ private readonly string _inputPath;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Email template used for generating personalized emails
|
|
|
+ /// </summary>
|
|
|
+ private readonly EmailTemplate _emailTemplate;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Configuration containing API keys, SMTP settings, and other operational parameters
|
|
|
+ /// </summary>
|
|
|
+ private readonly EmailProcessorConfiguration _configuration;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Logger instance for this processor
|
|
|
+ /// </summary>
|
|
|
+ private readonly ILogger _logger;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Typed log instance for structured logging
|
|
|
+ /// </summary>
|
|
|
+ private readonly ILog _log;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// File system abstraction for testable file operations
|
|
|
+ /// </summary>
|
|
|
+ private readonly IFileSystem _fileSystem;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Service for uploading files to Temporary Shared Storage
|
|
|
+ /// </summary>
|
|
|
+ private readonly FileUploadService _uploadService;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// SMTP client for sending emails to recipients
|
|
|
+ /// </summary>
|
|
|
+ private readonly SmtpClient _smtpClient;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Cancellation token source for graceful shutdown of processing operations
|
|
|
+ /// </summary>
|
|
|
+ private CancellationTokenSource _cancellationTokenSource;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Lock object for thread-safe metadata file operations
|
|
|
+ /// </summary>
|
|
|
+ private readonly object _metadataLock = new object();
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Flag to track if this instance has been disposed
|
|
|
+ /// </summary>
|
|
|
+ private bool _disposed = false;
|
|
|
+
|
|
|
+ // Required directory paths for file management
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Directory for files currently being processed (compressed but not yet uploaded)
|
|
|
+ /// </summary>
|
|
|
+ private readonly string _processingDir;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Directory for files ready to be sent (uploaded and email being sent)
|
|
|
+ /// </summary>
|
|
|
+ private readonly string _sendingDir;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Directory for files that have been successfully sent
|
|
|
+ /// </summary>
|
|
|
+ private readonly string _sentDir;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Path to the metadata.json file that tracks all processing state
|
|
|
+ /// </summary>
|
|
|
+ private readonly string _metadataFile;
|
|
|
+
|
|
|
+ #endregion
|
|
|
+
|
|
|
+ #region *** Properties ***
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Current metadata containing all email items and their processing state
|
|
|
+ /// </summary>
|
|
|
+ public Metadata Metadata { get; set; } = new Metadata();
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Total number of email items being tracked
|
|
|
+ /// </summary>
|
|
|
+ public int CountTotal => Metadata.Items.Count;
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Number of items currently being processed or uploaded
|
|
|
+ /// </summary>
|
|
|
+ public int CountProcessing => Metadata.Items.Count(x => x.State == EmailStateEnum.Processing || x.State == EmailStateEnum.Uploading);
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Number of items currently being sent (email transmission in progress)
|
|
|
+ /// </summary>
|
|
|
+ public int CountSending => Metadata.Items.Count(x => x.State == EmailStateEnum.Sending);
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Number of items that have been successfully sent
|
|
|
+ /// </summary>
|
|
|
+ public int CountSent => Metadata.Items.Count(x => x.State == EmailStateEnum.Sent);
|
|
|
+
|
|
|
+ #endregion
|
|
|
+
|
|
|
+ #region *** Events ***
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Event fired when an email item's state changes during processing.
|
|
|
+ /// Useful for UI updates, progress tracking, and monitoring.
|
|
|
+ /// </summary>
|
|
|
+ public event EmailItemStateChangeHandler? EmailItemStateChanged;
|
|
|
+
|
|
|
+ #endregion
|
|
|
+
|
|
|
+ #region *** Constructor ***
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Initializes a new instance of the EmailStorageProcessor.
|
|
|
+ /// Sets up all required services, directory structure, and loads existing metadata.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="fs">File system abstraction for file operations</param>
|
|
|
+ /// <param name="path">Base path where recipient directories are located</param>
|
|
|
+ /// <param name="emailTemplate">Template for generating personalized emails</param>
|
|
|
+ /// <param name="configuration">Configuration with API keys and SMTP settings</param>
|
|
|
+ /// <param name="logger">Logger for operational logging</param>
|
|
|
+ /// <exception cref="ArgumentNullException">Thrown when any required parameter is null</exception>
|
|
|
+ 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 ***
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 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
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="recipient">Email address of the recipient to process</param>
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Processes all email items that are in Ready state sequentially.
|
|
|
+ /// Can be cancelled using the Stop() method which sets the cancellation token.
|
|
|
+ /// </summary>
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 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.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="recipient">Email address of the recipient to reset</param>
|
|
|
+ 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}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Gracefully stops all processing operations by setting the cancellation token.
|
|
|
+ /// Currently running operations will complete, but no new ones will start.
|
|
|
+ /// </summary>
|
|
|
+ public void Stop()
|
|
|
+ {
|
|
|
+ _cancellationTokenSource?.Cancel();
|
|
|
+ _log.Log(LogSeverityEnum.Info, "Processing stop requested.");
|
|
|
+ }
|
|
|
+
|
|
|
+ #endregion
|
|
|
+
|
|
|
+ #region Private Methods
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Creates the required directory structure for file management.
|
|
|
+ /// Sets up .processing, .sending, and .sent directories if they don't exist.
|
|
|
+ /// </summary>
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Loads existing metadata from metadata.json file or creates new metadata structure.
|
|
|
+ /// After loading, scans the input directory for new or changed recipient folders.
|
|
|
+ /// </summary>
|
|
|
+ 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<Metadata>(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();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 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
|
|
|
+ /// </summary>
|
|
|
+ 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}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Creates a new MetadataItem for a discovered directory.
|
|
|
+ /// Scans JPG files and extracts order numbers from filenames.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="directoryPath">Full path to the directory</param>
|
|
|
+ /// <param name="dirName">Directory name (typically email address)</param>
|
|
|
+ /// <param name="emailAddress">Email address for this item</param>
|
|
|
+ /// <returns>Newly created MetadataItem</returns>
|
|
|
+ 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))
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Updates an existing MetadataItem with current file information.
|
|
|
+ /// Refreshes file count and order numbers without changing processing state.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="item">Existing metadata item to update</param>
|
|
|
+ /// <param name="directoryPath">Path to the directory to scan</param>
|
|
|
+ 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));
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Extracts order numbers from JPG filenames using regex pattern matching.
|
|
|
+ /// Expects filenames in format: "{orderNumber}_{description}.jpg"
|
|
|
+ /// Example: "1001_photo1.jpg" extracts order "1001"
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="filePaths">Array of file paths to process</param>
|
|
|
+ /// <returns>List of unique order numbers found</returns>
|
|
|
+ private List<string> ExtractOrderNumbers(string[] filePaths)
|
|
|
+ {
|
|
|
+ var orders = new List<string>();
|
|
|
+ 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();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 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.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="item">Metadata item to process</param>
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Uploads the compressed ZIP file to Temporary Shared Storage service.
|
|
|
+ /// Generates download password and retrieves permalink for recipient access.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="item">Metadata item with compressed file to upload</param>
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Personalizes the email template with recipient-specific information and
|
|
|
+ /// moves the ZIP file from processing to sending directory.
|
|
|
+ /// Replaces template placeholders with actual values.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="item">Metadata item to prepare for sending</param>
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Sends the personalized email to the recipient using SMTP.
|
|
|
+ /// Email contains download link, password, and order information.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="item">Metadata item with email details to send</param>
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Handles the final result of email sending operation.
|
|
|
+ /// Moves files to appropriate directory and updates item state based on success/failure.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="item">Metadata item that was processed</param>
|
|
|
+ /// <param name="success">Whether the entire process completed successfully</param>
|
|
|
+ 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}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Updates an item's state and fires the state change event for subscribers.
|
|
|
+ /// Provides a centralized way to track state transitions.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="item">Item whose state is changing</param>
|
|
|
+ /// <param name="state">New state value</param>
|
|
|
+ /// <param name="progress">Progress percentage (0-100)</param>
|
|
|
+ private void UpdateItemState(MetadataItem item, EmailStateEnum state, int progress)
|
|
|
+ {
|
|
|
+ item.State = state;
|
|
|
+ EmailItemStateChanged?.Invoke(this, item.EmailAddress, state, progress);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Saves the current metadata to the metadata.json file in a thread-safe manner.
|
|
|
+ /// Updates the modification timestamp and formats JSON for readability.
|
|
|
+ /// </summary>
|
|
|
+ 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}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Removes all processed files associated with an email item.
|
|
|
+ /// Cleans up ZIP files from processing, sending, and sent directories.
|
|
|
+ /// Used during item reset operations.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="item">Item whose files should be cleaned up</param>
|
|
|
+ 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}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Normalizes an email address for use in filenames by replacing problematic characters.
|
|
|
+ /// Converts @ to _at_ and . to _dot_ to create filesystem-safe names.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="email">Email address to normalize</param>
|
|
|
+ /// <returns>Filesystem-safe version of the email address</returns>
|
|
|
+ private string NormalizeRecipientForFileName(string email)
|
|
|
+ {
|
|
|
+ return email.Replace("@", "_at_").Replace(".", "_dot_");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Generates a cryptographically secure random password for file downloads.
|
|
|
+ /// Creates a 12-character password using uppercase, lowercase, and numeric characters.
|
|
|
+ /// </summary>
|
|
|
+ /// <returns>Randomly generated password string</returns>
|
|
|
+ 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
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Disposes of the processor and all managed resources.
|
|
|
+ /// Implements the Dispose pattern for proper resource cleanup.
|
|
|
+ /// </summary>
|
|
|
+ public void Dispose()
|
|
|
+ {
|
|
|
+ Dispose(true);
|
|
|
+ GC.SuppressFinalize(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// Protected implementation of Dispose pattern.
|
|
|
+ /// Properly disposes of cancellation token and SMTP client resources.
|
|
|
+ /// </summary>
|
|
|
+ /// <param name="disposing">True if disposing managed resources</param>
|
|
|
+ protected virtual void Dispose(bool disposing)
|
|
|
+ {
|
|
|
+ if (!_disposed)
|
|
|
+ {
|
|
|
+ if (disposing)
|
|
|
+ {
|
|
|
+ // Dispose managed resources
|
|
|
+ _cancellationTokenSource?.Cancel();
|
|
|
+ _cancellationTokenSource?.Dispose();
|
|
|
+ _smtpClient?.Dispose();
|
|
|
+ }
|
|
|
+ _disposed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ #endregion
|
|
|
+ }
|
|
|
+}
|