Unify email/SMS via ProcessWeb Mailer API, remove legacy

Replaces legacy email/SMS logic with a new IComService abstraction using the ProcessWeb Mailer API for all outbound communication. Removes FuchsFdsEmail, FuchsEmailService, IEmailService, SmtpAccountSettings, and FuchsEmailSettings. Updates controllers to use IComService. Refactors appsettings.json to use a new "Mailer" section. Adds ProcessWebComSettings and a stub for secret management. Removes OCORE.sms.SMS77 and direct SMTP/MailKit usage. Cleans up solution file references to OCORE projects.
This commit is contained in:
Stefan
2026-05-18 08:11:21 +02:00
parent cc2abc91d6
commit b17baca835
15 changed files with 285 additions and 555 deletions
-197
View File
@@ -1,197 +0,0 @@
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
using Newtonsoft.Json;
using OCORE.SQL;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Email service implementation. Replaces the static <c>FuchsFdsEmail</c> class.
/// Reads settings from <c>IOptions&lt;FuchsEmailSettings&gt;</c> (appsettings.json)
/// instead of <c>System.Configuration.ConfigurationManager</c>.
/// </summary>
public class FuchsEmailService : IEmailService
{
private readonly ILogger<FuchsEmailService> _logger;
private readonly Fuchs_intranet _intranet;
private readonly FuchsEmailSettings _emailSettings;
private OCORE.email.EmailServerSettings? _cachedSettings;
private const string ReplyToName = "Sebastian Fuchs - Bad und Heizung";
private const string ReplyToAddress = "info@sanitaerfuchs.de";
private const string SignatureIntro =
"<p>&nbsp;</p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
"Herzliche Gr\u00fc\u00dfe aus D\u00fcsseldorf-Bilk<br/>" +
"Ihr Team der Firma Sebastian Fuchs</p>";
public FuchsEmailService(
ILogger<FuchsEmailService> logger,
Fuchs_intranet intranet,
IOptions<FuchsEmailSettings> emailSettings)
{
_logger = logger;
_intranet = intranet;
_emailSettings = emailSettings.Value;
}
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments)
{
var errors = new List<string>();
string guid = "";
string config = "";
DateTime sent = default;
if (!IsValidEmail(email))
errors.Add("Die Email-Adresse ist nicht g\u00fcltig.");
if (string.IsNullOrEmpty(html))
errors.Add("Bitte geben Sie eine Nachricht ein.");
if (errors.Count == 0)
{
try
{
var settings = GetEmailSettings();
string body = html + BuildSignature();
// Development redirect: replace To with DevRedirectAddress, suppress CC/BCC
string devRedirect = _emailSettings.DevRedirectAddress;
bool isDevRedirect = !string.IsNullOrWhiteSpace(devRedirect);
if (isDevRedirect)
{
_logger.LogWarning("DEV redirect: email to '{Original}' redirected to '{Redirect}'", email, devRedirect);
email = devRedirect;
name = "Dev Redirect";
}
if (settings != null)
{
var msg = new OCORE.email.Email(
Mode: OCORE.email.EmailMode.DirectMode,
type: settings.type)
{
EmailSettings = settings,
Subject = subject
};
msg.AddTo(email, name);
msg.AddReplyTo(new MailboxAddress(ReplyToName, ReplyToAddress));
msg.SetBody(body);
if (attachments != null)
foreach (var kv in attachments)
msg.AttachFile(filecontent: kv.Value, kv.Key);
var result = await msg.SendAsync(
(dref, ex) => _logger.LogError(ex, "Email send error {Reference}", dref));
guid = msg.MessageId ?? "";
config = msg.EmailConfig_serialized;
sent = result.Timestamp;
errors.AddRange(result.ErrorMessages);
}
else
{
var main = _emailSettings.Main;
string host = !string.IsNullOrEmpty(main.Host) ? main.Host : _emailSettings.SmtpHost;
string user = !string.IsNullOrEmpty(main.Username) ? main.Username : _emailSettings.SmtpUser;
string pass = !string.IsNullOrEmpty(main.Password) ? main.Password : _emailSettings.SmtpPass;
string from = !string.IsNullOrEmpty(main.From) ? main.From
: (!string.IsNullOrEmpty(_emailSettings.SmtpFrom) ? _emailSettings.SmtpFrom : user);
string fromName = !string.IsNullOrEmpty(main.Alias) ? main.Alias : _emailSettings.SmtpFromName;
int port = main.Port > 0 ? main.Port : _emailSettings.SmtpPort;
var message = new MimeMessage();
message.From.Add(new MailboxAddress(fromName, from));
message.To.Add(new MailboxAddress(name, email));
message.ReplyTo.Add(new MailboxAddress(ReplyToName, ReplyToAddress));
message.Subject = subject;
var builder = new BodyBuilder { HtmlBody = body };
if (attachments != null)
foreach (var kv in attachments)
builder.Attachments.Add(kv.Key, kv.Value);
message.Body = builder.ToMessageBody();
using var client = new MailKit.Net.Smtp.SmtpClient();
await client.ConnectAsync(host, port, MailKit.Security.SecureSocketOptions.Auto);
if (!string.IsNullOrEmpty(user))
await client.AuthenticateAsync(user, pass);
await client.SendAsync(message);
await client.DisconnectAsync(true);
guid = message.MessageId!;
sent = DateTime.UtcNow;
}
}
catch (Exception ex)
{
errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
}
}
if (errors.Count > 0)
_logger.LogWarning("SendEmailAsync errors for {Reference}: {Errors}", reference, errors);
// SQL audit log
try
{
var pl = new List<SqlParameter>
{
SQL_VarChar("@Ref", reference),
SQL_VarChar("@guid", guid),
SQL_DateTime("@DateSent", sent == default ? DBNull.Value : (object)sent),
SQL_NVarChar("@config", config, dbNull_IfEmpty: true),
SQL_Bit("@success", errors.Count == 0),
SQL_NVarChar("@log", JsonConvert.SerializeObject(errors))
};
await setSQLValue_async(
"EXECUTE [dbo].[fds__logEmail] @Ref, @guid, @DateSent, @config, @success, @log;",
_intranet.Intranet__SQLConnectionString, pl,
Security: _intranet.GetDbSecurity());
}
catch (Exception logEx)
{
_logger.LogError(logEx, "Failed to log email audit for {Reference}", reference);
}
return errors.Count == 0;
}
private OCORE.email.EmailServerSettings? GetEmailSettings()
{
if (_cachedSettings != null) return _cachedSettings;
var main = _emailSettings.Main;
if (string.IsNullOrWhiteSpace(main.Host)) return null;
_cachedSettings = new OCORE.email.EmailServerSettings(main.Type, main.ToOcoreJson());
return _cachedSettings;
}
private static string BuildSignature()
{
try
{
string sigPath = Path.Combine(AppContext.BaseDirectory,
"email_signature", "sanitaerfuchs_email_signature.txt");
if (File.Exists(sigPath))
return SignatureIntro + File.ReadAllText(sigPath);
}
catch { /* signature is optional */ }
return "";
}
private static bool IsValidEmail(string email)
{
try
{
var a = new System.Net.Mail.MailAddress(email);
return string.Equals(a.Address, email, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
}
-34
View File
@@ -1,34 +0,0 @@
namespace Fuchs.Services;
/// <summary>
/// SMTP / email server settings bound from appsettings.json → "Fuchs:Email" section.
/// </summary>
public class FuchsEmailSettings
{
/// <summary>Main account for outgoing customer emails (anfrage@sanitaerfuchs.de).</summary>
public SmtpAccountSettings Main { get; set; } = new();
/// <summary>FDS/invoicing account (rechnungen@sanitaerfuchs.de).</summary>
public SmtpAccountSettings Fds { get; set; } = new();
/// <summary>Internal OCORE service account used for system notifications.</summary>
public SmtpAccountSettings Service { get; set; } = new();
/// <summary>Comma-separated list of addresses used as test recipients.</summary>
public string TestAddresses { get; set; } = "";
// ── MailKit fallback settings (used when Main is not configured) ──
public string SmtpHost { get; set; } = "";
public int SmtpPort { get; set; } = 587;
public string SmtpUser { get; set; } = "";
public string SmtpPass { get; set; } = "";
public string SmtpFrom { get; set; } = "";
public string SmtpFromName { get; set; } = "SanitärFuchs";
/// <summary>
/// Development only: when set, all outgoing emails are redirected to this address;
/// CC and BCC are cleared. Leave empty in production.
/// </summary>
public string DevRedirectAddress { get; set; } = "";
}
@@ -1,9 +1,10 @@
namespace Fuchs.Services;
/// <summary>
/// Abstraction for sending emails with optional attachments and audit logging.
/// Abstraction for outbound communication: email and SMS.
/// Backed by the ProcessWeb Mailer API (POST /api/mailer?fn=push_com).
/// </summary>
public interface IEmailService
public interface IComService
{
/// <summary>
/// Sends an email and logs the result to the database.
@@ -14,7 +15,15 @@ public interface IEmailService
/// <param name="email">Recipient email address.</param>
/// <param name="name">Recipient display name.</param>
/// <param name="attachments">Optional file attachments (filename → content).</param>
/// <returns><c>true</c> when the email was sent successfully.</returns>
/// <returns><c>true</c> when the email was accepted successfully.</returns>
Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments);
string email, string name, Dictionary<string, byte[]>? attachments = null);
/// <summary>
/// Sends an SMS message.
/// </summary>
/// <param name="mobile">Recipient mobile number (E.164 or local format).</param>
/// <param name="message">Text message body.</param>
/// <returns><c>true</c> when the SMS was accepted successfully.</returns>
Task<bool> SendSmsAsync(string mobile, string message);
}
+202
View File
@@ -0,0 +1,202 @@
using System.Net.Http.Headers;
using System.Text;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using OCORE.SQL;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Outbound communication service backed by the ProcessWeb Mailer API
/// (POST https://api.processweb.de/api/mailer?fn=push_com).
/// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
/// only logs the intended communication without calling the API.
/// </summary>
public class ProcessWebComService : IComService
{
private readonly ILogger<ProcessWebComService> _logger;
private readonly Fuchs_intranet _intranet;
private readonly ProcessWebComSettings _settings;
private readonly IHttpClientFactory _httpClientFactory;
private const string SignatureIntro =
"<p>&nbsp;</p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
"Herzliche Grüße aus Düsseldorf-Bilk<br/>" +
"Ihr Team der Firma Sebastian Fuchs</p>";
public ProcessWebComService(
ILogger<ProcessWebComService> logger,
Fuchs_intranet intranet,
IOptions<ProcessWebComSettings> settings,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_intranet = intranet;
_settings = settings.Value;
_httpClientFactory = httpClientFactory;
}
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null)
{
if (!IsValidEmail(email))
{
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
return false;
}
string body = html + BuildSignature();
if (!_settings.Enabled)
{
_logger.LogInformation(
"[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}",
reference, subject, email);
await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled email not sent"]);
return false;
}
bool success = false;
string messageId = "";
var errors = new List<string>();
try
{
var payload = new
{
comType = "email",
recipient = email,
subject,
body
};
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
if (ok)
{
success = true;
messageId = Guid.NewGuid().ToString("N");
}
else
{
errors.Add($"API error: {responseBody}");
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Body}", reference, responseBody);
}
}
catch (Exception ex)
{
errors.Add("Beim Versenden ist ein Fehler aufgetreten.");
_logger.LogError(ex, "SendEmailAsync failed for {Reference}", reference);
}
await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors);
return success;
}
public async Task<bool> SendSmsAsync(string mobile, string message)
{
if (string.IsNullOrWhiteSpace(mobile))
{
_logger.LogWarning("SendSmsAsync: empty mobile number");
return false;
}
if (!_settings.Enabled)
{
_logger.LogInformation(
"[ComService DISABLED] Would send SMS to={Mobile} message='{Message}'",
mobile, message);
return false;
}
try
{
var payload = new
{
comType = "sms",
recipient = mobile,
body = message
};
var (ok, responseBody) = await PostToApiAsync("push_com", payload);
if (ok) return true;
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Body}", mobile, responseBody);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "SendSmsAsync failed for {Mobile}", mobile);
return false;
}
}
// ── Private helpers ────────────────────────────────────────────────────────
private async Task<(bool ok, string body)> PostToApiAsync(string fn, object payload)
{
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
var json = JsonConvert.SerializeObject(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
string credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
var response = await client.PostAsync($"{_settings.BaseUrl}/api/mailer?fn={fn}", content);
string responseBody = await response.Content.ReadAsStringAsync();
return (response.IsSuccessStatusCode, responseBody);
}
private async Task WriteAuditLogAsync(string reference, string guid, string config,
DateTime sent, bool success, IEnumerable<string> errors)
{
try
{
var pl = new List<SqlParameter>
{
SQL_VarChar("@Ref", reference),
SQL_VarChar("@guid", guid),
SQL_DateTime("@DateSent", sent == default ? DBNull.Value : (object)sent),
SQL_NVarChar("@config", config, dbNull_IfEmpty: true),
SQL_Bit("@success", success),
SQL_NVarChar("@log", JsonConvert.SerializeObject(errors.ToList()))
};
await setSQLValue_async(
"EXECUTE [dbo].[fds__logEmail] @Ref, @guid, @DateSent, @config, @success, @log;",
_intranet.Intranet__SQLConnectionString, pl,
Security: _intranet.GetDbSecurity());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write audit log for {Reference}", reference);
}
}
private static string BuildSignature()
{
try
{
string sigPath = Path.Combine(AppContext.BaseDirectory,
"email_signature", "sanitaerfuchs_email_signature.txt");
if (File.Exists(sigPath))
return SignatureIntro + File.ReadAllText(sigPath);
}
catch { /* signature is optional */ }
return "";
}
private static bool IsValidEmail(string email)
{
try
{
var a = new System.Net.Mail.MailAddress(email);
return string.Equals(a.Address, email, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace Fuchs.Services;
/// <summary>
/// Settings for the ProcessWeb Mailer API, bound from appsettings.json → "Fuchs:Mailer".
/// </summary>
public class ProcessWebComSettings
{
/// <summary>Base URL of the ProcessWeb API (e.g. "https://api.processweb.de").</summary>
public string BaseUrl { get; set; } = "https://api.processweb.de";
/// <summary>Account ID used for HTTP Basic authentication.</summary>
public string AccountId { get; set; } = "";
/// <summary>API token used for HTTP Basic authentication.</summary>
public string Token { get; set; } = "";
/// <summary>
/// When <c>false</c> (default) the service is disabled and only logs the
/// intended communication without actually calling the API.
/// Set to <c>true</c> to enable live sending.
/// </summary>
public bool Enabled { get; set; } = false;
}
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Builder;
namespace OCORE_web.Secrets;
/// <summary>
/// Placeholder for secret management extension methods.
/// Replace with a real Azure Key Vault / DPAPI implementation when ready.
/// </summary>
public static class SecretManagementExtensions
{
/// <summary>
/// No-op stub. Wire up real secret management (e.g. Azure Key Vault) here.
/// </summary>
public static WebApplicationBuilder AddSecretManagement(this WebApplicationBuilder builder)
=> builder;
}
-46
View File
@@ -1,46 +0,0 @@
using Newtonsoft.Json;
namespace Fuchs.Services;
/// <summary>
/// SMTP account configuration for a single email account.
/// Property names match the OCORE EmailServerSettings JSON format (lowercase).
/// </summary>
public class SmtpAccountSettings
{
[JsonProperty("alias")]
public string Alias { get; set; } = "";
[JsonProperty("to")]
public string To { get; set; } = "";
[JsonProperty("from")]
public string From { get; set; } = "";
[JsonProperty("bcc")]
public string Bcc { get; set; } = "";
[JsonProperty("host")]
public string Host { get; set; } = "";
[JsonProperty("port")]
public int Port { get; set; } = 587;
[JsonProperty("security")]
public string Security { get; set; } = "StartTls";
[JsonProperty("username")]
public string Username { get; set; } = "";
[JsonProperty("password")]
public string Password { get; set; } = "";
[JsonProperty("type")]
public string Type { get; set; } = "smtp";
/// <summary>
/// Serializes this instance back to the JSON string format expected by
/// <c>OCORE.email.EmailServerSettings(type, raw)</c>.
/// </summary>
public string ToOcoreJson() => JsonConvert.SerializeObject(this);
}