11 Коммиты cfb02a7eba ... 4783a9805b

Автор SHA1 Сообщение Дата
  Dalibor Votruba 4783a9805b Merge branch '1.3' 1 месяц назад
  Dalibor Votruba 30cb1d2ecd Fix variant price reset on Import; bump version to 1.3.4 1 месяц назад
  Dalibor Votruba 8c9758f9ae Merge branch '1.3' 9 месяцев назад
  Dalibor Votruba add0b06b9e change version of setup to 1.3.3 9 месяцев назад
  Dalibor Votruba 7ef7205769 add processing email tag in LabelPrint. Change version to 1.3.3 9 месяцев назад
  Dalibor Votruba 81ddff16b1 change setup version to 1.3.2 9 месяцев назад
  Dalibor Votruba b2e5ca0c8d Fix aggregation for quantity during Generate Labels. Change version to 1.3.2 9 месяцев назад
  Dalibor Votruba 122c5eaa27 add column [CsvColumn("Vlastnost 1 varianta")] v.1.3.1 9 месяцев назад
  Dalibor Votruba 65c194a850 add EmailStorageProcessorTests 1 год назад
  Dalibor Votruba 545a520574 sdd UT for EmailItem 1 год назад
  Dalibor Votruba 4f309d0f7c change file structure, add empty UT, add EmailStorageProcessor 1 год назад
72 измененных файлов с 3812 добавлено и 114 удалено
  1. 13 1
      App/App.config
  2. 0 0
      App/Design.Designer.cs
  3. 0 0
      App/Design.cs
  4. 0 0
      App/Design.resx
  5. 219 0
      App/EmailStorage/EmailItem.cs
  6. 82 0
      App/EmailStorage/EmailProcessorConfiguration.cs
  7. 53 0
      App/EmailStorage/EmailStateEnum.cs
  8. 834 0
      App/EmailStorage/EmailStorageProcessor.cs
  9. 52 0
      App/EmailStorage/EmailTemplate.cs
  10. 186 0
      App/EmailStorage/Metadata.cs
  11. 0 0
      App/Extensions/CsvParserUtility.cs
  12. 0 0
      App/Extensions/CsvUtils.cs
  13. 0 0
      App/Extensions/ExtendedParametrizedString.cs
  14. 0 0
      App/Extensions/ListViewExtension.cs
  15. 0 0
      App/Extensions/StringExt.cs
  16. 0 0
      App/FormBatch.Designer.cs
  17. 0 0
      App/FormBatch.cs
  18. 0 0
      App/FormBatch.resx
  19. 256 0
      App/FormDigitalSend.Designer.cs
  20. 145 0
      App/FormDigitalSend.cs
  21. 132 0
      App/FormDigitalSend.resx
  22. 0 0
      App/FormLabelPrint.Designer.cs
  23. 58 2
      App/FormLabelPrint.cs
  24. 0 0
      App/FormLabelPrint.resx
  25. 19 6
      App/FormLauncher.Designer.cs
  26. 6 0
      App/FormLauncher.cs
  27. 0 0
      App/FormLauncher.resx
  28. 0 0
      App/FormProductEdit.Designer.cs
  29. 2 0
      App/FormProductEdit.cs
  30. 0 0
      App/FormProductEdit.resx
  31. 0 0
      App/LICENSE
  32. 0 0
      App/Labels/ExcelLabelGenerator.cs
  33. 0 0
      App/ProductStorage/CategoryDto.cs
  34. 0 0
      App/ProductStorage/IIdentifierDto.cs
  35. 3 0
      App/ProductStorage/ProductDto.cs
  36. 7 2
      App/ProductStorage/ProductStorage.cs
  37. 0 0
      App/ProductStorage/VariantDto.cs
  38. 0 0
      App/Program.cs
  39. 50 2
      App/Properties/AppSettings.Designer.cs
  40. 13 1
      App/Properties/AppSettings.settings
  41. 0 0
      App/Properties/PublishProfiles/FolderProfile.pubxml
  42. 0 0
      App/Properties/Resources.Designer.cs
  43. 0 0
      App/Properties/Resources.resx
  44. 65 0
      App/README.md
  45. 0 0
      App/Resources/logo.png
  46. 0 0
      App/Resources/mail_header.jpg
  47. 86 8
      App/dlgEditCfg.Designer.cs
  48. 17 0
      App/dlgEditCfg.cs
  49. 0 0
      App/dlgEditCfg.resx
  50. 0 0
      App/dlgImport.Designer.cs
  51. 0 0
      App/dlgImport.cs
  52. 0 0
      App/dlgImport.resx
  53. 2 2
      App/dlgLabelSettings.Designer.cs
  54. 0 0
      App/dlgLabelSettings.cs
  55. 0 0
      App/dlgLabelSettings.resx
  56. 0 0
      App/dlgProtocol.Designer.cs
  57. 0 0
      App/dlgProtocol.cs
  58. 0 0
      App/dlgProtocol.resx
  59. 0 0
      App/dlgSettings.Designer.cs
  60. 0 0
      App/dlgSettings.cs
  61. 0 0
      App/dlgSettings.resx
  62. 0 0
      App/logo_icon.ico
  63. 6 5
      App/qdr.app.studiou.orders2printpack.csproj
  64. 62 0
      CLAUDE.md
  65. 117 9
      README.md
  66. 5 5
      Setup/Setup.vdproj
  67. 597 0
      Test/EmailItemTests.cs
  68. 592 0
      Test/EmailStorageProcessorTests.cs
  69. 89 0
      Test/ProductStorageTests.cs
  70. 29 0
      Test/qdr.app.studiou.orders2printpack.Test.csproj
  71. 0 70
      qdr.app.studiou.orders2printpack.csproj.Backup.tmp
  72. 15 1
      qdr.app.studiou.orders2printpack.sln

+ 13 - 1
App.config → App/App.config

@@ -72,7 +72,7 @@
                 <value>8</value>
             </setting>
             <setting name="LabelTemplate" serializeAs="String">
-                <value>^^18^**Objednávka: {order_no}**^^{cr}^^14^**{prod_cat}**^^{cr}{prod_var_type_list}</value>
+                <value>^^18^**Objednávka: {order_no}**^^{cr}{email}{cr}^^14^**{prod_cat}**^^{cr}{prod_var_type_list}</value>
             </setting>
             <setting name="CSVDelimiter" serializeAs="String">
                 <value>,</value>
@@ -89,6 +89,18 @@
             <setting name="FormatDigitalContains" serializeAs="String">
                 <value>Digitální data</value>
             </setting>
+            <setting name="TSSApiKey" serializeAs="String">
+                <value>riyeVPRvz9QF1YmrhdSz9rgiVzhy3mSb</value>
+            </setting>
+            <setting name="TSSChunkSizeBytes" serializeAs="String">
+                <value>2097152</value>
+            </setting>
+            <setting name="TSSUri" serializeAs="String">
+                <value>https://www.studiou.cz/</value>
+            </setting>
+            <setting name="LastDigitalDir" serializeAs="String">
+                <value />
+            </setting>
         </qdr.app.studiou.orders2printpack.Properties.AppSettings>
     </userSettings>
 </configuration>

+ 0 - 0
Design.Designer.cs → App/Design.Designer.cs


+ 0 - 0
Design.cs → App/Design.cs


+ 0 - 0
Design.resx → App/Design.resx


+ 219 - 0
App/EmailStorage/EmailItem.cs

@@ -0,0 +1,219 @@
+using System.IO.Abstractions;
+
+namespace qdr.app.studiou.orders2printpack.EmailStorage
+{
+    /// <summary>
+    /// Helper class for working with email items and their file operations.
+    /// Provides a convenient wrapper around MetadataItem with additional functionality
+    /// for file system operations, validation, and state management.
+    /// </summary>
+    public class EmailItem
+    {
+        #region Private Fields
+        
+        /// <summary>
+        /// The underlying metadata item containing email and processing information
+        /// </summary>
+        private readonly MetadataItem _metadata;
+        
+        /// <summary>
+        /// Base directory path where email item directories are located
+        /// </summary>
+        private readonly string _basePath;
+
+        /// <summary>
+        /// File system abstraction for testable file operations
+        /// </summary>
+        private readonly IFileSystem _fileSystem;
+
+        #endregion
+
+        #region Constructor
+
+        /// <summary>
+        /// Initializes a new instance of the EmailItem class
+        /// </summary>
+        /// <param name="metadata">The metadata item containing email information</param>
+        /// <param name="basePath">Base directory path for file operations</param>
+        /// <param name="fileSystem">File system abstraction for file operations</param>
+        /// <exception cref="ArgumentNullException">Thrown when metadata, basePath, or fileSystem is null</exception>
+        public EmailItem(MetadataItem metadata, string basePath, IFileSystem fileSystem)
+        {
+            _metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
+            _basePath = basePath ?? throw new ArgumentNullException(nameof(basePath));
+            _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+        }
+
+        #endregion
+
+        #region Public Properties
+
+        /// <summary>
+        /// Gets the underlying metadata item
+        /// </summary>
+        public MetadataItem Metadata => _metadata;
+        
+        /// <summary>
+        /// Gets the full directory path for this email item
+        /// Combines the base path with the directory name from metadata
+        /// </summary>
+        public string DirectoryPath => _fileSystem.Path.Combine(_basePath, _metadata.DirectoryName);
+        
+        /// <summary>
+        /// Gets a value indicating whether the directory exists on the file system
+        /// </summary>
+        public bool DirectoryExists => _fileSystem.Directory.Exists(DirectoryPath);
+        
+        /// <summary>
+        /// Gets all JPG files in the email item's directory
+        /// Returns an empty enumerable if the directory doesn't exist
+        /// </summary>
+        public IEnumerable<string> JpgFiles => 
+            DirectoryExists ? _fileSystem.Directory.GetFiles(DirectoryPath, "*.jpg") : Enumerable.Empty<string>();
+        
+        /// <summary>
+        /// Gets the count of JPG files in the directory
+        /// </summary>
+        public int FileCount => JpgFiles.Count();
+        
+        /// <summary>
+        /// Gets the total size in bytes of all JPG files in the directory
+        /// </summary>
+        public long TotalFileSize => JpgFiles.Sum(f => _fileSystem.FileInfo.New(f).Length);
+        
+        /// <summary>
+        /// Gets a value indicating whether this item is ready for processing
+        /// Checks that the state is Ready, directory exists, and contains files
+        /// </summary>
+        public bool IsReadyForProcessing => 
+            _metadata.State == EmailStateEnum.Ready && DirectoryExists && FileCount > 0;
+        
+        /// <summary>
+        /// Gets a value indicating whether this item can be reset
+        /// Items cannot be reset while they are being processed or uploaded
+        /// </summary>
+        public bool CanBeReset => 
+            _metadata.State != EmailStateEnum.Processing && 
+            _metadata.State != EmailStateEnum.Uploading;
+
+        /// <summary>
+        /// Gets the normalized filename for the recipient (safe for file system)
+        /// Replaces @ with _at_ and . with _dot_ to create filesystem-safe names
+        /// </summary>
+        public string NormalizedRecipient => 
+            _metadata.EmailAddress.Replace("@", "_at_").Replace(".", "_dot_");
+
+        #endregion
+
+        #region Public Methods
+
+        /// <summary>
+        /// Generates the zip filename for this item based on recipient and timestamp
+        /// </summary>
+        /// <param name="timestamp">Optional timestamp to use, defaults to current time</param>
+        /// <returns>A formatted zip filename in the format: studioufoto_{recipient}_{timestamp}.zip</returns>
+        public string GenerateZipFileName(DateTime? timestamp = null) =>
+            $"studioufoto_{NormalizedRecipient}_{(timestamp ?? DateTime.Now):yyyyMMdd_HHmmss}.zip";
+
+        /// <summary>
+        /// Validates that the item is in a consistent state for processing
+        /// </summary>
+        /// <returns>A tuple containing validation result and list of issues found</returns>
+        public (bool IsValid, List<string> Issues) Validate()
+        {
+            var issues = new List<string>();
+
+            // Check required email address
+            if (string.IsNullOrEmpty(_metadata.EmailAddress))
+                issues.Add("Email address is required");
+
+            // Check directory and files existence
+            if (!DirectoryExists)
+                issues.Add($"Directory does not exist: {DirectoryPath}");
+            else if (FileCount == 0)
+                issues.Add("No JPG files found in directory");
+
+            // Check required email content
+            if (string.IsNullOrEmpty(_metadata.Subject))
+                issues.Add("Email subject is required");
+
+            if (string.IsNullOrEmpty(_metadata.Body))
+                issues.Add("Email body is required");
+
+            return (issues.Count == 0, issues);
+        }
+
+        /// <summary>
+        /// Gets a summary of the item for display purposes
+        /// Includes recipient, file count, current state, and associated orders
+        /// </summary>
+        /// <returns>A formatted summary string</returns>
+        public string GetSummary() =>
+            $"{_metadata.EmailAddress} - {FileCount} files, {_metadata.State}, Orders: {_metadata.Orders}";
+
+        /// <summary>
+        /// Gets detailed file information for all JPG files in the directory
+        /// </summary>
+        /// <returns>Collection of file information objects</returns>
+        public IEnumerable<IFileInfo> GetFileDetails() =>
+            JpgFiles.Select(f => _fileSystem.FileInfo.New(f));
+
+        /// <summary>
+        /// Checks if a specific file exists in the email item's directory
+        /// </summary>
+        /// <param name="fileName">Name of the file to check</param>
+        /// <returns>True if the file exists, false otherwise</returns>
+        public bool FileExists(string fileName)
+        {
+            if (string.IsNullOrEmpty(fileName))
+                return false;
+
+            var filePath = _fileSystem.Path.Combine(DirectoryPath, fileName);
+            return _fileSystem.File.Exists(filePath);
+        }
+
+        /// <summary>
+        /// Gets the full path for a file within the email item's directory
+        /// </summary>
+        /// <param name="fileName">Name of the file</param>
+        /// <returns>Full path to the file</returns>
+        public string GetFilePath(string fileName) =>
+            _fileSystem.Path.Combine(DirectoryPath, fileName);
+
+        /// <summary>
+        /// Creates the email item's directory if it doesn't exist
+        /// </summary>
+        /// <returns>True if directory was created or already exists, false if creation failed</returns>
+        public bool EnsureDirectoryExists()
+        {
+            try
+            {
+                if (!DirectoryExists)
+                {
+                    _fileSystem.Directory.CreateDirectory(DirectoryPath);
+                }
+                return true;
+            }
+            catch
+            {
+                return false;
+            }
+        }
+
+        /// <summary>
+        /// Gets file information for a specific file in the directory
+        /// </summary>
+        /// <param name="fileName">Name of the file</param>
+        /// <returns>File information object, or null if file doesn't exist</returns>
+        public IFileInfo? GetFileInfo(string fileName)
+        {
+            if (string.IsNullOrEmpty(fileName))
+                return null;
+
+            var filePath = GetFilePath(fileName);
+            return _fileSystem.File.Exists(filePath) ? _fileSystem.FileInfo.New(filePath) : null;
+        }
+
+        #endregion
+    }
+}

+ 82 - 0
App/EmailStorage/EmailProcessorConfiguration.cs

@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+
+namespace qdr.app.studiou.orders2printpack.EmailStorage
+{
+    /// <summary>
+    /// Configuration class for the EmailStorageProcessor containing all necessary
+    /// settings for TSS (Temporary Shared Storage) API and SMTP server connectivity.
+    /// This class is designed to be serialized/deserialized from JSON configuration files.
+    /// </summary>
+    public class EmailProcessorConfiguration
+    {
+        #region TSS API Configuration
+
+        /// <summary>
+        /// API key for accessing the Temporary Shared Storage (TSS) service.
+        /// This key is required for file upload operations to the shared storage.
+        /// </summary>
+        [JsonPropertyName("tss-api-key")]
+        [JsonRequired]
+        public string TssAPIKey { get; set; } = string.Empty;
+        
+        /// <summary>
+        /// Base URL for the Temporary Shared Storage (TSS) API endpoint.
+        /// Should include the protocol (https://) and domain.
+        /// </summary>
+        [JsonPropertyName("tss-url")]
+        [JsonRequired]
+        public string TssUrl { get; set; } = string.Empty;
+
+        #endregion
+
+        #region SMTP Server Configuration
+
+        /// <summary>
+        /// SMTP server hostname or IP address for sending emails.
+        /// Examples: smtp.gmail.com, mail.company.com, 192.168.1.100
+        /// </summary>
+        [JsonPropertyName("smtp-server")]
+        [JsonRequired]
+        public string SmtpServer { get; set; } = string.Empty;
+        
+        /// <summary>
+        /// SMTP server port number for email communication.
+        /// Common ports: 25 (plain), 587 (STARTTLS), 465 (SSL/TLS)
+        /// Defaults to 587 which is the standard submission port with STARTTLS.
+        /// </summary>
+        [JsonPropertyName("smtp-server-port")]
+        [JsonRequired]
+        public int SmtpServerPort { get; set; } = 587; // Default SMTP submission port
+        
+        /// <summary>
+        /// Username for SMTP server authentication.
+        /// This is typically an email address or account name depending on the server.
+        /// </summary>
+        [JsonPropertyName("smtp-server-user")]
+        [JsonRequired]
+        public string SmtpServerUser { get; set; } = string.Empty;
+        
+        /// <summary>
+        /// Password for SMTP server authentication.
+        /// For security, consider using app-specific passwords when available.
+        /// This value should be kept secure and not logged.
+        /// </summary>
+        [JsonPropertyName("smtp-server-pwd")]
+        [JsonRequired]
+        public string SmtpServerPassword { get; set; } = string.Empty;
+        
+        /// <summary>
+        /// Indicates whether to enable SSL/TLS encryption for SMTP communication.
+        /// Defaults to true for security. Most modern SMTP servers require encryption.
+        /// </summary>
+        [JsonPropertyName("smtp-enable-ssl")]
+        public bool SmtpEnableSsl { get; set; } = true; // Default to secure connection
+
+        #endregion
+    }
+}

