feat(management): Transport on UpdateSmtpConfigCommand with EWS shape validation
This commit is contained in:
@@ -8,7 +8,9 @@ public record CreateNotificationListCommand(string Name, IReadOnlyList<string> R
|
||||
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
|
||||
public record DeleteNotificationListCommand(int NotificationListId);
|
||||
public record ListSmtpConfigsCommand;
|
||||
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null);
|
||||
// Transport is a trailing additive parameter (Smtp or Ews; null = preserve the
|
||||
// stored value, which is itself null for every pre-EWS row and means Smtp).
|
||||
public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null, string? Transport = null);
|
||||
public record ListSmsConfigsCommand;
|
||||
// FromNumber is optional: a Twilio Messaging-Service-only config supplies MessagingServiceSid
|
||||
// instead. At-least-one-of (FromNumber, MessagingServiceSid) is validated at the CLI/UI boundary.
|
||||
|
||||
@@ -2120,6 +2120,7 @@ public class ManagementActor : ReceiveActor
|
||||
c.AuthType,
|
||||
c.FromAddress,
|
||||
c.TlsMode,
|
||||
c.Transport,
|
||||
c.ConnectionTimeoutSeconds,
|
||||
c.MaxConcurrentConnections,
|
||||
c.MaxRetries,
|
||||
@@ -2156,6 +2157,10 @@ public class ManagementActor : ReceiveActor
|
||||
// omitted authority/scope leaves the stored value (or its M365 default) intact.
|
||||
if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority;
|
||||
if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
|
||||
// Preserve-if-null for the transport too: an omitted value keeps the stored
|
||||
// one (null on every pre-EWS row, which means SMTP).
|
||||
if (cmd.Transport is not null) config.Transport = cmd.Transport;
|
||||
ValidateSmtpConfigTransportShape(config);
|
||||
await repo.UpdateSmtpConfigurationAsync(config);
|
||||
await repo.SaveChangesAsync();
|
||||
// Audit the credential-free shape — the *fact of* the change
|
||||
@@ -2166,6 +2171,60 @@ public class ManagementActor : ReceiveActor
|
||||
return publicShape;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write-path gate for the email transport: validates the EFFECTIVE
|
||||
/// post-assignment state of an SmtpConfiguration so an operator cannot persist a
|
||||
/// row the delivery adapter would immediately park every notification on. The
|
||||
/// adapter re-validates at delivery time and remains the authoritative check —
|
||||
/// this is a fail-fast at the point of the mistake, not a second source of truth.
|
||||
/// </summary>
|
||||
/// <param name="config">The configuration with all command assignments already applied.</param>
|
||||
/// <exception cref="ManagementCommandException">The transport is unknown, or the EWS shape rules are violated.</exception>
|
||||
private static void ValidateSmtpConfigTransportShape(Commons.Entities.Notifications.SmtpConfiguration config)
|
||||
{
|
||||
// Mirrors NotificationService's EmailTransportParser, inlined because
|
||||
// ManagementService does not (and should not) reference NotificationService
|
||||
// just for a two-value parse. Keep the accepted values in step with it.
|
||||
var isEws = false;
|
||||
var transport = config.Transport;
|
||||
if (!string.IsNullOrWhiteSpace(transport))
|
||||
{
|
||||
switch (transport.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "smtp":
|
||||
break;
|
||||
case "ews":
|
||||
isEws = true;
|
||||
break;
|
||||
default:
|
||||
throw new ManagementCommandException(
|
||||
$"Unknown email transport '{transport}'. Expected one of: Smtp, Ews.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEws)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// EWS reuses Host as the SOAP endpoint URL, not a mail-server hostname.
|
||||
if (!Uri.TryCreate(config.Host, UriKind.Absolute, out var endpoint)
|
||||
|| !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
"EWS transport requires Host to be an absolute https:// EWS endpoint URL "
|
||||
+ "(e.g. https://mail.example.com/ews/exchange.asmx).");
|
||||
}
|
||||
|
||||
// The EWS sender authenticates with HTTP Basic only — no OAuth2 flow exists
|
||||
// on that path, so an OAuth2 row would park on every send.
|
||||
if (!string.Equals(config.AuthType, "basic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
"EWS transport supports only Basic authentication; set AuthMode to 'basic'.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project an SmsConfiguration to a credential-free shape so the
|
||||
/// stored AuthToken (Twilio Auth Token secret) never leaves this boundary via
|
||||
|
||||
Reference in New Issue
Block a user