diff --git a/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs b/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs
index 5ad0436..1bf0bb7 100644
--- a/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs
+++ b/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs
@@ -77,6 +77,18 @@ public class SiteStorageService
recipient_emails TEXT NOT NULL,
updated_at TEXT NOT NULL
);
+
+ CREATE TABLE IF NOT EXISTS smtp_configurations (
+ name TEXT PRIMARY KEY,
+ server TEXT NOT NULL,
+ port INTEGER NOT NULL,
+ auth_mode TEXT NOT NULL,
+ from_address TEXT NOT NULL,
+ username TEXT,
+ password TEXT,
+ oauth_config TEXT,
+ updated_at TEXT NOT NULL
+ );
";
await command.ExecuteNonQueryAsync();
@@ -416,6 +428,45 @@ public class SiteStorageService
await command.ExecuteNonQueryAsync();
}
+
+ // ── WP-33: SMTP Configuration CRUD ──
+
+ ///
+ /// Stores or updates an SMTP configuration.
+ ///
+ public async Task StoreSmtpConfigurationAsync(
+ string name, string server, int port, string authMode, string fromAddress,
+ string? username, string? password, string? oauthConfig)
+ {
+ await using var connection = new SqliteConnection(_connectionString);
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = @"
+ INSERT INTO smtp_configurations (name, server, port, auth_mode, from_address, username, password, oauth_config, updated_at)
+ VALUES (@name, @server, @port, @authMode, @fromAddress, @username, @password, @oauthConfig, @updatedAt)
+ ON CONFLICT(name) DO UPDATE SET
+ server = excluded.server,
+ port = excluded.port,
+ auth_mode = excluded.auth_mode,
+ from_address = excluded.from_address,
+ username = excluded.username,
+ password = excluded.password,
+ oauth_config = excluded.oauth_config,
+ updated_at = excluded.updated_at";
+
+ command.Parameters.AddWithValue("@name", name);
+ command.Parameters.AddWithValue("@server", server);
+ command.Parameters.AddWithValue("@port", port);
+ command.Parameters.AddWithValue("@authMode", authMode);
+ command.Parameters.AddWithValue("@fromAddress", fromAddress);
+ command.Parameters.AddWithValue("@username", (object?)username ?? DBNull.Value);
+ command.Parameters.AddWithValue("@password", (object?)password ?? DBNull.Value);
+ command.Parameters.AddWithValue("@oauthConfig", (object?)oauthConfig ?? DBNull.Value);
+ command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
+
+ await command.ExecuteNonQueryAsync();
+ }
}
///
diff --git a/src/ScadaLink.SiteRuntime/Repositories/SiteNotificationRepository.cs b/src/ScadaLink.SiteRuntime/Repositories/SiteNotificationRepository.cs
new file mode 100644
index 0000000..0023cf3
--- /dev/null
+++ b/src/ScadaLink.SiteRuntime/Repositories/SiteNotificationRepository.cs
@@ -0,0 +1,255 @@
+using System.Text.Json;
+using Microsoft.Data.Sqlite;
+using ScadaLink.Commons.Entities.Notifications;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.SiteRuntime.Persistence;
+
+namespace ScadaLink.SiteRuntime.Repositories;
+
+///
+/// Site-side read-only implementation of
+/// backed by the local SQLite database via .
+/// Write operations throw because site-local
+/// artifacts are managed exclusively through deployment from Central.
+///
+public class SiteNotificationRepository : INotificationRepository
+{
+ private readonly SiteStorageService _storage;
+
+ public SiteNotificationRepository(SiteStorageService storage)
+ {
+ _storage = storage ?? throw new ArgumentNullException(nameof(storage));
+ }
+
+ // ── NotificationList (read) ──
+
+ public async Task GetListByNameAsync(
+ string name, CancellationToken cancellationToken = default)
+ {
+ await using var connection = CreateConnection();
+ await connection.OpenAsync(cancellationToken);
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = @"
+ SELECT name, recipient_emails
+ FROM notification_lists
+ WHERE name = @name";
+ command.Parameters.AddWithValue("@name", name);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
+ return null;
+
+ return MapNotificationList(reader);
+ }
+
+ public async Task> GetAllNotificationListsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ await using var connection = CreateConnection();
+ await connection.OpenAsync(cancellationToken);
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = "SELECT name, recipient_emails FROM notification_lists";
+
+ var results = new List();
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ results.Add(MapNotificationList(reader));
+ }
+
+ return results;
+ }
+
+ public async Task GetNotificationListByIdAsync(
+ int id, CancellationToken cancellationToken = default)
+ {
+ // The SQLite table is keyed by name, not by integer ID.
+ // Scan all rows and match on the synthetic ID derived from the name.
+ var all = await GetAllNotificationListsAsync(cancellationToken);
+ return all.FirstOrDefault(l => l.Id == id);
+ }
+
+ // ── NotificationRecipient (read) ──
+
+ public async Task> GetRecipientsByListIdAsync(
+ int notificationListId, CancellationToken cancellationToken = default)
+ {
+ // Find the parent list to get its name, then parse the recipient_emails JSON.
+ var list = await GetNotificationListByIdAsync(notificationListId, cancellationToken);
+ if (list is null)
+ return Array.Empty();
+
+ await using var connection = CreateConnection();
+ await connection.OpenAsync(cancellationToken);
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = @"
+ SELECT recipient_emails
+ FROM notification_lists
+ WHERE name = @name";
+ command.Parameters.AddWithValue("@name", list.Name);
+
+ var json = (string?)await command.ExecuteScalarAsync(cancellationToken);
+ if (string.IsNullOrWhiteSpace(json))
+ return Array.Empty();
+
+ return ParseRecipientEmails(json, notificationListId);
+ }
+
+ public async Task GetRecipientByIdAsync(
+ int id, CancellationToken cancellationToken = default)
+ {
+ // Scan all lists and their recipients to find the matching synthetic ID.
+ var lists = await GetAllNotificationListsAsync(cancellationToken);
+ foreach (var list in lists)
+ {
+ var recipients = await GetRecipientsByListIdAsync(list.Id, cancellationToken);
+ var match = recipients.FirstOrDefault(r => r.Id == id);
+ if (match is not null)
+ return match;
+ }
+
+ return null;
+ }
+
+ // ── SmtpConfiguration (read) ──
+
+ public async Task> GetAllSmtpConfigurationsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ await using var connection = CreateConnection();
+ await connection.OpenAsync(cancellationToken);
+
+ await using var command = connection.CreateCommand();
+ command.CommandText = @"
+ SELECT name, server, port, auth_mode, from_address, username, password, oauth_config
+ FROM smtp_configurations";
+
+ var results = new List();
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ results.Add(MapSmtpConfiguration(reader));
+ }
+
+ return results;
+ }
+
+ public async Task GetSmtpConfigurationByIdAsync(
+ int id, CancellationToken cancellationToken = default)
+ {
+ var all = await GetAllSmtpConfigurationsAsync(cancellationToken);
+ return all.FirstOrDefault(s => s.Id == id);
+ }
+
+ // ── Write operations (not supported on site) ──
+
+ public Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ public Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ public Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ public Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ public Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ public Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException("Managed via artifact deployment from Central");
+
+ // ── Private helpers ──
+
+ private SqliteConnection CreateConnection()
+ {
+ var field = typeof(SiteStorageService).GetField("_connectionString",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var connectionString = (string)field!.GetValue(_storage)!;
+ return new SqliteConnection(connectionString);
+ }
+
+ private static NotificationList MapNotificationList(SqliteDataReader reader)
+ {
+ var name = reader.GetString(0);
+ var list = new NotificationList(name)
+ {
+ Id = GenerateSyntheticId(name)
+ };
+
+ // Eagerly populate Recipients from the JSON column.
+ var emailsJson = reader.GetString(1);
+ var recipients = ParseRecipientEmails(emailsJson, list.Id);
+ foreach (var r in recipients)
+ {
+ list.Recipients.Add(r);
+ }
+
+ return list;
+ }
+
+ private static IReadOnlyList ParseRecipientEmails(
+ string json, int notificationListId)
+ {
+ try
+ {
+ var emails = JsonSerializer.Deserialize>(json,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+
+ if (emails is null)
+ return Array.Empty();
+
+ return emails.Select(email => new NotificationRecipient(
+ name: email,
+ emailAddress: email)
+ {
+ Id = GenerateSyntheticId($"{notificationListId}:{email}"),
+ NotificationListId = notificationListId
+ }).ToList();
+ }
+ catch (JsonException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ private static SmtpConfiguration MapSmtpConfiguration(SqliteDataReader reader)
+ {
+ var name = reader.GetString(0);
+ return new SmtpConfiguration(
+ host: reader.GetString(1),
+ authType: reader.GetString(3),
+ fromAddress: reader.GetString(4))
+ {
+ Id = GenerateSyntheticId(name),
+ Port = reader.GetInt32(2),
+ Credentials = reader.IsDBNull(5) ? null : reader.GetString(5),
+ TlsMode = reader.IsDBNull(7) ? null : reader.GetString(7)
+ };
+ }
+
+ ///
+ /// Generates a stable positive integer ID from a string name.
+ /// Uses a hash to produce a deterministic synthetic ID since the SQLite
+ /// tables are keyed by name rather than auto-increment integer.
+ ///
+ private static int GenerateSyntheticId(string name)
+ => name.GetHashCode() & 0x7FFFFFFF;
+}