+ 53 - 0
App/EmailStorage/EmailStateEnum.cs

@@ -0,0 +1,53 @@
+namespace qdr.app.studiou.orders2printpack.EmailStorage
+{
+    /// <summary>
+    /// Enumeration representing the various states an email item can be in during processing.
+    /// These states track the progression of an email item through the complete workflow
+    /// from initial discovery to final delivery.
+    /// </summary>
+    public enum EmailStateEnum
+    {
+        /// <summary>
+        /// Initial state when an email item is discovered and ready for processing.
+        /// Items in this state have their directory scanned and metadata populated,
+        /// but no processing operations have begun.
+        /// </summary>
+        Ready,
+        
+        /// <summary>
+        /// The email item is currently being processed.
+        /// This includes file compression and preparation for upload.
+        /// Items in this state should not be modified by other operations.
+        /// </summary>
+        Processing,
+        
+        /// <summary>
+        /// The compressed file is being uploaded to the Temporary Shared Storage.
+        /// This state indicates active network operation in progress.
+        /// Items in this state should not be reset or reprocessed.
+        /// </summary>
+        Uploading,
+        
+        /// <summary>
+        /// The email is being prepared and sent to the recipient.
+        /// This includes email template processing, personalization,
+        /// and actual SMTP transmission.
+        /// </summary>
+        Sending,
+        
+        /// <summary>
+        /// The email has been successfully sent to the recipient.
+        /// This is a terminal success state - processing is complete.
+        /// Files are typically moved to the "sent" directory.
+        /// </summary>
+        Sent,
+        
+        /// <summary>
+        /// An error occurred during processing at any stage.
+        /// Items in this state require manual intervention or can be reset
+        /// to retry processing. The ErrorMessage property should contain
+        /// details about what went wrong.
+        /// </summary>
+        Error
+    }
+}

+ 834 - 0
App/EmailStorage/EmailStorageProcessor.cs

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

+ 52 - 0
App/EmailStorage/EmailTemplate.cs

@@ -0,0 +1,52 @@
+using System.Text.Json.Serialization;
+
+namespace qdr.app.studiou.orders2printpack.EmailStorage
+{
+    /// <summary>
+    /// Represents an email template that can be used to send personalized emails to recipients.
+    /// Templates support placeholder replacement for dynamic content such as download links,
+    /// passwords, recipient information, and order details.
+    /// 
+    /// Common placeholders that can be used in Subject and Body:
+    /// - {{DOWNLOAD_LINK}} - The download URL for the shared file
+    /// - {{PASSWORD}} - The password required to access the download
+    /// - {{RECIPIENT}} - The recipient's email address
+    /// - {{ORDERS}} - Comma-separated list of order numbers
+    /// </summary>
+    public class EmailTemplate
+    {
+        /// <summary>
+        /// Unique identifier/code for this email template.
+        /// Used to reference specific templates in configuration or selection logic.
+        /// Example: "STUDIO_PHOTOS", "ORDER_CONFIRMATION", etc.
+        /// </summary>
+        [JsonPropertyName("code")]
+        [JsonRequired]
+        public string Code { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Email subject line template.
+        /// Can contain placeholders that will be replaced with actual values
+        /// when the email is prepared for sending.
+        /// Example: "Your Studio Photos are Ready - Orders {{ORDERS}}"
+        /// </summary>
+        [JsonPropertyName("subject")]
+        [JsonRequired]
+        public string Subject { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Email body template in HTML or plain text format.
+        /// Supports placeholder replacement for personalization.
+        /// Should include instructions for downloading files and using passwords.
+        /// 
+        /// Example body might include:
+        /// - Greeting with {{RECIPIENT}}
+        /// - Download instructions with {{DOWNLOAD_LINK}}
+        /// - Password information with {{PASSWORD}}
+        /// - Order reference with {{ORDERS}}
+        /// </summary>
+        [JsonPropertyName("body")]
+        [JsonRequired]
+        public string Body { get; set; } = string.Empty;
+    }
+}

+ 186 - 0
App/EmailStorage/Metadata.cs

@@ -0,0 +1,186 @@
+using System.Text.Json.Serialization;
+
+namespace qdr.app.studiou.orders2printpack.EmailStorage
+{
+    /// <summary>
+    /// Root metadata container that holds information about the overall processing system
+    /// and maintains a collection of individual email items. This is the main data structure
+    /// that gets serialized to/from the metadata.json file.
+    /// </summary>
+    public class Metadata
+    {
+        /// <summary>
+        /// Timestamp when this metadata file was first created.
+        /// This helps track the age of the processing batch and provides audit information.
+        /// </summary>
+        [JsonRequired]
+        [JsonPropertyName("metadata-created")]
+        public DateTime Created { get; set; } = DateTime.Now;
+        
+        /// <summary>
+        /// Timestamp of the last modification to this metadata.
+        /// Updated whenever items are added, removed, or their state changes.
+        /// Null when no modifications have been made since creation.
+        /// </summary>
+        [JsonPropertyName("metadata-modified")]
+        public DateTime? Modified { get; set; } = null;
+
+        /// <summary>
+        /// Collection of individual email items being processed.
+        /// Each item represents one recipient with their associated files and processing state.
+        /// </summary>
+        [JsonRequired]
+        [JsonPropertyName("items")]
+        public List<MetadataItem> Items { get; set; } = new List<MetadataItem>();
+    }
+
+    /// <summary>
+    /// Represents a single email item with all associated processing information.
+    /// Contains details about the recipient, files, processing state, and email content.
+    /// This is the core data structure that tracks each individual email through the workflow.
+    /// </summary>
+    public class MetadataItem
+    {
+        #region Directory and File Information
+
+        /// <summary>
+        /// Name of the directory containing files for this email item.
+        /// Typically matches or is derived from the recipient's email address.
+        /// Used to locate the source files for processing.
+        /// </summary>
+        [JsonPropertyName("directory-name")]
+        [JsonRequired]
+        public string DirectoryName { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Current processing state of this email item.
+        /// Tracks progression through Ready -> Processing -> Uploading -> Sending -> Sent.
+        /// Can also be Error if something goes wrong.
+        /// </summary>
+        [JsonPropertyName("state")]
+        [JsonRequired]
+        public EmailStateEnum State { get; set; } = EmailStateEnum.Ready;
+
+        #endregion
+
+        #region Recipient Information
+
+        /// <summary>
+        /// Email address of the recipient who will receive the processed files.
+        /// This is the primary identifier for the email item.
+        /// </summary>
+        [JsonPropertyName("email-address")]
+        [JsonRequired]
+        public string EmailAddress { get; set; } = string.Empty;
+
+        #endregion
+
+        #region Email Content
+
+        /// <summary>
+        /// Subject line for the email that will be sent to the recipient.
+        /// Initially populated from the email template, may be personalized during processing.
+        /// </summary>
+        [JsonPropertyName("subject")]
+        [JsonRequired]
+        public string Subject { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Body content of the email in HTML or plain text format.
+        /// Initially from template, gets personalized with download links, passwords, etc.
+        /// </summary>
+        [JsonPropertyName("body")]
+        [JsonRequired]
+        public string Body { get; set; } = string.Empty;
+
+        #endregion
+
+        #region File and Attachment Information
+
+        /// <summary>
+        /// Filename of the compressed attachment that will be uploaded and sent.
+        /// Generated during processing and includes timestamp for uniqueness.
+        /// Example: "studioufoto_user_at_example_dot_com_20240530_143022.zip"
+        /// </summary>
+        [JsonPropertyName("attachment")]
+        [JsonRequired]
+        public string FileNameAttachment { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Size of the compressed attachment file in bytes.
+        /// Populated after compression is complete, used for upload progress tracking.
+        /// </summary>
+        [JsonPropertyName("attachment-size")]
+        [JsonRequired]
+        public int SizeAttachment { get; set; } = 0;
+
+        /// <summary>
+        /// Number of individual files (typically JPG images) contained within the compressed attachment.
+        /// Derived from scanning the source directory during metadata update.
+        /// </summary>
+        [JsonPropertyName("attachment-content-count")]
+        [JsonRequired]
+        public int InternalFilesCount { get; set; } = 0;
+
+        #endregion
+
+        #region Business Logic
+
+        /// <summary>
+        /// Comma-separated list of order numbers extracted from the filenames.
+        /// Used for business reference and included in email communications.
+        /// Example: "1001,1002,1003" extracted from files like "1001_photo1.jpg", "1002_photo1.jpg"
+        /// </summary>
+        [JsonPropertyName("orders")]
+        [JsonRequired]
+        public string Orders { get; set; } = string.Empty;
+
+        #endregion
+
+        #region Upload and Download Information
+
+        /// <summary>
+        /// Permanent link URL where the recipient can download their files.
+        /// Generated by the Temporary Shared Storage service after successful upload.
+        /// This link is included in the email sent to the recipient.
+        /// </summary>
+        [JsonPropertyName("upload-permalink")]
+        public string UploadPermalink { get; set; } = string.Empty;
+
+        /// <summary>
+        /// Password required to access the download link.
+        /// Generated during upload process for security.
+        /// Included in the email so recipient can access their files.
+        /// </summary>
+        [JsonPropertyName("download-password")]
+        public string DownloadPassword { get; set; } = string.Empty;
+
+        #endregion
+
+        #region Audit and Tracking
+
+        /// <summary>
+        /// Timestamp when this item completed processing (compression and upload).
+        /// Null until processing is complete, used for audit and performance tracking.
+        /// </summary>
+        [JsonPropertyName("processed-date")]
+        public DateTime? ProcessedDate { get; set; }
+        
+        /// <summary>
+        /// Timestamp when the email was successfully sent to the recipient.
+        /// Null until email sending is complete, marks final completion of the workflow.
+        /// </summary>
+        [JsonPropertyName("sent-date")]
+        public DateTime? SentDate { get; set; }
+        
+        /// <summary>
+        /// Error message if processing failed at any stage.
+        /// Null when no errors have occurred, populated when State is set to Error.
+        /// Used for debugging and manual intervention.
+        /// </summary>
+        [JsonPropertyName("error-message")]
+        public string? ErrorMessage { get; set; }
+
+        #endregion
+    }
+}

+ 0 - 0
Extensions/CsvParserUtility.cs → App/Extensions/CsvParserUtility.cs


+ 0 - 0
Extensions/CsvUtils.cs → App/Extensions/CsvUtils.cs


+ 0 - 0
Extensions/ExtendedParametrizedString.cs → App/Extensions/ExtendedParametrizedString.cs


+ 0 - 0
Extensions/ListViewExtension.cs → App/Extensions/ListViewExtension.cs


+ 0 - 0
Extensions/StringExt.cs → App/Extensions/StringExt.cs


+ 0 - 0
FormBatch.Designer.cs → App/FormBatch.Designer.cs


+ 0 - 0
FormBatch.cs → App/FormBatch.cs


+ 0 - 0
FormBatch.resx → App/FormBatch.resx


+ 256 - 0
App/FormDigitalSend.Designer.cs

@@ -0,0 +1,256 @@
+namespace qdr.app.studiou.orders2printpack
+{
+    partial class FormDigitalSend
+    {
+        /// <summary>
+        /// Required designer variable.
+        /// </summary>
+        private System.ComponentModel.IContainer components = null;
+
+        /// <summary>
+        /// Clean up any resources being used.
+        /// </summary>
+        /// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
+        protected override void Dispose(bool disposing)
+        {
+            if (disposing && (components != null))
+            {
+                components.Dispose();
+            }
+            base.Dispose(disposing);
+        }
+
+        #region Windows Form Designer generated code
+
+        /// <summary>
+        /// Required method for Designer support - do not modify
+        /// the contents of this method with the code editor.
+        /// </summary>
+        private void InitializeComponent()
+        {
+            menuStrip1 = new MenuStrip();
+            toolStripMenuItem1 = new ToolStripMenuItem();
+            mmOpenDir = new ToolStripMenuItem();
+            operaceToolStripMenuItem = new ToolStripMenuItem();
+            mmSend = new ToolStripMenuItem();
+            lvData = new ListView();
+            colDDStatus = new ColumnHeader();
+            colDDRecipient = new ColumnHeader();
+            colDDOrders = new ColumnHeader();
+            colDDFiles = new ColumnHeader();
+            colDDFilesSize = new ColumnHeader();
+            statusStrip1 = new StatusStrip();
+            tssCount = new ToolStripStatusLabel();
+            tssSent = new ToolStripStatusLabel();
+            tssProgress = new ToolStripProgressBar();
+            tssNotSentSize = new ToolStripStatusLabel();
+            tssFolder = new ToolStripStatusLabel();
+            panel1 = new Panel();
+            dlgOpenDir = new FolderBrowserDialog();
+            splitContainer1 = new SplitContainer();
+            lbLog = new ListBox();
+            menuStrip1.SuspendLayout();
+            statusStrip1.SuspendLayout();
+            ((System.ComponentModel.ISupportInitialize)splitContainer1).BeginInit();
+            splitContainer1.Panel1.SuspendLayout();
+            splitContainer1.Panel2.SuspendLayout();
+            splitContainer1.SuspendLayout();
+            SuspendLayout();
+            // 
+            // menuStrip1
+            // 
+            menuStrip1.Items.AddRange(new ToolStripItem[] { toolStripMenuItem1, operaceToolStripMenuItem });
+            menuStrip1.Location = new Point(0, 0);
+            menuStrip1.Name = "menuStrip1";
+            menuStrip1.Size = new Size(800, 24);
+            menuStrip1.TabIndex = 0;
+            menuStrip1.Text = "menuStrip1";
+            // 
+            // toolStripMenuItem1
+            // 
+            toolStripMenuItem1.DropDownItems.AddRange(new ToolStripItem[] { mmOpenDir });
+            toolStripMenuItem1.Name = "toolStripMenuItem1";
+            toolStripMenuItem1.Size = new Size(57, 20);
+            toolStripMenuItem1.Text = "Soubor";
+            // 
+            // mmOpenDir
+            // 
+            mmOpenDir.Name = "mmOpenDir";
+            mmOpenDir.Size = new Size(272, 22);
+            mmOpenDir.Text = "Načti adresář s digitálním obsahem ...";
+            mmOpenDir.Click += mmOpenDir_Click;
+            // 
+            // operaceToolStripMenuItem
+            // 
+            operaceToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { mmSend });
+            operaceToolStripMenuItem.Name = "operaceToolStripMenuItem";
+            operaceToolStripMenuItem.Size = new Size(63, 20);
+            operaceToolStripMenuItem.Text = "Operace";
+            // 
+            // mmSend
+            // 
+            mmSend.Name = "mmSend";
+            mmSend.Size = new Size(180, 22);
+            mmSend.Text = "Pošli";
+            mmSend.Click += mmSend_Click;
+            // 
+            // lvData
+            // 
+            lvData.AllowColumnReorder = true;
+            lvData.Columns.AddRange(new ColumnHeader[] { colDDStatus, colDDRecipient, colDDOrders, colDDFiles, colDDFilesSize });
+            lvData.Dock = DockStyle.Fill;
+            lvData.FullRowSelect = true;
+            lvData.GridLines = true;
+            lvData.Location = new Point(0, 0);
+            lvData.Name = "lvData";
+            lvData.Size = new Size(800, 183);
+            lvData.TabIndex = 2;
+            lvData.UseCompatibleStateImageBehavior = false;
+            lvData.View = View.Details;
+            // 
+            // colDDStatus
+            // 
+            colDDStatus.Text = "Stav";
+            // 
+            // colDDRecipient
+            // 
+            colDDRecipient.Text = "Příjemce / Zákazník";
+            colDDRecipient.Width = 240;
+            // 
+            // colDDOrders
+            // 
+            colDDOrders.Text = "Objednávky";
+            colDDOrders.Width = 240;
+            // 
+            // colDDFiles
+            // 
+            colDDFiles.Text = "Počet souborů";
+            colDDFiles.Width = 80;
+            // 
+            // colDDFilesSize
+            // 
+            colDDFilesSize.Text = "Velikost (MB)";
+            colDDFilesSize.Width = 120;
+            // 
+            // statusStrip1
+            // 
+            statusStrip1.Items.AddRange(new ToolStripItem[] { tssCount, tssSent, tssProgress, tssNotSentSize, tssFolder });
+            statusStrip1.Location = new Point(0, 428);
+            statusStrip1.Name = "statusStrip1";
+            statusStrip1.Size = new Size(800, 22);
+            statusStrip1.TabIndex = 3;
+            statusStrip1.Text = "statusStrip1";
+            // 
+            // tssCount
+            // 
+            tssCount.Name = "tssCount";
+            tssCount.Size = new Size(83, 17);
+            tssCount.Text = "Počet Celkem:";
+            // 
+            // tssSent
+            // 
+            tssSent.Name = "tssSent";
+            tssSent.Size = new Size(52, 17);
+            tssSent.Text = "Posláno:";
+            // 
+            // tssProgress
+            // 
+            tssProgress.Name = "tssProgress";
+            tssProgress.Size = new Size(100, 16);
+            // 
+            // tssNotSentSize
+            // 
+            tssNotSentSize.Name = "tssNotSentSize";
+            tssNotSentSize.Size = new Size(110, 17);
+            tssNotSentSize.Text = "Neposláno velikost:";
+            // 
+            // tssFolder
+            // 
+            tssFolder.Name = "tssFolder";
+            tssFolder.Size = new Size(43, 17);
+            tssFolder.Text = "Složka:";
+            // 
+            // panel1
+            // 
+            panel1.Dock = DockStyle.Top;
+            panel1.Location = new Point(0, 24);
+            panel1.Name = "panel1";
+            panel1.Size = new Size(800, 38);
+            panel1.TabIndex = 4;
+            // 
+            // splitContainer1
+            // 
+            splitContainer1.Dock = DockStyle.Fill;
+            splitContainer1.Location = new Point(0, 62);
+            splitContainer1.Name = "splitContainer1";
+            splitContainer1.Orientation = Orientation.Horizontal;
+            // 
+            // splitContainer1.Panel1
+            // 
+            splitContainer1.Panel1.Controls.Add(lvData);
+            // 
+            // splitContainer1.Panel2
+            // 
+            splitContainer1.Panel2.Controls.Add(lbLog);
+            splitContainer1.Size = new Size(800, 366);
+            splitContainer1.SplitterDistance = 183;
+            splitContainer1.TabIndex = 5;
+            // 
+            // lbLog
+            // 
+            lbLog.Dock = DockStyle.Fill;
+            lbLog.FormattingEnabled = true;
+            lbLog.Location = new Point(0, 0);
+            lbLog.Name = "lbLog";
+            lbLog.Size = new Size(800, 179);
+            lbLog.TabIndex = 0;
+            // 
+            // FormDigitalSend
+            // 
+            AutoScaleDimensions = new SizeF(7F, 15F);
+            AutoScaleMode = AutoScaleMode.Font;
+            ClientSize = new Size(800, 450);
+            Controls.Add(splitContainer1);
+            Controls.Add(panel1);
+            Controls.Add(statusStrip1);
+            Controls.Add(menuStrip1);
+            MainMenuStrip = menuStrip1;
+            Name = "FormDigitalSend";
+            Text = "Odeslání digitálních dat";
+            menuStrip1.ResumeLayout(false);
+            menuStrip1.PerformLayout();
+            statusStrip1.ResumeLayout(false);
+            statusStrip1.PerformLayout();
+            splitContainer1.Panel1.ResumeLayout(false);
+            splitContainer1.Panel2.ResumeLayout(false);
+            ((System.ComponentModel.ISupportInitialize)splitContainer1).EndInit();
+            splitContainer1.ResumeLayout(false);
+            ResumeLayout(false);
+            PerformLayout();
+        }
+
+        #endregion
+
+        private MenuStrip menuStrip1;
+        private ToolStripMenuItem toolStripMenuItem1;
+        private ToolStripMenuItem mmOpenDir;
+        private ListView lvData;
+        private ColumnHeader colDDStatus;
+        private ColumnHeader colDDRecipient;
+        private ColumnHeader colDDOrders;
+        private ColumnHeader colDDFiles;
+        private ColumnHeader colDDFilesSize;
+        private ToolStripMenuItem operaceToolStripMenuItem;
+        private ToolStripMenuItem mmSend;
+        private StatusStrip statusStrip1;
+        private ToolStripStatusLabel tssCount;
+        private ToolStripStatusLabel tssSent;
+        private ToolStripProgressBar tssProgress;
+        private ToolStripStatusLabel tssNotSentSize;
+        private Panel panel1;
+        private ToolStripStatusLabel tssFolder;
+        private FolderBrowserDialog dlgOpenDir;
+        private SplitContainer splitContainer1;
+        private ListBox lbLog;
+    }
+}

+ 145 - 0
App/FormDigitalSend.cs

@@ -0,0 +1,145 @@
+using qdr.app.studiou.orders2printpack.Properties;
+using System.IO.Abstractions;
+
+namespace qdr.app.studiou.orders2printpack
+{
+    public partial class FormDigitalSend : Form
+    {
+        #region *** Cnstants ***
+        private const string CS_DigitalDirSent = ".sent";
+        private const string CS_DigitalDirTemp = ".temp";
+        private const string CS_DigitalMetadata = "metadata.json";
+        #endregion
+
+        #region *** Fields ***
+        private IFileSystem _fileSystem = new FileSystem();
+        private string? _openedDigitalDir = null;
+        private string? _openedDigitalDirSent = null;
+        private string? _openedDigitalDirTemp = null;
+        #endregion
+
+        #region *** Constructors ***
+        public FormDigitalSend()
+        {
+            InitializeComponent();
+        }
+        #endregion
+
+        #region *** Form Handlers ***
+        private void mmOpenDir_Click(object sender, EventArgs e)
+        {
+            dlgOpenDir.Description = "Otevřít další složku s digitálním obsahem ...";
+            dlgOpenDir.UseDescriptionForTitle = true;
+            dlgOpenDir.InitialDirectory = AppSettings.Default.LastDigitalDir;
+            if (dlgOpenDir.ShowDialog() == DialogResult.OK)
+            {
+                AppSettings.Default.LastDigitalDir = dlgOpenDir.SelectedPath;
+                AppSettings.Default.Save();
+                OpenDigitalDir(_openedDigitalDir);
+            }
+        }
+
+  
+
+        private void mmSend_Click(object sender, EventArgs e)
+        {
+
+        }
+        #endregion
+
+        #region *** Private Methods ***
+        #region **** Error Handling *****
+        private void BlockErrorHandled(Action block)
+        {
+            try
+            {
+                block();
+            }
+            catch (Exception ex)
+            {
+                MessageBox.Show(this, ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
+                RefreshToolButtons();
+            }
+        }
+        #endregion
+        #region **** Refresh UI *****
+        private void RefreshToolButtons()
+        {
+            throw new NotImplementedException();
+        }
+        #endregion
+
+        #region **** Digital Directory Operations *****
+        private void OpenDigitalDir(string selectedPath)
+        {
+            BlockErrorHandled(()=>{
+                EnsureDigitalDirStructureExists(selectedPath);
+                
+            });
+        }
+
+        private void EnsureDigitalDirStructureExists(string selectedPath)
+        {
+            if (string.IsNullOrEmpty(selectedPath) || !_fileSystem.Directory.Exists(selectedPath))
+            {
+                throw new DirectoryNotFoundException($"Adresář '{selectedPath}' neexistuje.");
+            }
+            _openedDigitalDir = selectedPath;
+            _openedDigitalDirSent = _fileSystem.Path.Combine(selectedPath, CS_DigitalDirSent);
+            _openedDigitalDirTemp = _fileSystem.Path.Combine(selectedPath, CS_DigitalDirTemp);
+
+            if (!_fileSystem.Directory.Exists(_openedDigitalDirSent))
+            {
+                _fileSystem.Directory.CreateDirectory(_openedDigitalDirSent);
+                Log($"Vytvořen adresář pro odeslaná data '{_openedDigitalDirSent}'");
+            }
+            if (!_fileSystem.Directory.Exists(_openedDigitalDirTemp))
+            {
+                _fileSystem.Directory.CreateDirectory(_openedDigitalDirTemp);
+                Log($"Vytvořen pracovní adresář '{_openedDigitalDirTemp}'");
+            }
+            EnsureDigitalMetadataExists(selectedPath);
+        }
+
+        private void EnsureDigitalMetadataExists(string selectedPath)
+        {
+            var metadataFilePath = _fileSystem.Path.Combine(selectedPath, CS_DigitalMetadata);
+            if (!_fileSystem.File.Exists(metadataFilePath))
+            {
+                RefreshMetadataFile(metadataFilePath);
+            }
+        }
+
+        private void RefreshMetadataFile(string metadataFilePath)
+        {
+            Log($"Aktualizuji soubor s metadaty '{metadataFilePath}'...");
+
+            var metadata = new EmailStorage.Metadata();
+            _fileSystem.File.WriteAllText(metadataFilePath, System.Text.Json.JsonSerializer.Serialize(metadata, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
+            Log($"Soubor s metadaty '{metadataFilePath}' byl vytvořen.");
+        }
+        #endregion
+        #region **** Logging *****
+        private void LogBreak()
+        {
+            Log(new string('-', 80));
+        }
+        private void LogCaption(string message)
+        {
+                        Log($"*** {message} ***");
+        }
+        private void LogError(string message)
+        {
+                        Log($"[ERROR] {message}");
+        }
+        private void Log(string message)
+        {
+            lbLog.BeginUpdate();
+            lbLog.Items.Insert(0, message);
+            lbLog.EndUpdate();
+        }
+        #endregion
+        #endregion
+
+    }
+}

+ 132 - 0
App/FormDigitalSend.resx

@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+  <!--
+    Microsoft ResX Schema
+
+    Version 2.0
+
+    The primary goals of this format is to allow a simple XML format
+    that is mostly human readable. The generation and parsing of the
+    various data types are done through the TypeConverter classes
+    associated with the data types.
+
+    Example:
+
+    ... ado.net/XML headers & schema ...
+    <resheader name="resmimetype">text/microsoft-resx</resheader>
+    <resheader name="version">2.0</resheader>
+    <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+    <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+    <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+    <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+    <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+        <value>[base64 mime encoded serialized .NET Framework object]</value>
+    </data>
+    <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+        <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+        <comment>This is a comment</comment>
+    </data>
+
+    There are any number of "resheader" rows that contain simple
+    name/value pairs.
+
+    Each data row contains a name, and value. The row also contains a
+    type or mimetype. Type corresponds to a .NET class that support
+    text/value conversion through the TypeConverter architecture.
+    Classes that don't support this are serialized and stored with the
+    mimetype set.
+
+    The mimetype is used for serialized objects, and tells the
+    ResXResourceReader how to depersist the object. This is currently not
+    extensible. For a given mimetype the value must be set accordingly:
+
+    Note - application/x-microsoft.net.object.binary.base64 is the format
+    that the ResXResourceWriter will generate, however the reader can
+    read any of the formats listed below.
+
+    mimetype: application/x-microsoft.net.object.binary.base64
+    value   : The object must be serialized with
+            : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.soap.base64
+    value   : The object must be serialized with
+            : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+            : and then encoded with base64 encoding.
+
+    mimetype: application/x-microsoft.net.object.bytearray.base64
+    value   : The object must be serialized into a byte array
+            : using a System.ComponentModel.TypeConverter
+            : and then encoded with base64 encoding.
+    -->
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+    <xsd:element name="root" msdata:IsDataSet="true">
+      <xsd:complexType>
+        <xsd:choice maxOccurs="unbounded">
+          <xsd:element name="metadata">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" />
+              </xsd:sequence>
+              <xsd:attribute name="name" use="required" type="xsd:string" />
+              <xsd:attribute name="type" type="xsd:string" />
+              <xsd:attribute name="mimetype" type="xsd:string" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="assembly">
+            <xsd:complexType>
+              <xsd:attribute name="alias" type="xsd:string" />
+              <xsd:attribute name="name" type="xsd:string" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="data">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+              <xsd:attribute ref="xml:space" />
+            </xsd:complexType>
+          </xsd:element>
+          <xsd:element name="resheader">
+            <xsd:complexType>
+              <xsd:sequence>
+                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+              </xsd:sequence>
+              <xsd:attribute name="name" type="xsd:string" use="required" />
+            </xsd:complexType>
+          </xsd:element>
+        </xsd:choice>
+      </xsd:complexType>
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>2.0</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
+    <value>17, 17</value>
+  </metadata>
+  <metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
+    <value>149, 17</value>
+  </metadata>
+  <metadata name="dlgOpenDir.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
+    <value>282, 17</value>
+  </metadata>
+  <metadata name="$this.TrayHeight" type="System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
+    <value>37</value>
+  </metadata>
+</root>

+ 0 - 0
FormLabelPrint.Designer.cs → App/FormLabelPrint.Designer.cs


+ 58 - 2
FormLabelPrint.cs → App/FormLabelPrint.cs

@@ -44,7 +44,7 @@ namespace qdr.app.studiou.orders2printpack
                 if (dlgSaveXls.ShowDialog(this) == DialogResult.OK)
                 {
                     _xlsPath = dlgSaveXls.FileName;
-                    tbXslxPath.Text = _csvPath;
+                    tbXslxPath.Text = _xlsPath;
                 }
             });
         }
@@ -71,7 +71,9 @@ namespace qdr.app.studiou.orders2printpack
 
         private DataTable ProcessData(DataTable data)
         {
-            //LabelAggegationDataExpression
+            // Aggregate data first
+            data = AggregateData(data);
+
             var expressionParts = AppSettings.Default.LabelAggegationDataExpression.Split('=');
             var expressionColumnName = expressionParts[0];
             var expressionValue = expressionParts[1];
@@ -100,7 +102,61 @@ namespace qdr.app.studiou.orders2printpack
             return result;
 
         }
+        /// <summary>
+        /// Aggregates order data by grouping order numbers, product categories, and product variant types,
+        /// then summing the quantities for each group.
+        /// </summary>
+        /// <param name="data">Input DataTable containing order details</param>
+        /// <returns>Aggregated DataTable with columns: order_no, prod_cat, prod_var_type, qty (sum)</returns>
+        private DataTable AggregateData(DataTable data)
+        {
+            if (data == null)
+                throw new ArgumentNullException(nameof(data));
+
+            // Create result DataTable with appropriate columns
+            DataTable result = new DataTable();
+            result.Columns.Add("order_no", typeof(string));
+            result.Columns.Add("email", typeof(string));
+            result.Columns.Add("prod_cat", typeof(string));
+            result.Columns.Add("prod_var_type", typeof(string));
+            result.Columns.Add("qty", typeof(string));
+
+            // Group and aggregate using LINQ
+            var aggregatedData = data.AsEnumerable()
+                .GroupBy(row => new
+                {
+                    OrderNo = row.Field<string>("order_no"),
+                    Email = row.Field<string>("email"),
+                    ProdCat = row.Field<string>("prod_cat"),
+                    ProdVarType = row.Field<string>("prod_var_type")
+                })
+                .Select(group => new
+                {
+                    group.Key.OrderNo,
+                    group.Key.Email,
+                    group.Key.ProdCat,
+                    group.Key.ProdVarType,
+                    TotalQty = group.Sum(row => int.Parse(row.Field<string>("qty"))).ToString()
+                })
+                .OrderBy(x => x.OrderNo)
+                .ThenBy(x => x.ProdCat)
+                .ThenBy(x => x.ProdVarType);
+
+
+            // Populate result DataTable
+            foreach (var item in aggregatedData)
+            {
+                result.Rows.Add(
+                    item.OrderNo,
+                    item.Email,
+                    item.ProdCat,
+                    item.ProdVarType,
+                    int.Parse(item.TotalQty)
+                );
+            }
 
+            return result;
+        }
 
 
         private void butConfig_Click(object sender, EventArgs e)

+ 0 - 0
FormLabelPrint.resx → App/FormLabelPrint.resx


+ 19 - 6
FormLauncher.Designer.cs → App/FormLauncher.Designer.cs

@@ -36,6 +36,7 @@
             butLabalPrint = new Button();
             lVersion = new Label();
             butEditCfg = new Button();
+            butDigitalDataSend = new Button();
             ((System.ComponentModel.ISupportInitialize)pictureBox1).BeginInit();
             SuspendLayout();
             // 
@@ -62,7 +63,7 @@
             // 
             // butProductEdit
             // 
-            butProductEdit.Location = new Point(209, 42);
+            butProductEdit.Location = new Point(209, 76);
             butProductEdit.Name = "butProductEdit";
             butProductEdit.Size = new Size(253, 27);
             butProductEdit.TabIndex = 3;
@@ -72,7 +73,7 @@
             // 
             // butResetCfg
             // 
-            butResetCfg.Location = new Point(390, 116);
+            butResetCfg.Location = new Point(390, 150);
             butResetCfg.Name = "butResetCfg";
             butResetCfg.Size = new Size(72, 25);
             butResetCfg.TabIndex = 4;
@@ -82,7 +83,7 @@
             // 
             // butLabalPrint
             // 
-            butLabalPrint.Location = new Point(209, 75);
+            butLabalPrint.Location = new Point(209, 109);
             butLabalPrint.Name = "butLabalPrint";
             butLabalPrint.Size = new Size(253, 27);
             butLabalPrint.TabIndex = 5;
@@ -93,7 +94,7 @@
             // lVersion
             // 
             lVersion.AutoSize = true;
-            lVersion.Location = new Point(10, 130);
+            lVersion.Location = new Point(10, 175);
             lVersion.Name = "lVersion";
             lVersion.Size = new Size(70, 15);
             lVersion.TabIndex = 6;
@@ -101,7 +102,7 @@
             // 
             // butEditCfg
             // 
-            butEditCfg.Location = new Point(312, 116);
+            butEditCfg.Location = new Point(312, 150);
             butEditCfg.Name = "butEditCfg";
             butEditCfg.Size = new Size(72, 25);
             butEditCfg.TabIndex = 7;
@@ -109,11 +110,22 @@
             butEditCfg.UseVisualStyleBackColor = true;
             butEditCfg.Click += butEditCfg_Click;
             // 
+            // butDigitalDataSend
+            // 
+            butDigitalDataSend.Location = new Point(209, 43);
+            butDigitalDataSend.Name = "butDigitalDataSend";
+            butDigitalDataSend.Size = new Size(253, 27);
+            butDigitalDataSend.TabIndex = 8;
+            butDigitalDataSend.Text = "Odeslání digitálních dat";
+            butDigitalDataSend.UseVisualStyleBackColor = true;
+            butDigitalDataSend.Click += butDigitalDataSend_Click;
+            // 
             // FormLauncher
             // 
             AutoScaleDimensions = new SizeF(7F, 15F);
             AutoScaleMode = AutoScaleMode.Font;
-            ClientSize = new Size(477, 154);
+            ClientSize = new Size(479, 199);
+            Controls.Add(butDigitalDataSend);
             Controls.Add(butEditCfg);
             Controls.Add(lVersion);
             Controls.Add(butLabalPrint);
@@ -143,5 +155,6 @@
         private Button butLabalPrint;
         private Label lVersion;
         private Button butEditCfg;
+        private Button butDigitalDataSend;
     }
 }

+ 6 - 0
FormLauncher.cs → App/FormLauncher.cs

@@ -57,5 +57,11 @@ namespace qdr.app.studiou.orders2printpack
             var dlg = new dlgEditCfg();
             dlg.ShowDialog(this);
         }
+
+        private void butDigitalDataSend_Click(object sender, EventArgs e)
+        {
+            var form = new FormDigitalSend();
+            form.Show(this);
+        }
     }
 }

+ 0 - 0
FormLauncher.resx → App/FormLauncher.resx


+ 0 - 0
FormProductEdit.Designer.cs → App/FormProductEdit.Designer.cs


+ 2 - 0
FormProductEdit.cs → App/FormProductEdit.cs

@@ -861,6 +861,7 @@ namespace qdr.app.studiou.orders2printpack
                         parentDto.Description = new ExtendedParametrizedString(description, tplParams, "{", "}").ToString();
                         parentDto.Uri = new ExtendedParametrizedString(urlPrefix, tplParams, "{", "}").ToString();
                         parentDto.VariantType = "Formát";
+                    
                     }
 
                     product = _storage.AddNewProductVariant(parentDto.Sku);
@@ -883,6 +884,7 @@ namespace qdr.app.studiou.orders2printpack
 
                 foreach (var product in _storage.Products.Where(x => !x.IsVariant))
                 {
+                    product.Hidden2_1 = 1;
                     var variantString = string.Join(",", _storage.Products.Where(x => string.Equals(x.ParentName, product.Sku, StringComparison.InvariantCultureIgnoreCase)).Select(x => x.VariantName));
                     product.VariantName = variantString;
                     product.ShortDescription = $"{product.Category} - fotka: {product.ProductName}";

+ 0 - 0
FormProductEdit.resx → App/FormProductEdit.resx


+ 0 - 0
LICENSE → App/LICENSE


+ 0 - 0
Labels/ExcelLabelGenerator.cs → App/Labels/ExcelLabelGenerator.cs


+ 0 - 0
ProductStorage/CategoryDto.cs → App/ProductStorage/CategoryDto.cs


+ 0 - 0
ProductStorage/IIdentifierDto.cs → App/ProductStorage/IIdentifierDto.cs


+ 3 - 0
ProductStorage/ProductDto.cs → App/ProductStorage/ProductDto.cs

@@ -51,6 +51,9 @@ namespace qdr.app.studiou.orders2printpack.ProductStorage
         [CsvColumn("Vlastnost 1 globální")]
         public int Hidden2 { get; set; } = 1;
 
+        [CsvColumn("Vlastnost 1 varianta")]
+        public int Hidden2_1 { get; set; } = 0;
+
         [CsvColumn("Povolit zákaznické recenze?")]
         public int Hidden3 { get; set; } = 0;
 

+ 7 - 2
ProductStorage/ProductStorage.cs → App/ProductStorage/ProductStorage.cs

@@ -212,8 +212,13 @@ namespace qdr.app.studiou.orders2printpack.ProductStorage
                 }
                 else
                 {
-                    if (_variants.First(x => x.Name == variant.Name).Price != variant.Price)
-                        _variants.First(x => x.Name == variant.Name).Price = variant.Price;
+                    // Keep the existing (e.g. predefined) price when the product variant has
+                    // no price set (0). Freshly imported variants default to price 0 and must
+                    // not overwrite a price the user already configured. Only sync a real,
+                    // non-zero price coming from the products.
+                    var existing = _variants.First(x => x.Name == variant.Name);
+                    if (variant.Price != 0 && existing.Price != variant.Price)
+                        existing.Price = variant.Price;
 
                 }
             }

+ 0 - 0
ProductStorage/VariantDto.cs → App/ProductStorage/VariantDto.cs


+ 0 - 0
Program.cs → App/Program.cs


+ 50 - 2
Properties/AppSettings.Designer.cs → App/Properties/AppSettings.Designer.cs

@@ -278,8 +278,8 @@ namespace qdr.app.studiou.orders2printpack.Properties {
         
         [global::System.Configuration.UserScopedSettingAttribute()]
         [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
-        [global::System.Configuration.DefaultSettingValueAttribute("^^18^**Objednávka: {order_no}**^^{cr}^^14^**{prod_cat}**^^{cr}{prod_var_type_list" +
-            "}")]
+        [global::System.Configuration.DefaultSettingValueAttribute("^^18^**Objednávka: {order_no}**^^{cr}{email}{cr}^^14^**{prod_cat}**^^{cr}{prod_va" +
+            "r_type_list}")]
         public string LabelTemplate {
             get {
                 return ((string)(this["LabelTemplate"]));
@@ -348,5 +348,53 @@ namespace qdr.app.studiou.orders2printpack.Properties {
                 this["FormatDigitalContains"] = value;
             }
         }
+        
+        [global::System.Configuration.UserScopedSettingAttribute()]
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+        [global::System.Configuration.DefaultSettingValueAttribute("riyeVPRvz9QF1YmrhdSz9rgiVzhy3mSb")]
+        public string TSSApiKey {
+            get {
+                return ((string)(this["TSSApiKey"]));
+            }
+            set {
+                this["TSSApiKey"] = value;
+            }
+        }
+        
+        [global::System.Configuration.UserScopedSettingAttribute()]
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+        [global::System.Configuration.DefaultSettingValueAttribute("2097152")]
+        public int TSSChunkSizeBytes {
+            get {
+                return ((int)(this["TSSChunkSizeBytes"]));
+            }
+            set {
+                this["TSSChunkSizeBytes"] = value;
+            }
+        }
+        
+        [global::System.Configuration.UserScopedSettingAttribute()]
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+        [global::System.Configuration.DefaultSettingValueAttribute("https://www.studiou.cz/")]
+        public string TSSUri {
+            get {
+                return ((string)(this["TSSUri"]));
+            }
+            set {
+                this["TSSUri"] = value;
+            }
+        }
+        
+        [global::System.Configuration.UserScopedSettingAttribute()]
+        [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+        [global::System.Configuration.DefaultSettingValueAttribute("")]
+        public string LastDigitalDir {
+            get {
+                return ((string)(this["LastDigitalDir"]));
+            }
+            set {
+                this["LastDigitalDir"] = value;
+            }
+        }
     }
 }

+ 13 - 1
Properties/AppSettings.settings → App/Properties/AppSettings.settings

@@ -67,7 +67,7 @@
       <Value Profile="(Default)">8</Value>
     </Setting>
     <Setting Name="LabelTemplate" Type="System.String" Scope="User">
-      <Value Profile="(Default)">^^18^**Objednávka: {order_no}**^^{cr}^^14^**{prod_cat}**^^{cr}{prod_var_type_list}</Value>
+      <Value Profile="(Default)">^^18^**Objednávka: {order_no}**^^{cr}{email}{cr}^^14^**{prod_cat}**^^{cr}{prod_var_type_list}</Value>
     </Setting>
     <Setting Name="CSVDelimiter" Type="System.String" Scope="User">
       <Value Profile="(Default)">,</Value>
@@ -84,5 +84,17 @@
     <Setting Name="FormatDigitalContains" Type="System.String" Scope="User">
       <Value Profile="(Default)">Digitální data</Value>
     </Setting>
+    <Setting Name="TSSApiKey" Type="System.String" Scope="User">
+      <Value Profile="(Default)">riyeVPRvz9QF1YmrhdSz9rgiVzhy3mSb</Value>
+    </Setting>
+    <Setting Name="TSSChunkSizeBytes" Type="System.Int32" Scope="User">
+      <Value Profile="(Default)">2097152</Value>
+    </Setting>
+    <Setting Name="TSSUri" Type="System.String" Scope="User">
+      <Value Profile="(Default)">https://www.studiou.cz/</Value>
+    </Setting>
+    <Setting Name="LastDigitalDir" Type="System.String" Scope="User">
+      <Value Profile="(Default)" />
+    </Setting>
   </Settings>
 </SettingsFile>

+ 0 - 0
Properties/PublishProfiles/FolderProfile.pubxml → App/Properties/PublishProfiles/FolderProfile.pubxml


+ 0 - 0
Properties/Resources.Designer.cs → App/Properties/Resources.Designer.cs


+ 0 - 0
Properties/Resources.resx → App/Properties/Resources.resx


+ 65 - 0
App/README.md

@@ -0,0 +1,65 @@
+# qdr.app.studiou.orders2printpack
+
+Proprietální řešení zpracování WooCommerce objednávek pro online tiskovou službu (WinForm)
+## Changelog
+### 1.3.4 (2026-06-09)
+- Oprava: import produktů již nepřepisuje ceny v číselníku variant na 0. Předdefinované ceny variant (sloupec "Cena") zůstávají po importu zachovány.
+
+### 1.3.3
+- Přidán tag pro zpracování e-mailu (processing email) při tisku štítků
+
+### 1.3.2
+- Oprava agregace množství (quantity) při generování štítků
+
+### 1.3.1
+- Přidán sloupec "Vlastnost 1 varianta"
+
+### 1.3.0 (2025-05-26)
+- Přidán TSS REST API client (v.1.0.0)
+- Rozšíření konfigurace o hodnoty pro TSS REST API client
+- Přidána obrazovka "Odeslání digitálních dat"
+
+
+### 1.2.4 (2025-05-19)
+- Rozšíření podpory přípravy balíčků k tisku o genenrování digitálního obsahu podle cílového emailu
+- Přidán do konfigurace email a identifikace formátu digitálního obsahu
+
+### 1.2.3 (2025-04-28)
+- Editace hodnot Minimum/Maximum na Produktu a Variantě
+- Editace RAW hodnot pro Kategorie produktu ,variant a mapování Batch CSV sloupců + delimiter
+- Kontrola duplicit source souborů v importu Batch CSV
+
+### 1.2.2 (2025-04-01)
+- Při importu produktů se nastavuje fixní ShortDescription a Descrtiption pro produkt
+- Přidány sloupce Maximum/Minimum quantity pro varianty
+
+### 1.2.1 (2025-04-01)
+- Přidána možnost definovat agregaci při generování štítků a přidávání kalkulovaných sloupců
+- Rozšíření nastavení (konfigurace) tisku štítků
+
+### 1.2.0 (2025-03-13)
+- Upgrade na .NET 9.0
+- Přidána podpora pro tisk štítků podle importovaného .CSV do xlsx (Excel)
+- Přidána funkce generování repository v Editaci Produktů
+
+### 1.1.1 (2025-03-13)
+- Opraveno, Nový a Uložit otevře dialog pro uložení
+- Oprava importu produktů, měsíc importu v Url je formátován na dvě číslice
+- Oprava importu produktů, při importu se SKU skloží ze jména importovaného souboru bez koncovky
+- Oprava výchozího mapování sloupců
+
+### 1.1.0 (2024-10-16)
+- Přidán launch screen
+- Přidána obrazovka Editace produktů s podporou importu ze struktury
+
+### 1.0.2 (2024-10-05)
+- Úprava mapování na sloupečky CSV, nastavení defaultů
+- Přidáno nastavení pro promazání výstupního adresáře, včetně nastavení
+
+### 1.0.1 (2024-09-27)
+- Přidána podpora generování protokolu (.csv)
+- Rozšíření datové stuktury o položky, OutputFileName, ExternalOrder, ExternalOrderDate
+- Oprav chyb UI
+
+### 1.0.0 (2024-09-27)
+- První verze

+ 0 - 0
Resources/logo.png → App/Resources/logo.png


+ 0 - 0
Resources/mail_header.jpg → App/Resources/mail_header.jpg


+ 86 - 8
dlgEditCfg.Designer.cs → App/dlgEditCfg.Designer.cs

@@ -54,11 +54,18 @@
             label12 = new Label();
             tbFormatDigital = new TextBox();
             label13 = new Label();
+            label14 = new Label();
+            tbTssApiKey = new TextBox();
+            label15 = new Label();
+            tbTssChunkSize = new TextBox();
+            label16 = new Label();
+            tbTssUrl = new TextBox();
+            label17 = new Label();
             SuspendLayout();
             // 
             // butCancel
             // 
-            butCancel.Location = new Point(392, 394);
+            butCancel.Location = new Point(392, 532);
             butCancel.Margin = new Padding(3, 2, 3, 2);
             butCancel.Name = "butCancel";
             butCancel.Size = new Size(82, 22);
@@ -69,7 +76,7 @@
             // 
             // butOk
             // 
-            butOk.Location = new Point(12, 394);
+            butOk.Location = new Point(12, 532);
             butOk.Margin = new Padding(3, 2, 3, 2);
             butOk.Name = "butOk";
             butOk.Size = new Size(82, 22);
@@ -252,15 +259,15 @@
             // label12
             // 
             label12.AutoSize = true;
-            label12.Location = new Point(19, 320);
+            label12.Location = new Point(19, 459);
             label12.Name = "label12";
-            label12.Size = new Size(45, 15);
+            label12.Size = new Size(48, 15);
             label12.TabIndex = 25;
-            label12.Text = "Ostatní";
+            label12.Text = "Ostatní:";
             // 
             // tbFormatDigital
             // 
-            tbFormatDigital.Location = new Point(20, 355);
+            tbFormatDigital.Location = new Point(20, 492);
             tbFormatDigital.Name = "tbFormatDigital";
             tbFormatDigital.Size = new Size(100, 23);
             tbFormatDigital.TabIndex = 27;
@@ -268,18 +275,82 @@
             // label13
             // 
             label13.AutoSize = true;
-            label13.Location = new Point(19, 337);
+            label13.Location = new Point(19, 474);
             label13.Name = "label13";
             label13.Size = new Size(95, 15);
             label13.TabIndex = 26;
             label13.Text = "Formát: Digitální";
             // 
+            // label14
+            // 
+            label14.AutoSize = true;
+            label14.Location = new Point(19, 323);
+            label14.Name = "label14";
+            label14.Size = new Size(171, 15);
+            label14.TabIndex = 28;
+            label14.Text = "Zpracování digitálního obsahu:";
+            // 
+            // tbTssApiKey
+            // 
+            tbTssApiKey.Location = new Point(20, 367);
+            tbTssApiKey.Name = "tbTssApiKey";
+            tbTssApiKey.Size = new Size(312, 23);
+            tbTssApiKey.TabIndex = 30;
+            // 
+            // label15
+            // 
+            label15.AutoSize = true;
+            label15.Location = new Point(19, 349);
+            label15.Name = "label15";
+            label15.Size = new Size(66, 15);
+            label15.TabIndex = 29;
+            label15.Text = "TSS APIKEY";
+            // 
+            // tbTssChunkSize
+            // 
+            tbTssChunkSize.Location = new Point(336, 367);
+            tbTssChunkSize.Name = "tbTssChunkSize";
+            tbTssChunkSize.Size = new Size(100, 23);
+            tbTssChunkSize.TabIndex = 32;
+            // 
+            // label16
+            // 
+            label16.AutoSize = true;
+            label16.Location = new Point(335, 349);
+            label16.Name = "label16";
+            label16.Size = new Size(99, 15);
+            label16.TabIndex = 31;
+            label16.Text = "ChunkSize (Bajty)";
+            // 
+            // tbTssUrl
+            // 
+            tbTssUrl.Location = new Point(19, 415);
+            tbTssUrl.Name = "tbTssUrl";
+            tbTssUrl.Size = new Size(312, 23);
+            tbTssUrl.TabIndex = 34;
+            // 
+            // label17
+            // 
+            label17.AutoSize = true;
+            label17.Location = new Point(18, 397);
+            label17.Name = "label17";
+            label17.Size = new Size(43, 15);
+            label17.TabIndex = 33;
+            label17.Text = "TSS Url";
+            // 
             // dlgEditCfg
             // 
             AutoScaleDimensions = new SizeF(7F, 15F);
             AutoScaleMode = AutoScaleMode.Font;
-            ClientSize = new Size(503, 488);
+            ClientSize = new Size(509, 627);
             ControlBox = false;
+            Controls.Add(tbTssUrl);
+            Controls.Add(label17);
+            Controls.Add(tbTssChunkSize);
+            Controls.Add(label16);
+            Controls.Add(tbTssApiKey);
+            Controls.Add(label15);
+            Controls.Add(label14);
             Controls.Add(tbFormatDigital);
             Controls.Add(label13);
             Controls.Add(label12);
@@ -343,5 +414,12 @@
         private Label label12;
         private TextBox tbFormatDigital;
         private Label label13;
+        private Label label14;
+        private TextBox tbTssApiKey;
+        private Label label15;
+        private TextBox tbTssChunkSize;
+        private Label label16;
+        private TextBox tbTssUrl;
+        private Label label17;
     }
 }

+ 17 - 0
dlgEditCfg.cs → App/dlgEditCfg.cs

@@ -36,6 +36,20 @@ namespace qdr.app.studiou.orders2printpack
             AppSettings.Default.CSVDelimiter = tbCSVDelimiter.Text;
             AppSettings.Default.MapColEmail = tbEmail.Text;
             AppSettings.Default.FormatDigitalContains = tbFormatDigital.Text;
+
+            AppSettings.Default.TSSApiKey = tbTssApiKey.Text;
+            if (!Uri.TryCreate(tbTssUrl.Text, UriKind.Absolute, out Uri tssUri) || string.IsNullOrWhiteSpace(tbTssUrl.Text))
+            {
+                MessageBox.Show("Vložte prosím validní TSS URL (https://www.some.com).", "Nevalidní vstup", MessageBoxButtons.OK, MessageBoxIcon.Error);
+                return;
+            }
+            tbTssUrl.Text = AppSettings.Default.TSSUri;
+            if (!int.TryParse(tbTssChunkSize.Text, out int chunkSize) || chunkSize <= 0)
+            {
+                MessageBox.Show("Vložte prosím kladné celé číslo pro velikost bloku dat pro TSS Chunk Size.", "Nevalidní vstup", MessageBoxButtons.OK, MessageBoxIcon.Error);
+                return;
+            }
+            AppSettings.Default.TSSChunkSizeBytes = chunkSize;
             AppSettings.Default.Save();
             Close();
         }
@@ -59,6 +73,9 @@ namespace qdr.app.studiou.orders2printpack
             tbCSVDelimiter.Text = AppSettings.Default.CSVDelimiter;
             tbEmail.Text = AppSettings.Default.MapColEmail;
             tbFormatDigital.Text = AppSettings.Default.FormatDigitalContains;
+            tbTssApiKey.Text = AppSettings.Default.TSSApiKey;
+            tbTssChunkSize.Text = AppSettings.Default.TSSChunkSizeBytes.ToString();
+            tbTssUrl.Text = AppSettings.Default.TSSUri;
         }
         #endregion
 

+ 0 - 0
dlgEditCfg.resx → App/dlgEditCfg.resx


+ 0 - 0
dlgImport.Designer.cs → App/dlgImport.Designer.cs


+ 0 - 0
dlgImport.cs → App/dlgImport.cs


+ 0 - 0
dlgImport.resx → App/dlgImport.resx


+ 2 - 2
dlgLabelSettings.Designer.cs → App/dlgLabelSettings.Designer.cs

@@ -89,9 +89,9 @@
             label2.AutoSize = true;
             label2.Location = new Point(385, 11);
             label2.Name = "label2";
-            label2.Size = new Size(100, 120);
+            label2.Size = new Size(100, 150);
             label2.TabIndex = 4;
-            label2.Text = "Použité proměné:\r\norder_no\r\nprod_cat\r\nprod_name\r\nprod_var\r\nprod_var_type\r\nprod_img_url\r\nqty\r\ncr";
+            label2.Text = "Použité proměné:\r\norder_no\r\nprod_cat\r\nprod_name\r\nprod_var\r\nprod_var_type\r\nprod_img_url\r\nqty\r\nemail\r\ncr";
             // 
             // label3
             // 

+ 0 - 0
dlgLabelSettings.cs → App/dlgLabelSettings.cs


+ 0 - 0
dlgLabelSettings.resx → App/dlgLabelSettings.resx


+ 0 - 0
dlgProtocol.Designer.cs → App/dlgProtocol.Designer.cs


+ 0 - 0
dlgProtocol.cs → App/dlgProtocol.cs


+ 0 - 0
dlgProtocol.resx → App/dlgProtocol.resx


+ 0 - 0
dlgSettings.Designer.cs → App/dlgSettings.Designer.cs


+ 0 - 0
dlgSettings.cs → App/dlgSettings.cs


+ 0 - 0
dlgSettings.resx → App/dlgSettings.resx


+ 0 - 0
logo_icon.ico → App/logo_icon.ico


+ 6 - 5
qdr.app.studiou.orders2printpack.csproj → App/qdr.app.studiou.orders2printpack.csproj

@@ -10,15 +10,15 @@
     <Company>Quadarax</Company>
     <Product>Order2PrintPack</Product>
     <Description>Proprietální řešení tvorby balíčků obrázků pro online objednávky pro tiskovou službu.</Description>
-    <Copyright>Quadarax (c) 2024</Copyright>
+    <Copyright>Quadarax (c) 2025</Copyright>
     <PackageIcon>logo_icon.png</PackageIcon>
     <Title>StudioU Orders2PrintPack</Title>
     <SignAssembly>True</SignAssembly>
-    <AssemblyOriginatorKeyFile>bo_strong_key_pair.snk</AssemblyOriginatorKeyFile>
+    <AssemblyOriginatorKeyFile>..\bo_strong_key_pair.snk</AssemblyOriginatorKeyFile>
     <Platforms>AnyCPU;x86;x64</Platforms>
-    <AssemblyVersion>1.2.0.0</AssemblyVersion>
-    <FileVersion>1.2.0.0</FileVersion>
-    <Version>1.2.4</Version>
+    <AssemblyVersion>1.3.4.0</AssemblyVersion>
+    <FileVersion>1.3.4.0</FileVersion>
+    <Version>1.3.4</Version>
 	<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
     <ForceDesignerDpiUnaware>true</ForceDesignerDpiUnaware>
   </PropertyGroup>
@@ -36,6 +36,7 @@
 
   <ItemGroup>
     <PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" />
+    <PackageReference Include="qdr.app.tss.client" Version="1.0.0-alpha" />
     <PackageReference Include="qdr.fnd.core" Version="0.0.8-alpha" />
   </ItemGroup>
 

+ 62 - 0
CLAUDE.md

@@ -0,0 +1,62 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Overview
+
+Proprietary Windows Forms (.NET 9) desktop tool for **StudioU**, an online photo-print service. It turns WooCommerce order exports into print-ready file packages, product catalogs, printable labels, and emailed digital downloads. UI text, comments, and CSV column names are predominantly **Czech** — match that when editing user-facing strings.
+
+## Build / Test / Run
+
+```powershell
+# Build whole solution
+dotnet build qdr.app.studiou.orders2printpack.sln
+
+# Run the app (WinExe Windows Forms — Windows only)
+dotnet run --project App
+
+# Run all tests (NUnit)
+dotnet test
+
+# Run a single test fixture or test
+dotnet test --filter "FullyQualifiedName~EmailStorageProcessorTests"
+dotnet test --filter "Name=MethodNameHere"
+```
+
+Notes:
+- App targets `net9.0-windows7.0`; Test targets `net9.0-windows`. `Nullable` and `ImplicitUsings` are enabled in both.
+- The App assembly is **signed** with `bo_strong_key_pair.snk` (referenced as `..\bo_strong_key_pair.snk`).
+- `Setup/Setup.vdproj` is a Visual Studio Installer project — only buildable in Visual Studio with the installer-projects extension, not via `dotnet`. Bump `Version`/`AssemblyVersion`/`FileVersion` in `App/...csproj` and the README changelog together when releasing (see commit history convention).
+
+### External (NuGet) dependencies that aren't in this repo
+- `qdr.fnd.core` (Quadarax.Foundation.Core) — shared foundation: `ILogger`/`ILog` logging, `FileSearch`, `FileUtils`, `CsvHelper`/`CsvUtils` (DataTable CSV), and `ParameterizedString` (the `{token}` substitution engine used for output filenames). Source is not in this repo; treat as an opaque library.
+- `qdr.app.tss.client` — REST client for Quadarax **Temporary Shared Storage (TSS)**; used to upload ZIPs and get a download permalink (`FileUploadService`, `UploadFileRequest`).
+- `DocumentFormat.OpenXml` — used to generate `.xlsx` label sheets without Office installed.
+
+## Architecture
+
+`Program.cs` launches `FormLauncher`, a hub with buttons opening four independent workflows plus a config editor. Each workflow is a self-contained `Form` with its own logic — there is no shared controller/service layer; forms read/write the shared `AppSettings` (user settings) directly.
+
+The four workflows:
+
+1. **FormBatch** — the core "orders → print package" flow. Loads a WooCommerce order CSV into a `DataTable`, scans a source RAW-image folder (`FileSearch`), matches each order line's image URL to a local file by filename, then copies/renames files into the output folder. Output paths are built from `AppSettings.OutputFileMask` via `ParameterizedString` (`{order_no}`, `{prod_name}`, `{ordinal}`, `{ext}`, plus every CSV column normalized to lower_snake_case). Quantity expands to multiple numbered copies. Lines whose format matches `FormatDigitalContains` **and** have an email are routed into a per-email subfolder (the "digital" path) instead of print. Can also emit a protocol CSV (`GenerateProtocol`) stamping external order number/date.
+
+2. **FormProductEdit** + `ProductStorage/` — edit/import the product catalog. `ProductStorage` holds a flat list of `ProductDto` rows where a row is either a product (`Type == "variable"`) or a variant (`Type == "variation"`, linked to parent by SKU). Variants and categories are derived views over that list. `ProductDto.IsValid` encodes the catalog validity rules. Import builds URLs/descriptions from `AppSettings.ImportTpl*` templates.
+
+3. **FormLabelPrint** + `Labels/ExcelLabelGenerator.cs` — CSV → printable `.xlsx` label sheet. `ProcessData` first aggregates rows (group-by `LabelAggregateColumns`, building computed columns via `LabelAggegationDataExpression`). `ExcelLabelGenerator` lays labels out in an N×M grid per A4 page using OpenXML, expanding `{columnName}` placeholders and a lightweight markup in `LabelTemplate`: `**bold**`, `__italic__`, `_underline_`, `^^size^text^^`, and `{cr}` → newline.
+
+4. **FormDigitalSend** + `EmailStorage/EmailStorageProcessor.cs` — emails download links for digital orders. `EmailStorageProcessor` is the substantive class here (FormDigitalSend itself is largely a stub). It treats each subdirectory of an input folder as one recipient (directory name = email address), and drives a **state machine** persisted in `metadata.json`: `Ready → Processing → Uploading → Sending → Sent` (or `Error`). Per item it zips the JPGs, uploads to TSS, generates a download password + permalink, personalizes the email template (`{{DOWNLOAD_LINK}}`, `{{PASSWORD}}`, `{{RECIPIENT}}`, `{{ORDERS}}`), sends via SMTP, and moves the ZIP through hidden dirs `.processing/` → `.sending/` → `.sent/`. Order numbers are parsed from filenames matching `^(\d+)_.*\.jpg$`.
+
+### CSV handling — two distinct parsers
+- **`CsvParser`** (`App/Extensions/CsvParserUtility.cs`) — reflection/attribute-based; maps CSV columns to typed properties via `[CsvColumn("Header Name")]`. Used for the strongly-typed product catalog (`ProductDto`).
+- **`CsvHelper` / `CsvUtils`** (DataTable-based, mostly from `qdr.fnd.core`) — used by FormBatch and FormLabelPrint where columns are dynamic and addressed by name at runtime.
+
+Both respect `AppSettings.CSVDelimiter`.
+
+### Configuration (`AppSettings`)
+All operational config lives in the generated `Properties/AppSettings` user-settings class (`AppSettings.settings`), edited at runtime through `dlgSettings` / `dlgEditCfg`. Key groups: `MapCol*` (map logical fields to actual CSV column names — change these, not the code, when an export's headers differ), output/import templates, label layout + template, TSS credentials (`TSSApiKey`/`TSSUri`/`TSSChunkSizeBytes`), and SMTP. "Reset config" on the launcher restores defaults.
+
+### Testability convention
+File I/O goes through `System.IO.Abstractions` (`IFileSystem`), so logic is unit-testable with `MockFileSystem`. New file-touching logic should take/use `IFileSystem` rather than `System.IO.File`/`Directory` directly. Tests use **NUnit + Moq + System.IO.Abstractions.TestingHelpers**; `EmailStorageProcessorTests` is the model to follow.
+</content>
+</invoke>

+ 117 - 9
README.md

@@ -1,19 +1,126 @@
-# qdr.app.studiou.orders2printpack
+# StudioU Orders2PrintPack
 
-Proprietální řešení zpracování WooCommerce objednávek pro online tiskovou službu (WinForm)
+**Verze 1.3.4** · Quadarax © 2025
+
+Proprietární desktopová aplikace (Windows Forms, .NET 9) pro online tiskovou službu **StudioU**. Zpracovává exporty objednávek z WooCommerce a převádí je na podklady připravené k tisku, katalog produktů, tiskové štítky a e-mailové odesílání digitálního obsahu.
+
+> Uživatelské rozhraní, názvy sloupců CSV i texty jsou v češtině.
+
+---
+
+## Hlavní funkce
+
+Aplikace se spouští úvodní obrazovkou (`FormLauncher`), ze které jsou dostupné čtyři samostatné moduly a editor konfigurace.
+
+### 1. Příprava balíčků k tisku (`FormBatch`)
+Hlavní pracovní postup „objednávky → balíček k tisku":
+- Načte CSV export objednávek z WooCommerce.
+- Prohledá složku se zdrojovými (RAW) soubory a podle názvu souboru spáruje každý řádek objednávky s lokálním obrázkem.
+- Zkopíruje a přejmenuje soubory do výstupní složky podle masky `OutputFileMask` (tokeny `{order_no}`, `{prod_name}`, `{ordinal}`, `{ext}` a libovolný sloupec CSV).
+- Množství (`qty`) se rozkopíruje do číslovaných kopií.
+- Řádky odpovídající formátu digitálního obsahu (`FormatDigitalContains`) s vyplněným e-mailem se směrují do podsložky podle e-mailu místo tisku.
+- Umožňuje vygenerovat protokol (`.csv`) s číslem a datem externí objednávky.
+
+### 2. Editace produktů (`FormProductEdit`)
+Editace a import katalogu produktů a variant:
+- Produkt (`variable`) nebo varianta (`variation`) navázaná na rodiče přes SKU.
+- Generování URL a popisů z šablon (`ImportTpl*`).
+- Kontrola validity a duplicit, editace hodnot Minimum/Maximum množství.
+
+**Číselník variant a ceny.** Pravý panel obsahuje číselník variant (sloupce *Jméno*, *Cena*,
+*Min. Počet*, *Max. Počet*). Tento číselník slouží jako referenční ceník a ukládá se mezi spuštěními
+do uživatelské konfigurace (`PredefinedVariants`); při otevření okna se z ní obnoví.
+
+Doporučený postup pro ceny:
+1. **Import** ze složkové struktury (`kategorie/varianta/soubor`) vytvoří produkty a varianty.
+   Importované varianty vznikají s cenou `0` (cena se z importu nepřebírá).
+2. Cenu nastavíte v číselníku variant a tlačítkem **Použít** ji hromadně přenesete na všechny
+   produktové varianty se shodným názvem.
+
+Číselník variant si ceny drží i při importu – produkt s nulovou (nenastavenou) cenou referenční
+cenu nepřepíše. Skutečná nenulová cena zadaná na produktu se do číselníku naopak promítne.
+
+### 3. Tisk štítků (`FormLabelPrint`)
+Generování tiskových štítků z CSV do souboru `.xlsx` (Excel, přes OpenXML — bez nutnosti Office):
+- Agregace řádků podle `LabelAggregateColumns` a tvorba počítaných sloupců (`LabelAggegationDataExpression`).
+- Rozvržení štítků do mřížky N×M na stránku A4.
+- Šablona `LabelTemplate` podporuje zástupné symboly `{nazevSloupce}` a jednoduché formátování: `**tučně**`, `__kurzíva__`, `_podtržení_`, `^^velikost^text^^` a `{cr}` pro nový řádek.
+
+### 4. Odeslání digitálních dat (`FormDigitalSend` / `EmailStorageProcessor`)
+Odesílání odkazů ke stažení pro digitální objednávky:
+- Každá podsložka vstupního adresáře = jeden příjemce (název složky = e-mail).
+- Stavový automat ukládaný do `metadata.json`: `Ready → Processing → Uploading → Sending → Sent` (případně `Error`).
+- Pro každou položku: zabalí JPG do ZIP, nahraje do **Temporary Shared Storage (TSS)**, vygeneruje heslo a permalink ke stažení, personalizuje e-mailovou šablonu (`{{DOWNLOAD_LINK}}`, `{{PASSWORD}}`, `{{RECIPIENT}}`, `{{ORDERS}}`) a odešle přes SMTP.
+- ZIP soubory procházejí skrytými adresáři `.processing/` → `.sending/` → `.sent/`.
+
+---
+
+## Požadavky
+
+- Windows (Windows Forms, cíl `net9.0-windows7.0`)
+- .NET 9 SDK
+- Externí NuGet závislosti:
+  - `qdr.fnd.core` — sdílená knihovna (logování, prohledávání souborů, CSV, `ParameterizedString`)
+  - `qdr.app.tss.client` — REST klient pro Temporary Shared Storage
+  - `DocumentFormat.OpenXml` — generování `.xlsx` štítků
+
+## Sestavení a spuštění
+
+```powershell
+# Sestavení celého řešení
+dotnet build qdr.app.studiou.orders2printpack.sln
+
+# Spuštění aplikace
+dotnet run --project App
+
+# Spuštění testů (NUnit)
+dotnet test
+```
+
+Instalátor (`Setup/Setup.vdproj`) je projekt Visual Studio Installer — lze sestavit pouze ve Visual Studiu s rozšířením pro instalační projekty, ne přes `dotnet`.
+
+## Konfigurace
+
+Veškeré nastavení je uloženo v uživatelské konfiguraci (`Properties/AppSettings`) a edituje se za běhu přes `dlgSettings` / `dlgEditCfg`. Klíčové skupiny:
+- **Mapování sloupců** (`MapCol*`) — propojení logických polí s názvy sloupců v CSV. Při jiné struktuře exportu se mění zde, ne v kódu.
+- **Šablony** výstupních souborů, importu, štítků.
+- **TSS** — `TSSApiKey`, `TSSUri`, `TSSChunkSizeBytes`.
+- **SMTP** — server, port, přihlášení, SSL.
+
+Tlačítko „Reset konfigurace" na úvodní obrazovce obnoví výchozí hodnoty.
+
+---
 
 ## Changelog
+
+### 1.3.4 (2026-06-09)
+- Oprava: import produktů již nepřepisuje ceny v číselníku variant na `0`. Předdefinované ceny variant (sloupec *Cena*) zůstávají po importu zachovány a není nutné je zadávat znovu ručně.
+
+### 1.3.3 (2025-06)
+- Přidán tag pro zpracování e-mailu (processing email) při tisku štítků
+
+### 1.3.2
+- Oprava agregace množství (quantity) při generování štítků
+
+### 1.3.1
+- Přidán sloupec „Vlastnost 1 varianta" (`[CsvColumn("Vlastnost 1 varianta")]`)
+
+### 1.3.0 (2025-05-26)
+- Přidán TSS REST API client (v.1.0.0)
+- Rozšíření konfigurace o hodnoty pro TSS REST API client
+- Přidána obrazovka „Odeslání digitálních dat"
+
 ### 1.2.4 (2025-05-19)
-- Rozšíření podpory přípravy balíčků k tisku o genenrování digitálního obsahu podle cílového emailu
-- Přidán do konfigurace email a identifikace formátu digitálního obsahu
+- Rozšíření podpory přípravy balíčků k tisku o generování digitálního obsahu podle cílového e-mailu
+- Přidán do konfigurace e-mail a identifikace formátu digitálního obsahu
 
 ### 1.2.3 (2025-04-28)
 - Editace hodnot Minimum/Maximum na Produktu a Variantě
-- Editace RAW hodnot pro Kategorie produktu ,variant a mapování Batch CSV sloupců + delimiter
+- Editace RAW hodnot pro Kategorie produktu, variant a mapování Batch CSV sloupců + delimiter
 - Kontrola duplicit source souborů v importu Batch CSV
 
 ### 1.2.2 (2025-04-01)
-- Při importu produktů se nastavuje fixní ShortDescription a Descrtiption pro produkt
+- Při importu produktů se nastavuje fixní ShortDescription a Description pro produkt
 - Přidány sloupce Maximum/Minimum quantity pro varianty
 
 ### 1.2.1 (2025-04-01)
@@ -28,7 +135,7 @@ Proprietální řešení zpracování WooCommerce objednávek pro online tiskovo
 ### 1.1.1 (2025-03-13)
 - Opraveno, Nový a Uložit otevře dialog pro uložení
 - Oprava importu produktů, měsíc importu v Url je formátován na dvě číslice
-- Oprava importu produktů, při importu se SKU skloží ze jména importovaného souboru bez koncovky
+- Oprava importu produktů, při importu se SKU složí ze jména importovaného souboru bez koncovky
 - Oprava výchozího mapování sloupců
 
 ### 1.1.0 (2024-10-16)
@@ -41,8 +148,9 @@ Proprietální řešení zpracování WooCommerce objednávek pro online tiskovo
 
 ### 1.0.1 (2024-09-27)
 - Přidána podpora generování protokolu (.csv)
-- Rozšíření datové stuktury o položky, OutputFileName, ExternalOrder, ExternalOrderDate
-- Oprav chyb UI
+- Rozšíření datové struktury o položky OutputFileName, ExternalOrder, ExternalOrderDate
+- Oprava chyb UI
 
 ### 1.0.0 (2024-09-27)
 - První verze
+</content>

+ 5 - 5
Setup/Setup.vdproj

@@ -124,7 +124,7 @@
         {
             "{1FB2D0AE-D3B9-43D4-B9DD-F88EC61E35DE}:_BD891965051B4B7DBDEE39DCD2A5C6EE"
             {
-            "SourcePath" = "8:..\\logo_icon.ico"
+            "SourcePath" = "8:..\\App\\logo_icon.ico"
             "TargetName" = "8:logo_icon.ico"
             "Tag" = "8:"
             "Folder" = "8:_96DC0B7F01834A1BA713E7E8A0904D3E"
@@ -198,15 +198,15 @@
         {
         "Name" = "8:Microsoft Visual Studio"
         "ProductName" = "8:Order2PrintPack"
-        "ProductCode" = "8:{018EA4A9-A387-4A8F-92D1-56ECDD3193FB}"
-        "PackageCode" = "8:{7D3C67DE-A909-492F-B232-D8C940908B11}"
+        "ProductCode" = "8:{D42F8167-1B20-4152-9060-F35390D35817}"
+        "PackageCode" = "8:{35847375-6045-4948-BF27-6B8635DC95FA}"
         "UpgradeCode" = "8:{38F8DC1E-8290-49ED-A3D2-32BC6DD74325}"
         "AspNetVersion" = "8:2.0.50727.0"
         "RestartWWWService" = "11:FALSE"
         "RemovePreviousVersions" = "11:TRUE"
         "DetectNewerInstalledVersion" = "11:TRUE"
         "InstallAllUsers" = "11:FALSE"
-        "ProductVersion" = "8:1.2.4"
+        "ProductVersion" = "8:1.3.4"
         "Manufacturer" = "8:Quadarax"
         "ARPHELPTELEPHONE" = "8:"
         "ARPHELPLINK" = "8:"
@@ -734,7 +734,7 @@
         {
             "{5259A561-127C-4D43-A0A1-72F10C7B3BF8}:_401EDA760B6F46889C1BF580F3C37DBF"
             {
-            "SourcePath" = "8:..\\obj\\x64\\Debug\\net9.0-windows7.0\\apphost.exe"
+            "SourcePath" = "8:..\\App\\obj\\x64\\Debug\\net9.0-windows7.0\\apphost.exe"
             "TargetName" = "8:"
             "Tag" = "8:"
             "Folder" = "8:_96DC0B7F01834A1BA713E7E8A0904D3E"

+ 597 - 0
Test/EmailItemTests.cs

@@ -0,0 +1,597 @@
+using qdr.app.studiou.orders2printpack.EmailStorage;
+using System.IO.Abstractions.TestingHelpers;
+
+namespace qdr.app.studiou.orders2printpack.Test
+{
+   [TestFixture]
+    public class EmailItemTests
+    {
+        private MockFileSystem _mockFileSystem;
+        private string _basePath;
+        private MetadataItem _testMetadata;
+
+        [SetUp]
+        public void SetUp()
+        {
+            _mockFileSystem = new MockFileSystem();
+            _basePath = @"C:\TestBase";
+            
+            _testMetadata = new MetadataItem
+            {
+                DirectoryName = "test_user_at_example_dot_com",
+                EmailAddress = "test.user@example.com",
+                State = EmailStateEnum.Ready,
+                Subject = "Test Subject",
+                Body = "Test Body",
+                FileNameAttachment = "test_attachment.zip",
+                SizeAttachment = 1024,
+                InternalFilesCount = 3,
+                Orders = "1001,1002,1003"
+            };
+
+            // Create base directory
+            _mockFileSystem.AddDirectory(_basePath);
+        }
+
+        #region Constructor Tests
+
+        [Test]
+        public void Constructor_WithValidParameters_ShouldInitializeCorrectly()
+        {
+            // Act
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Assert
+            Assert.That(emailItem.Metadata, Is.EqualTo(_testMetadata));
+            Assert.That(emailItem.DirectoryPath, Is.EqualTo(Path.Combine(_basePath, _testMetadata.DirectoryName)));
+        }
+
+        [Test]
+        public void Constructor_WithNullMetadata_ShouldThrowArgumentNullException()
+        {
+            // Act & Assert
+            var ex = Assert.Throws<ArgumentNullException>(() => new EmailItem(null, _basePath, _mockFileSystem));
+            Assert.That(ex.ParamName, Is.EqualTo("metadata"));
+        }
+
+        [Test]
+        public void Constructor_WithNullBasePath_ShouldThrowArgumentNullException()
+        {
+            // Act & Assert
+            var ex = Assert.Throws<ArgumentNullException>(() => new EmailItem(_testMetadata, null, _mockFileSystem));
+            Assert.That(ex.ParamName, Is.EqualTo("basePath"));
+        }
+
+        #endregion
+
+        #region Property Tests
+
+        [Test]
+        public void DirectoryPath_ShouldCombineBasePathWithDirectoryName()
+        {
+            // Arrange
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+            var expectedPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+
+            // Act & Assert
+            Assert.That(emailItem.DirectoryPath, Is.EqualTo(expectedPath));
+        }
+
+        [Test]
+        public void DirectoryExists_WhenDirectoryExists_ShouldReturnTrue()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.DirectoryExists, Is.True);
+        }
+
+        [Test]
+        public void DirectoryExists_WhenDirectoryDoesNotExist_ShouldReturnFalse()
+        {
+            // Arrange
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.DirectoryExists, Is.False);
+        }
+
+        [Test]
+        public void JpgFiles_WhenDirectoryExists_ShouldReturnJpgFiles()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData("image1"));
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image2.jpg"), new MockFileData("image2"));
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "document.txt"), new MockFileData("text"));
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var jpgFiles = emailItem.JpgFiles.ToList();
+
+            // Assert
+            Assert.That(jpgFiles.Count, Is.EqualTo(2));
+            Assert.That(jpgFiles.Any(f => f.EndsWith("image1.jpg")), Is.True);
+            Assert.That(jpgFiles.Any(f => f.EndsWith("image2.jpg")), Is.True);
+            Assert.That(jpgFiles.Any(f => f.EndsWith("document.txt")), Is.False);
+        }
+
+        [Test]
+        public void JpgFiles_WhenDirectoryDoesNotExist_ShouldReturnEmptyEnumerable()
+        {
+            // Arrange
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var jpgFiles = emailItem.JpgFiles.ToList();
+
+            // Assert
+            Assert.That(jpgFiles, Is.Empty);
+        }
+
+        [Test]
+        public void FileCount_WhenDirectoryHasJpgFiles_ShouldReturnCorrectCount()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData("image1"));
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image2.jpg"), new MockFileData("image2"));
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image3.jpg"), new MockFileData("image3"));
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.FileCount, Is.EqualTo(3));
+        }
+
+        [Test]
+        public void FileCount_WhenDirectoryDoesNotExist_ShouldReturnZero()
+        {
+            // Arrange
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.FileCount, Is.EqualTo(0));
+        }
+
+        [Test]
+        public void TotalFileSize_WhenDirectoryHasJpgFiles_ShouldReturnCorrectTotalSize()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData(new byte[100])); // 100 bytes
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image2.jpg"), new MockFileData(new byte[200])); // 200 bytes
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image3.jpg"), new MockFileData(new byte[300])); // 300 bytes
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.TotalFileSize, Is.EqualTo(600));
+        }
+
+        [Test]
+        public void TotalFileSize_WhenDirectoryDoesNotExist_ShouldReturnZero()
+        {
+            // Arrange
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.TotalFileSize, Is.EqualTo(0));
+        }
+
+        [Test]
+        public void IsReadyForProcessing_WhenStateIsReadyAndDirectoryExistsWithFiles_ShouldReturnTrue()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData("image1"));
+            
+            _testMetadata.State = EmailStateEnum.Ready;
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.IsReadyForProcessing, Is.True);
+        }
+
+        [Test]
+        public void IsReadyForProcessing_WhenStateIsNotReady_ShouldReturnFalse()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData("image1"));
+            
+            _testMetadata.State = EmailStateEnum.Processing;
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.IsReadyForProcessing, Is.False);
+        }
+
+        [Test]
+        public void IsReadyForProcessing_WhenDirectoryDoesNotExist_ShouldReturnFalse()
+        {
+            // Arrange
+            _testMetadata.State = EmailStateEnum.Ready;
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.IsReadyForProcessing, Is.False);
+        }
+
+        [Test]
+        public void IsReadyForProcessing_WhenDirectoryExistsButNoFiles_ShouldReturnFalse()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            
+            _testMetadata.State = EmailStateEnum.Ready;
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.IsReadyForProcessing, Is.False);
+        }
+
+        [Test]
+        public void CanBeReset_WhenStateIsNotProcessingOrUploading_ShouldReturnTrue()
+        {
+            // Arrange
+            var testCases = new[]
+            {
+                EmailStateEnum.Ready,
+                EmailStateEnum.Sending,
+                EmailStateEnum.Sent,
+                EmailStateEnum.Error
+            };
+
+            foreach (var state in testCases)
+            {
+                _testMetadata.State = state;
+                var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+                // Act & Assert
+                Assert.That(emailItem.CanBeReset, Is.True, $"Failed for state: {state}");
+            }
+        }
+
+        [Test]
+        public void CanBeReset_WhenStateIsProcessingOrUploading_ShouldReturnFalse()
+        {
+            // Arrange
+            var testCases = new[]
+            {
+                EmailStateEnum.Processing,
+                EmailStateEnum.Uploading
+            };
+
+            foreach (var state in testCases)
+            {
+                _testMetadata.State = state;
+                var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+                // Act & Assert
+                Assert.That(emailItem.CanBeReset, Is.False, $"Failed for state: {state}");
+            }
+        }
+
+        [Test]
+        public void NormalizedRecipient_ShouldReplaceSpecialCharacters()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "test.user@example.com";
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.NormalizedRecipient, Is.EqualTo("test_dot_user_at_example_dot_com"));
+        }
+
+        [Test]
+        public void NormalizedRecipient_WithComplexEmail_ShouldHandleMultipleSpecialCharacters()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "test.user.name@sub.domain.example.com";
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.NormalizedRecipient, Is.EqualTo("test_dot_user_dot_name_at_sub_dot_domain_dot_example_dot_com"));
+        }
+
+        #endregion
+
+        #region Method Tests
+
+        [Test]
+        public void GenerateZipFileName_WithDefaultTimestamp_ShouldIncludeCurrentTime()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "test.user@example.com";
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var fileName = emailItem.GenerateZipFileName();
+
+            // Assert
+            Assert.That(fileName, Does.StartWith("studioufoto_test_dot_user_at_example_dot_com_"));
+            Assert.That(fileName, Does.EndWith(".zip"));
+            
+            // Check that it contains a timestamp pattern (YYYYMMDD_HHMMSS)
+            var timestampPattern = @"\d{8}_\d{6}";
+            Assert.That(fileName, Does.Match($@"studioufoto_test_dot_user_at_example_dot_com_{timestampPattern}\.zip"));
+        }
+
+        [Test]
+        public void GenerateZipFileName_WithSpecificTimestamp_ShouldUseProvidedTimestamp()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "test.user@example.com";
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+            var timestamp = new DateTime(2024, 5, 30, 14, 30, 45);
+
+            // Act
+            var fileName = emailItem.GenerateZipFileName(timestamp);
+
+            // Assert
+            Assert.That(fileName, Is.EqualTo("studioufoto_test_dot_user_at_example_dot_com_20240530_143045.zip"));
+        }
+
+        [Test]
+        public void Validate_WithAllValidData_ShouldReturnValid()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData("image1"));
+            
+            _testMetadata.EmailAddress = "test.user@example.com";
+            _testMetadata.Subject = "Valid Subject";
+            _testMetadata.Body = "Valid Body";
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var (isValid, issues) = emailItem.Validate();
+
+            // Assert
+            Assert.That(isValid, Is.True);
+            Assert.That(issues, Is.Empty);
+        }
+
+        [Test]
+        public void Validate_WithMissingEmailAddress_ShouldReturnInvalid()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "";
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var (isValid, issues) = emailItem.Validate();
+
+            // Assert
+            Assert.That(isValid, Is.False);
+            Assert.That(issues, Contains.Item("Email address is required"));
+        }
+
+        [Test]
+        public void Validate_WithNullEmailAddress_ShouldReturnInvalid()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = null;
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var (isValid, issues) = emailItem.Validate();
+
+            // Assert
+            Assert.That(isValid, Is.False);
+            Assert.That(issues, Contains.Item("Email address is required"));
+        }
+
+        [Test]
+        public void Validate_WithNonExistentDirectory_ShouldReturnInvalid()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "test.user@example.com";
+            _testMetadata.Subject = "Valid Subject";
+            _testMetadata.Body = "Valid Body";
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var (isValid, issues) = emailItem.Validate();
+
+            // Assert
+            Assert.That(isValid, Is.False);
+            Assert.That(issues, Contains.Item($"Directory does not exist: {emailItem.DirectoryPath}"));
+        }
+
+        [Test]
+        public void Validate_WithDirectoryButNoJpgFiles_ShouldReturnInvalid()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            
+            _testMetadata.EmailAddress = "test.user@example.com";
+            _testMetadata.Subject = "Valid Subject";
+            _testMetadata.Body = "Valid Body";
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var (isValid, issues) = emailItem.Validate();
+
+            // Assert
+            Assert.That(isValid, Is.False);
+            Assert.That(issues, Contains.Item("No JPG files found in directory"));
+        }
+
+        [Test]
+        public void Validate_WithMissingSubject_ShouldReturnInvalid()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData("image1"));
+            
+            _testMetadata.EmailAddress = "test.user@example.com";
+            _testMetadata.Subject = "";
+            _testMetadata.Body = "Valid Body";
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var (isValid, issues) = emailItem.Validate();
+
+            // Assert
+            Assert.That(isValid, Is.False);
+            Assert.That(issues, Contains.Item("Email subject is required"));
+        }
+
+        [Test]
+        public void Validate_WithMissingBody_ShouldReturnInvalid()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData("image1"));
+            
+            _testMetadata.EmailAddress = "test.user@example.com";
+            _testMetadata.Subject = "Valid Subject";
+            _testMetadata.Body = "";
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var (isValid, issues) = emailItem.Validate();
+
+            // Assert
+            Assert.That(isValid, Is.False);
+            Assert.That(issues, Contains.Item("Email body is required"));
+        }
+
+        [Test]
+        public void Validate_WithMultipleIssues_ShouldReturnAllIssues()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "";
+            _testMetadata.Subject = "";
+            _testMetadata.Body = "";
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var (isValid, issues) = emailItem.Validate();
+
+            // Assert
+            Assert.That(isValid, Is.False);
+            Assert.That(issues.Count, Is.EqualTo(4)); // Email, Directory, Subject, Body
+            Assert.That(issues, Contains.Item("Email address is required"));
+            Assert.That(issues, Contains.Item($"Directory does not exist: {emailItem.DirectoryPath}"));
+            Assert.That(issues, Contains.Item("Email subject is required"));
+            Assert.That(issues, Contains.Item("Email body is required"));
+        }
+
+        [Test]
+        public void GetSummary_ShouldReturnFormattedSummary()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData("image1"));
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image2.jpg"), new MockFileData("image2"));
+            
+            _testMetadata.EmailAddress = "test.user@example.com";
+            _testMetadata.State = EmailStateEnum.Ready;
+            _testMetadata.Orders = "1001,1002";
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var summary = emailItem.GetSummary();
+
+            // Assert
+            Assert.That(summary, Is.EqualTo("test.user@example.com - 2 files, Ready, Orders: 1001,1002"));
+        }
+
+        [Test]
+        public void GetSummary_WithNoFiles_ShouldShowZeroFiles()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "test.user@example.com";
+            _testMetadata.State = EmailStateEnum.Error;
+            _testMetadata.Orders = "1001";
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var summary = emailItem.GetSummary();
+
+            // Assert
+            Assert.That(summary, Is.EqualTo("test.user@example.com - 0 files, Error, Orders: 1001"));
+        }
+
+        #endregion
+
+        #region Edge Cases and Boundary Tests
+
+        [Test]
+        public void NormalizedRecipient_WithEmptyEmail_ShouldReturnEmpty()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "";
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act & Assert
+            Assert.That(emailItem.NormalizedRecipient, Is.EqualTo(""));
+        }
+
+        [Test]
+        public void GenerateZipFileName_WithEmptyEmail_ShouldStillGenerateFileName()
+        {
+            // Arrange
+            _testMetadata.EmailAddress = "";
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var fileName = emailItem.GenerateZipFileName();
+
+            // Assert
+            Assert.That(fileName, Does.StartWith("studioufoto__"));
+            Assert.That(fileName, Does.EndWith(".zip"));
+        }
+
+        [Test]
+        public void JpgFiles_WithMixedCaseExtensions_ShouldReturnJpgFilesOnly()
+        {
+            // Arrange
+            var directoryPath = Path.Combine(_basePath, _testMetadata.DirectoryName);
+            _mockFileSystem.AddDirectory(directoryPath);
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image1.jpg"), new MockFileData("image1"));
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image2.JPG"), new MockFileData("image2"));
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "image3.jpeg"), new MockFileData("image3"));
+            _mockFileSystem.AddFile(Path.Combine(directoryPath, "document.txt"), new MockFileData("text"));
+            
+            var emailItem = new EmailItem(_testMetadata, _basePath, _mockFileSystem);
+
+            // Act
+            var jpgFiles = emailItem.JpgFiles.ToList();
+
+            // Assert
+            // Note: MockFileSystem's GetFiles with "*.jpg" pattern is case-sensitive
+            // This test verifies the current behavior - adjust if case-insensitive matching is needed
+            Assert.That(jpgFiles.Count, Is.EqualTo(2)); // Only lowercase .jpg
+            Assert.That(jpgFiles.Any(f => f.EndsWith("image1.jpg")), Is.True);
+        }
+
+        #endregion
+    }
+}

+ 592 - 0
Test/EmailStorageProcessorTests.cs

@@ -0,0 +1,592 @@
+using System.IO.Abstractions.TestingHelpers;
+using System.Text.Json;
+using Quadarax.Foundation.Core.Logging;
+using qdr.app.studiou.orders2printpack.EmailStorage;
+using Moq;
+
+namespace qdr.app.studiou.orders2printpack.Test
+{
+    [TestFixture]
+    public class EmailStorageProcessorTests
+    {
+        private Mock<ILogger> _mockLogger;
+        private Mock<ILog> _mockLog;
+        private MockFileSystem _mockFileSystem;
+        private EmailTemplate _emailTemplate;
+        private EmailProcessorConfiguration _configuration;
+        private string _testBasePath;
+        private EmailStorageProcessor _processor;
+
+        [SetUp]
+        public void SetUp()
+        {
+            // Setup mocks
+            _mockLogger = new Mock<ILogger>();
+            _mockLog = new Mock<ILog>();
+            _mockLogger.Setup(x => x.GetLogger(It.IsAny<Type>())).Returns(_mockLog.Object);
+
+            // Setup test paths
+            _testBasePath = @"C:\TestInput";
+
+            // Setup mock file system with initial directory structure
+            _mockFileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
+            {
+                // Create base directory
+                { _testBasePath, new MockDirectoryData() },
+                // Create a test recipient directory with JPG files
+                { Path.Combine(_testBasePath, "test@example.com"), new MockDirectoryData() },
+                { Path.Combine(_testBasePath, "test@example.com", "1001_photo1.jpg"), new MockFileData("fake jpg content") },
+                { Path.Combine(_testBasePath, "test@example.com", "1001_photo2.jpg"), new MockFileData("fake jpg content") },
+                { Path.Combine(_testBasePath, "test@example.com", "1002_photo1.jpg"), new MockFileData("fake jpg content") },
+            });
+
+            // Setup email template
+            _emailTemplate = new EmailTemplate
+            {
+                Code = "TEST_TEMPLATE",
+                Subject = "Your photos are ready - Orders {{ORDERS}}",
+                Body = "Hello {{RECIPIENT}},\n\nYour photos are ready for download:\n{{DOWNLOAD_LINK}}\nPassword: {{PASSWORD}}\n\nOrders: {{ORDERS}}"
+            };
+
+            // Setup configuration
+            _configuration = new EmailProcessorConfiguration
+            {
+                TssAPIKey = "test-api-key",
+                TssUrl = "https://test-tss-url.com",
+                SmtpServer = "smtp.test.com",
+                SmtpServerPort = 587,
+                SmtpServerUser = "test@smtp.com",
+                SmtpServerPassword = "test-password",
+                SmtpEnableSsl = true
+            };
+        }
+
+        [TearDown]
+        public void TearDown()
+        {
+            _processor?.Dispose();
+        }
+
+        private EmailStorageProcessor CreateProcessor()
+        {
+            return new EmailStorageProcessor(_mockFileSystem, _testBasePath, _emailTemplate, _configuration, _mockLogger.Object);
+        }
+
+        #region Constructor Tests
+
+        [Test]
+        public void Constructor_WithValidParameters_InitializesSuccessfully()
+        {
+            // Act
+            _processor = CreateProcessor();
+
+            // Assert
+            Assert.That(_processor, Is.Not.Null);
+            Assert.That(_processor.Metadata, Is.Not.Null);
+            Assert.That(_processor.Metadata.Items, Is.Not.Null);
+        }
+
+        [Test]
+        public void Constructor_WithNullFileSystem_ThrowsArgumentNullException()
+        {
+            // Act & Assert
+            Assert.Throws<ArgumentNullException>(() => 
+                new EmailStorageProcessor(null, _testBasePath, _emailTemplate, _configuration, _mockLogger.Object));
+        }
+
+        [Test]
+        public void Constructor_WithNullPath_ThrowsArgumentNullException()
+        {
+            // Act & Assert
+            Assert.Throws<ArgumentNullException>(() => 
+                new EmailStorageProcessor(_mockFileSystem, null, _emailTemplate, _configuration, _mockLogger.Object));
+        }
+
+        [Test]
+        public void Constructor_WithNullEmailTemplate_ThrowsArgumentNullException()
+        {
+            // Act & Assert
+            Assert.Throws<ArgumentNullException>(() => 
+                new EmailStorageProcessor(_mockFileSystem, _testBasePath, null, _configuration, _mockLogger.Object));
+        }
+
+        [Test]
+        public void Constructor_WithNullConfiguration_ThrowsArgumentNullException()
+        {
+            // Act & Assert
+            Assert.Throws<ArgumentNullException>(() => 
+                new EmailStorageProcessor(_mockFileSystem, _testBasePath, _emailTemplate, null, _mockLogger.Object));
+        }
+
+        [Test]
+        public void Constructor_WithNullLogger_ThrowsArgumentNullException()
+        {
+            // Act & Assert
+            Assert.Throws<ArgumentNullException>(() => 
+                new EmailStorageProcessor(_mockFileSystem, _testBasePath, _emailTemplate, _configuration, null));
+        }
+
+        [Test]
+        public void Constructor_CreatesRequiredDirectories()
+        {
+            // Act
+            _processor = CreateProcessor();
+
+            // Assert
+            Assert.That(_mockFileSystem.Directory.Exists(Path.Combine(_testBasePath, ".processing")), Is.True);
+            Assert.That(_mockFileSystem.Directory.Exists(Path.Combine(_testBasePath, ".sending")), Is.True);
+            Assert.That(_mockFileSystem.Directory.Exists(Path.Combine(_testBasePath, ".sent")), Is.True);
+        }
+
+        [Test]
+        public void Constructor_ScansExistingDirectories_CreatesMetadataItems()
+        {
+            // Act
+            _processor = CreateProcessor();
+
+            // Assert
+            Assert.That(_processor.Metadata.Items.Count, Is.EqualTo(1));
+            
+            var item = _processor.Metadata.Items.First();
+            Assert.That(item.DirectoryName, Is.EqualTo("test@example.com"));
+            Assert.That(item.EmailAddress, Is.EqualTo("test@example.com"));
+            Assert.That(item.State, Is.EqualTo(EmailStateEnum.Ready));
+            Assert.That(item.InternalFilesCount, Is.EqualTo(3));
+            Assert.That(item.Orders, Is.EqualTo("1001,1002"));
+        }
+
+        #endregion
+
+        #region Property Tests
+
+        [Test]
+        public void CountTotal_ReturnsCorrectCount()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+
+            // Act
+            var count = _processor.CountTotal;
+
+            // Assert
+            Assert.That(count, Is.EqualTo(1));
+        }
+
+        [Test]
+        public void CountProcessing_WithProcessingItems_ReturnsCorrectCount()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+            _processor.Metadata.Items.First().State = EmailStateEnum.Processing;
+
+            // Act
+            var count = _processor.CountProcessing;
+
+            // Assert
+            Assert.That(count, Is.EqualTo(1));
+        }
+
+        [Test]
+        public void CountSending_WithSendingItems_ReturnsCorrectCount()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+            _processor.Metadata.Items.First().State = EmailStateEnum.Sending;
+
+            // Act
+            var count = _processor.CountSending;
+
+            // Assert
+            Assert.That(count, Is.EqualTo(1));
+        }
+
+        [Test]
+        public void CountSent_WithSentItems_ReturnsCorrectCount()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+            _processor.Metadata.Items.First().State = EmailStateEnum.Sent;
+
+            // Act
+            var count = _processor.CountSent;
+
+            // Assert
+            Assert.That(count, Is.EqualTo(1));
+        }
+
+        #endregion
+
+        #region ResetItem Tests
+
+        [Test]
+        public void ResetItem_WithValidRecipient_ResetsItemToReadyState()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+            var item = _processor.Metadata.Items.First();
+            item.State = EmailStateEnum.Error;
+            item.FileNameAttachment = "test.zip";
+            item.SizeAttachment = 1000;
+            item.UploadPermalink = "http://test.com";
+            item.DownloadPassword = "password";
+            item.ProcessedDate = DateTime.Now;
+            item.SentDate = DateTime.Now;
+            item.ErrorMessage = "Test error";
+
+            // Act
+            _processor.ResetItem("test@example.com");
+
+            // Assert
+            Assert.That(item.State, Is.EqualTo(EmailStateEnum.Ready));
+            Assert.That(item.FileNameAttachment, Is.EqualTo(string.Empty));
+            Assert.That(item.SizeAttachment, Is.EqualTo(0));
+            Assert.That(item.UploadPermalink, Is.EqualTo(string.Empty));
+            Assert.That(item.DownloadPassword, Is.EqualTo(string.Empty));
+            Assert.That(item.ProcessedDate, Is.Null);
+            Assert.That(item.SentDate, Is.Null);
+            Assert.That(item.ErrorMessage, Is.Null);
+        }
+
+        [Test]
+        public void ResetItem_WithNonExistentRecipient_LogsWarning()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+
+            // Act
+            _processor.ResetItem("nonexistent@example.com");
+
+            // Assert
+            _mockLog.Verify(x => x.Log(LogSeverityEnum.Warn, It.IsAny<string>()), Times.Once);
+        }
+
+        [Test]
+        public void ResetItem_CleansUpAssociatedFiles()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+            var item = _processor.Metadata.Items.First();
+            item.FileNameAttachment = "test.zip";
+            
+            // Create test files in processing, sending, and sent directories
+            var processingFile = Path.Combine(_testBasePath, ".processing", "test.zip");
+            var sendingFile = Path.Combine(_testBasePath, ".sending", "test.zip");
+            var sentFile = Path.Combine(_testBasePath, ".sent", "test.zip");
+            
+            _mockFileSystem.AddFile(processingFile, new MockFileData("test"));
+            _mockFileSystem.AddFile(sendingFile, new MockFileData("test"));
+            _mockFileSystem.AddFile(sentFile, new MockFileData("test"));
+
+            // Act
+            _processor.ResetItem("test@example.com");
+
+            // Assert
+            Assert.That(_mockFileSystem.File.Exists(processingFile), Is.False);
+            Assert.That(_mockFileSystem.File.Exists(sendingFile), Is.False);
+            Assert.That(_mockFileSystem.File.Exists(sentFile), Is.False);
+        }
+
+        #endregion
+
+        #region ProcessItemAsync Tests
+
+        [Test]
+        public async Task ProcessItemAsync_WithNonExistentRecipient_LogsErrorAndReturns()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+
+            // Act
+            await _processor.ProcessItemAsync("nonexistent@example.com");
+
+            // Assert
+            _mockLog.Verify(x => x.Log(LogSeverityEnum.Error, It.IsAny<string>()), Times.Once);
+        }
+
+        [Test]
+        public async Task ProcessItemAsync_WithValidRecipient_UpdatesStateToProcessing()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+            bool stateChangeEventFired = false;
+            EmailStateEnum capturedState = EmailStateEnum.Ready;
+
+            _processor.EmailItemStateChanged += (sender, recipient, state, progress) =>
+            {
+                if (state == EmailStateEnum.Processing)
+                {
+                    stateChangeEventFired = true;
+                    capturedState = state;
+                }
+            };
+
+            // Act
+            try
+            {
+                await _processor.ProcessItemAsync("test@example.com");
+            }
+            catch
+            {
+                // Expected to fail due to missing external dependencies, but we want to check state change
+            }
+
+            // Assert
+            Assert.That(stateChangeEventFired, Is.True);
+            Assert.That(capturedState, Is.EqualTo(EmailStateEnum.Processing));
+        }
+
+        #endregion
+
+        #region ProcessAllAsync Tests
+
+        [Test]
+        public async Task ProcessAllAsync_ProcessesAllReadyItems()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+            
+            // Add another test item
+            _mockFileSystem.AddDirectory(Path.Combine(_testBasePath, "test2@example.com"));
+            _mockFileSystem.AddFile(Path.Combine(_testBasePath, "test2@example.com", "2001_photo1.jpg"), new MockFileData("content"));
+            
+            // Recreate processor to pick up new directory
+            _processor.Dispose();
+            _processor = CreateProcessor();
+
+            // Act
+            try
+            {
+                await _processor.ProcessAllAsync();
+            }
+            catch
+            {
+                // Expected to fail due to missing external dependencies
+            }
+
+            // Assert
+            // Verify that processing was attempted for all items
+            _mockLog.Verify(x => x.Log(LogSeverityEnum.Info, It.Is<string>(s => s.Contains("Starting processing for recipient:"))), Times.AtLeast(1));
+        }
+
+        #endregion
+
+        #region Stop Method Tests
+
+        [Test]
+        public void Stop_SetsCancellationToken()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+
+            // Act
+            _processor.Stop();
+
+            // Assert
+            _mockLog.Verify(x => x.Log(LogSeverityEnum.Info, "Processing stop requested."), Times.Once);
+        }
+
+        #endregion
+
+        #region Event Tests
+
+        [Test]
+        public void EmailItemStateChanged_WhenStateUpdated_FiresEvent()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+            bool eventFired = false;
+            string capturedRecipient = "";
+            EmailStateEnum capturedState = EmailStateEnum.Ready;
+            int capturedProgress = 0;
+
+            _processor.EmailItemStateChanged += (sender, recipient, state, progress) =>
+            {
+                eventFired = true;
+                capturedRecipient = recipient;
+                capturedState = state;
+                capturedProgress = progress;
+            };
+
+            var item = _processor.Metadata.Items.First();
+
+            // Act
+            _processor.ResetItem("test@example.com"); // This should trigger state change
+
+            // Assert
+            Assert.That(eventFired, Is.True);
+            Assert.That(capturedRecipient, Is.EqualTo("test@example.com"));
+            Assert.That(capturedState, Is.EqualTo(EmailStateEnum.Ready));
+        }
+
+        #endregion
+
+        #region Metadata Management Tests
+
+        [Test]
+        public void Constructor_WithExistingMetadataFile_LoadsMetadata()
+        {
+            // Arrange
+            var existingMetadata = new Metadata
+            {
+                Created = DateTime.Now.AddDays(-1),
+                Modified = DateTime.Now.AddHours(-1),
+                Items = new List<MetadataItem>
+                {
+                    new MetadataItem
+                    {
+                        DirectoryName = "existing@example.com",
+                        EmailAddress = "existing@example.com",
+                        State = EmailStateEnum.Sent,
+                        Subject = "Test Subject",
+                        Body = "Test Body",
+                        InternalFilesCount = 5,
+                        Orders = "3001,3002"
+                    }
+                }
+            };
+
+            var metadataJson = JsonSerializer.Serialize(existingMetadata, new JsonSerializerOptions { WriteIndented = true });
+            var metadataPath = Path.Combine(_testBasePath, "metadata.json");
+            _mockFileSystem.AddFile(metadataPath, new MockFileData(metadataJson));
+
+            // Act
+            _processor = CreateProcessor();
+
+            // Assert
+            Assert.That(_processor.Metadata.Items.Count, Is.GreaterThan(0));
+            var existingItem = _processor.Metadata.Items.FirstOrDefault(x => x.EmailAddress == "existing@example.com");
+            Assert.That(existingItem, Is.Null);
+            existingItem = _processor.Metadata.Items.FirstOrDefault(x => x.EmailAddress == "test@example.com");
+            Assert.That(existingItem.State, Is.EqualTo(EmailStateEnum.Ready));
+        }
+
+        [Test]
+        public void Constructor_ScansDirectoriesAndUpdatesMetadata()
+        {
+            // Arrange - metadata file exists but doesn't contain current directory
+            var existingMetadata = new Metadata
+            {
+                Created = DateTime.Now.AddDays(-1),
+                Items = new List<MetadataItem>()
+            };
+
+            var metadataJson = JsonSerializer.Serialize(existingMetadata);
+            var metadataPath = Path.Combine(_testBasePath, "metadata.json");
+            _mockFileSystem.AddFile(metadataPath, new MockFileData(metadataJson));
+
+            // Act
+            _processor = CreateProcessor();
+
+            // Assert
+            Assert.That(_processor.Metadata.Items.Count, Is.EqualTo(1));
+            var newItem = _processor.Metadata.Items.First();
+            Assert.That(newItem.DirectoryName, Is.EqualTo("test@example.com"));
+            Assert.That(newItem.EmailAddress, Is.EqualTo("test@example.com"));
+        }
+
+        #endregion
+
+        #region File Operations Tests
+
+        [Test]
+        public void Constructor_WithOrderNumbersInFilenames_ExtractsOrdersCorrectly()
+        {
+            // Arrange - setup files with different order patterns
+            _mockFileSystem.RemoveFile(Path.Combine(_testBasePath, "test@example.com", "1001_photo1.jpg"));
+            _mockFileSystem.RemoveFile(Path.Combine(_testBasePath, "test@example.com", "1001_photo2.jpg"));
+            _mockFileSystem.RemoveFile(Path.Combine(_testBasePath, "test@example.com", "1002_photo1.jpg"));
+            
+            _mockFileSystem.AddFile(Path.Combine(_testBasePath, "test@example.com", "5001_portrait.jpg"), new MockFileData("content"));
+            _mockFileSystem.AddFile(Path.Combine(_testBasePath, "test@example.com", "5002_landscape.jpg"), new MockFileData("content"));
+            _mockFileSystem.AddFile(Path.Combine(_testBasePath, "test@example.com", "5001_closeup.jpg"), new MockFileData("content"));
+            _mockFileSystem.AddFile(Path.Combine(_testBasePath, "test@example.com", "invalid_name.jpg"), new MockFileData("content"));
+
+            // Act
+            _processor = CreateProcessor();
+
+            // Assert
+            var item = _processor.Metadata.Items.First();
+            Assert.That(item.Orders, Is.EqualTo("5001,5002")); // Should extract unique order numbers
+            Assert.That(item.InternalFilesCount, Is.EqualTo(4)); // Should count all JPG files
+        }
+
+        #endregion
+
+        #region Integration Tests
+
+        [Test]
+        public void Constructor_WithMultipleRecipientDirectories_CreatesMultipleItems()
+        {
+            // Arrange
+            _mockFileSystem.AddDirectory(Path.Combine(_testBasePath, "user1@test.com"));
+            _mockFileSystem.AddFile(Path.Combine(_testBasePath, "user1@test.com", "1001_photo.jpg"), new MockFileData("content"));
+            
+            _mockFileSystem.AddDirectory(Path.Combine(_testBasePath, "user2@test.com"));
+            _mockFileSystem.AddFile(Path.Combine(_testBasePath, "user2@test.com", "2001_photo.jpg"), new MockFileData("content"));
+
+            // Act
+            _processor = CreateProcessor();
+
+            // Assert
+            Assert.That(_processor.Metadata.Items.Count, Is.EqualTo(3)); // Original + 2 new
+            
+            var emails = _processor.Metadata.Items.Select(x => x.EmailAddress).ToList();
+            Assert.That(emails, Contains.Item("test@example.com"));
+            Assert.That(emails, Contains.Item("user1@test.com"));
+            Assert.That(emails, Contains.Item("user2@test.com"));
+        }
+
+        [Test]
+        public void Constructor_IgnoresHiddenDirectories()
+        {
+            // Arrange
+            _mockFileSystem.AddDirectory(Path.Combine(_testBasePath, ".hidden"));
+            _mockFileSystem.AddFile(Path.Combine(_testBasePath, ".hidden", "1001_photo.jpg"), new MockFileData("content"));
+            
+            _mockFileSystem.AddDirectory(Path.Combine(_testBasePath, ".processing"));
+            _mockFileSystem.AddFile(Path.Combine(_testBasePath, ".processing", "test.zip"), new MockFileData("content"));
+
+            // Act
+            _processor = CreateProcessor();
+
+            // Assert
+            // Should only have the original test@example.com directory, not the hidden ones
+            Assert.That(_processor.Metadata.Items.Count, Is.EqualTo(1));
+            Assert.That(_processor.Metadata.Items.First().DirectoryName, Is.EqualTo("test@example.com"));
+        }
+
+        #endregion
+
+        #region Dispose Tests
+
+        [Test]
+        public void Dispose_DisposesResourcesProperly()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+
+            // Act
+            _processor.Dispose();
+
+            // Assert - Should not throw
+            Assert.Pass("Dispose completed without exceptions");
+        }
+
+        [Test]
+        public void Dispose_CalledMultipleTimes_DoesNotThrow()
+        {
+            // Arrange
+            _processor = CreateProcessor();
+
+            // Act & Assert
+            Assert.DoesNotThrow(() =>
+            {
+                _processor.Dispose();
+                _processor.Dispose();
+                _processor.Dispose();
+            });
+        }
+
+        #endregion
+    }
+}

+ 89 - 0
Test/ProductStorageTests.cs

@@ -0,0 +1,89 @@
+using ProductStore = qdr.app.studiou.orders2printpack.ProductStorage.ProductStorage;
+
+namespace qdr.app.studiou.orders2printpack.Test
+{
+    /// <summary>
+    /// Tests around the variant price behaviour when products are imported.
+    /// Import creates product variants with Price = 0; the predefined variant
+    /// prices (loaded from settings) must survive that, otherwise the user has
+    /// to re-enter every price by hand after each import.
+    /// </summary>
+    [TestFixture]
+    public class ProductStorageTests
+    {
+        // Mirrors how ImportProducts adds a variant: a parent product plus a
+        // variation row whose VariantName matches a predefined variant, Price = 0.
+        private static void SimulateImportedVariant(ProductStore storage, string parentSku, string variantName, decimal price = 0m)
+        {
+            var parent = storage.AddNewProduct();
+            parent.Sku = parentSku;
+
+            var variant = storage.AddNewProductVariant(parentSku);
+            variant.VariantName = variantName;
+            variant.Price = price;
+        }
+
+        [Test]
+        public void Import_DoesNotResetPredefinedVariantPrice()
+        {
+            // Arrange: predefined variants with prices, as restored from settings.
+            var storage = new ProductStore();
+            storage.AppendVariantsFromString("Foto 10x15cm=150;Foto 15x23cm=120");
+
+            // Act: import brings in a matching variant priced at 0.
+            SimulateImportedVariant(storage, "P1", "Foto 10x15cm", price: 0m);
+            var variants = storage.Variants.ToList();
+
+            // Assert: the configured price is kept, not overwritten with 0.
+            var imported = variants.Single(x => x.Name == "Foto 10x15cm");
+            Assert.That(imported.Price, Is.EqualTo(150m));
+
+            // The untouched predefined variant is unaffected too.
+            var other = variants.Single(x => x.Name == "Foto 15x23cm");
+            Assert.That(other.Price, Is.EqualTo(120m));
+        }
+
+        [Test]
+        public void Import_PersistsPredefinedPriceAfterRoundTrip()
+        {
+            // Arrange
+            var storage = new ProductStore();
+            storage.AppendVariantsFromString("Foto 10x15cm=150");
+
+            // Act: import (price 0) then serialise as settings would on form close.
+            SimulateImportedVariant(storage, "P1", "Foto 10x15cm", price: 0m);
+            _ = storage.Variants.ToList();
+            var persisted = storage.GetVariantsAsString();
+
+            // Assert: the price written back to settings is still 150, not 0.
+            Assert.That(persisted, Does.Contain("Foto 10x15cm=150"));
+        }
+
+        [Test]
+        public void Variants_StillSyncsRealNonZeroProductPrice()
+        {
+            // Guard against over-correcting: a genuine non-zero product price
+            // must still update the variant, as it did before the fix.
+            var storage = new ProductStore();
+            storage.AppendVariantsFromString("Foto 10x15cm=150");
+
+            SimulateImportedVariant(storage, "P1", "Foto 10x15cm", price: 99m);
+            var imported = storage.Variants.Single(x => x.Name == "Foto 10x15cm");
+
+            Assert.That(imported.Price, Is.EqualTo(99m));
+        }
+
+        [Test]
+        public void Variants_AddsBrandNewVariantFromImport()
+        {
+            // A variant name that isn't predefined should still appear in the list.
+            var storage = new ProductStore();
+            storage.AppendVariantsFromString("Foto 10x15cm=150");
+
+            SimulateImportedVariant(storage, "P1", "Plakát A2", price: 0m);
+            var variants = storage.Variants.ToList();
+
+            Assert.That(variants.Select(x => x.Name), Does.Contain("Plakát A2"));
+        }
+    }
+}

+ 29 - 0
Test/qdr.app.studiou.orders2printpack.Test.csproj

@@ -0,0 +1,29 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net9.0-windows</TargetFramework>
+    <LangVersion>latest</LangVersion>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="coverlet.collector" Version="6.0.2" />
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
+    <PackageReference Include="Moq" Version="4.20.72" />
+    <PackageReference Include="NUnit" Version="4.2.2" />
+    <PackageReference Include="NUnit.Analyzers" Version="4.4.0" />
+    <PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
+    <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.14" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\App\qdr.app.studiou.orders2printpack.csproj" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Using Include="NUnit.Framework" />
+  </ItemGroup>
+
+</Project>

+ 0 - 70
qdr.app.studiou.orders2printpack.csproj.Backup.tmp

@@ -1,70 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-
-  <PropertyGroup>
-    <OutputType>WinExe</OutputType>
-    <TargetFramework>net8.0-windows</TargetFramework>
-    <Nullable>enable</Nullable>
-    <UseWindowsForms>true</UseWindowsForms>
-    <ImplicitUsings>enable</ImplicitUsings>
-    <ApplicationIcon>logo_icon.ico</ApplicationIcon>
-    <Company>Quadarax</Company>
-    <Product>Order2PrintPack</Product>
-    <Description>Proprietální řešení tvorby balíčků obrázků pro online objednávky pro tiskovou službu.</Description>
-    <Copyright>Quadarax (c) 2025</Copyright>
-    <PackageIcon>logo_icon.png</PackageIcon>
-    <Title>StudioU Orders2PrintPack</Title>
-    <SignAssembly>True</SignAssembly>
-    <AssemblyOriginatorKeyFile>bo_strong_key_pair.snk</AssemblyOriginatorKeyFile>
-    <Platforms>AnyCPU;x86;x64</Platforms>
-    <AssemblyVersion>1.0.2.0</AssemblyVersion>
-    <FileVersion>1.0.2.0</FileVersion>
-    <Version>1.0.2</Version>
-	<ForceDesignerDpiUnaware>true</ForceDesignerDpiUnaware>
-  </PropertyGroup>
-
-  <ItemGroup>
-    <Content Include="logo_icon.ico" />
-  </ItemGroup>
-
-  <ItemGroup>
-    <None Include="C:\Temp\pedro\logo_icon.png">
-      <Pack>True</Pack>
-      <PackagePath>\</PackagePath>
-    </None>
-  </ItemGroup>
-
-  <ItemGroup>
-    <PackageReference Include="qdr.fnd.core" Version="0.0.5-alpha" />
-  </ItemGroup>
-
-  <ItemGroup>
-    <Compile Update="Properties\AppSettings.Designer.cs">
-      <DesignTimeSharedInput>True</DesignTimeSharedInput>
-      <AutoGen>True</AutoGen>
-      <DependentUpon>AppSettings.settings</DependentUpon>
-    </Compile>
-    <Compile Update="Properties\Resources.Designer.cs">
-      <DesignTime>True</DesignTime>
-      <AutoGen>True</AutoGen>
-      <DependentUpon>Resources.resx</DependentUpon>
-    </Compile>
-  </ItemGroup>
-
-  <ItemGroup>
-    <EmbeddedResource Update="Properties\Resources.resx">
-      <Generator>ResXFileCodeGenerator</Generator>
-      <LastGenOutput>Resources.Designer.cs</LastGenOutput>
-    </EmbeddedResource>
-  </ItemGroup>
-
-  <ItemGroup>
-    <None Update="Properties\AppSettings.settings">
-      <Generator>SettingsSingleFileGenerator</Generator>
-      <LastGenOutput>AppSettings.Designer.cs</LastGenOutput>
-    </None>
-    <None Update="README.md">
-      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
-    </None>
-  </ItemGroup>
-
-</Project>

+ 15 - 1
qdr.app.studiou.orders2printpack.sln

@@ -3,10 +3,12 @@ Microsoft Visual Studio Solution File, Format Version 12.00
 # Visual Studio Version 17
 VisualStudioVersion = 17.11.35312.102
 MinimumVisualStudioVersion = 10.0.40219.1
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "qdr.app.studiou.orders2printpack", "qdr.app.studiou.orders2printpack.csproj", "{B85B4AB7-BD36-431F-A7C1-D83C9C073AE2}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "qdr.app.studiou.orders2printpack", "App\qdr.app.studiou.orders2printpack.csproj", "{B85B4AB7-BD36-431F-A7C1-D83C9C073AE2}"
 EndProject
 Project("{54435603-DBB4-11D2-8724-00A0C9A8B90C}") = "Setup", "Setup\Setup.vdproj", "{6525CEB5-7957-4B38-A78B-9A1955DEDDDF}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "qdr.app.studiou.orders2printpack.Test", "Test\qdr.app.studiou.orders2printpack.Test.csproj", "{C52407EF-452E-4F1C-9A3F-C80A78849314}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -38,6 +40,18 @@ Global
 		{6525CEB5-7957-4B38-A78B-9A1955DEDDDF}.Release|x64.Build.0 = Release
 		{6525CEB5-7957-4B38-A78B-9A1955DEDDDF}.Release|x86.ActiveCfg = Release
 		{6525CEB5-7957-4B38-A78B-9A1955DEDDDF}.Release|x86.Build.0 = Release
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Debug|x64.Build.0 = Debug|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Debug|x86.Build.0 = Debug|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Release|Any CPU.Build.0 = Release|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Release|x64.ActiveCfg = Release|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Release|x64.Build.0 = Release|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Release|x86.ActiveCfg = Release|Any CPU
+		{C52407EF-452E-4F1C-9A3F-C80A78849314}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE