diff --git a/Dockerfile b/Dockerfile index e8877b5..f230fda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,16 +43,65 @@ COPY --from=loclx-download /usr/local/bin/loclx /usr/local/bin/loclx COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh -# Relay configuration -ENV RELAY_Mode=smtp -ENV RELAY_Smtp__Port=2525 - -# LocalXpose tunnel configuration (all optional — tunnel is disabled by default). -# Set LOCLX_ENABLED=true to activate the tunnel. -# Provide LOCLX_TOKEN with your LocalXpose access token. -# Optionally set LOCLX_RESERVED_ENDPOINT= to use a reserved endpoint. -# Alternatively, mount a tunnels YAML file at /etc/resgrid/loclx-tunnels.yaml. +# ─── Relay configuration ─────────────────────────────────────────────── +# Every setting below can be overridden at runtime via -e or docker-compose. +# Required values are marked [REQUIRED] — the relay will not start without them. + +# Operational mode +ENV RESGRID__RELAY__Mode=smtp + +# Resgrid API (v4) +ENV RESGRID__RELAY__Resgrid__BaseUrl= # [REQUIRED] e.g. https://api.resgrid.com +ENV RESGRID__RELAY__Resgrid__ApiVersion=4 +ENV RESGRID__RELAY__Resgrid__ClientId= # [REQUIRED] +ENV RESGRID__RELAY__Resgrid__ClientSecret= # [REQUIRED] +ENV RESGRID__RELAY__Resgrid__RefreshToken= # required when GrantType=RefreshToken +ENV RESGRID__RELAY__Resgrid__Scope=openid profile email offline_access mobile +ENV RESGRID__RELAY__Resgrid__TokenCachePath=./data/resgrid-token.json +ENV RESGRID__RELAY__Resgrid__GrantType=RefreshToken # RefreshToken | ClientCredentials | SystemApiKey +ENV RESGRID__RELAY__Resgrid__SystemApiKey= # required when GrantType=SystemApiKey +ENV RESGRID__RELAY__Resgrid__DepartmentId= # optional, used as fallback in hosted mode + +# Telemetry (optional — telemetry is disabled when these are left empty) +ENV RESGRID__RELAY__Telemetry__Environment= +ENV RESGRID__RELAY__Telemetry__Sentry__Dsn= +ENV RESGRID__RELAY__Telemetry__Sentry__Release= +ENV RESGRID__RELAY__Telemetry__Sentry__SendDefaultPii=true +ENV RESGRID__RELAY__Telemetry__Countly__Url= +ENV RESGRID__RELAY__Telemetry__Countly__AppKey= +ENV RESGRID__RELAY__Telemetry__Countly__DeviceId= +ENV RESGRID__RELAY__Telemetry__Countly__RequestTimeoutSeconds=5 + +# SMTP server +ENV RESGRID__RELAY__Smtp__ServerName=resgrid-relay +ENV RESGRID__RELAY__Smtp__Port=2525 +ENV RESGRID__RELAY__Smtp__DataDirectory=./data +ENV RESGRID__RELAY__Smtp__DuplicateWindowHours=72 +ENV RESGRID__RELAY__Smtp__DefaultCallPriority=1 +ENV RESGRID__RELAY__Smtp__MaxAttachmentBytes=10485760 +ENV RESGRID__RELAY__Smtp__MaxMessageBytes=26214400 +ENV RESGRID__RELAY__Smtp__SaveRawMessages=true +ENV RESGRID__RELAY__Smtp__DepartmentDispatchPrefix=G + +# Dispatch domain configuration — at least one domain list is required. +# Use the indexed form for arrays in env vars: +# RESGRID__RELAY__Smtp__DepartmentAddressDomains__0=dispatch.resgrid.com +# RESGRID__RELAY__Smtp__DepartmentAddressDomains__1=pager.example.com +# RESGRID__RELAY__Smtp__GroupAddressDomains__0=groups.resgrid.com +# RESGRID__RELAY__Smtp__GroupMessageAddressDomains__0=gm.resgrid.com +# RESGRID__RELAY__Smtp__ListAddressDomains__0=lists.resgrid.com + +# Hosted (multi-department) mode +ENV RESGRID__RELAY__Smtp__HostedMode=false # true when running for Resgrid Hosted +ENV RESGRID__RELAY__Smtp__DepartmentDomainSeparator=. +ENV RESGRID__RELAY__Smtp__DefaultDepartmentId= # optional department override +ENV RESGRID__RELAY__Smtp__ResolveDispatchCodes=true # resolve code names to numeric IDs via lookup API + +# ─── LocalXpose tunnel ───────────────────────────────────────────────── +# All optional — tunnel is disabled by default. ENV LOCLX_ENABLED=false +# ENV LOCLX_TOKEN= # LocalXpose access token +# ENV LOCLX_RESERVED_ENDPOINT= # e.g. smtp.loclx.io:25 EXPOSE 2525 diff --git a/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs b/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs index 6292658..7b1bf41 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs @@ -16,12 +16,16 @@ public static async Task SaveCallAsync(NewCallInput call, CancellationTo return result.Id; } - public static async Task GetCallAsync(string callId, CancellationToken cancellationToken = default) + public static async Task GetCallAsync(string callId, string departmentId = null, CancellationToken cancellationToken = default) { if (String.IsNullOrWhiteSpace(callId)) throw new ArgumentException("A call id is required.", nameof(callId)); - return await ResgridV4ApiClient.GetAsync($"Calls/GetCall?callId={Uri.EscapeDataString(callId)}", cancellationToken).ConfigureAwait(false); + var url = $"Calls/GetCall?callId={Uri.EscapeDataString(callId)}"; + if (!String.IsNullOrWhiteSpace(departmentId)) + url += $"&departmentId={Uri.EscapeDataString(departmentId)}"; + + return await ResgridV4ApiClient.GetAsync(url, cancellationToken).ConfigureAwait(false); } public static async Task SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken = default) diff --git a/Providers/Resgrid.Providers.ApiClient/V4/DispatchListBuilder.cs b/Providers/Resgrid.Providers.ApiClient/V4/DispatchListBuilder.cs index fa5f540..5415733 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/DispatchListBuilder.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/DispatchListBuilder.cs @@ -6,26 +6,99 @@ namespace Resgrid.Providers.ApiClient.V4 { public enum DispatchCodeType { + /// + /// Department-wide dispatch email (Type 1 in the Postmark pipeline). + /// The local-part identifies the department (InternalDispatchEmail setting). + /// This does not map to a single group — the call should dispatch to + /// all department users. When processed by DispatchListBuilder this + /// type produces no DispatchList token. + /// Department = 1, - Group = 2 + + /// + /// Group dispatch email (Type 3 in the Postmark pipeline). + /// The local-part is the dispatch email code stored in + /// DepartmentGroups.DispatchEmail. It resolves to a single numeric + /// group ID via the lookup API. + /// + Group = 2, + + /// + /// Group message email (Type 4 in the Postmark pipeline). + /// The local-part is the message email code stored in + /// DepartmentGroups.MessageEmail. Results in a group message, + /// not a call. + /// + GroupMessage = 3, + + /// + /// Distribution list email (Type 2 in the Postmark pipeline). + /// The local-part is the list address stored in + /// DistributionLists.EmailAddress. Results in list forwarding. + /// + DistributionList = 4 } public sealed class DispatchCode { + /// + /// The dispatch code (name) extracted from the email local-part. + /// Example: "station5" from station5@dispatch.resgrid.com + /// public string Code { get; set; } + + /// + /// The type of dispatch this code represents. + /// public DispatchCodeType Type { get; set; } + + /// + /// (Optional) The resolved numeric entity ID from the lookup API. + /// When set, DispatchListBuilder uses this value in the DispatchList + /// string (e.g. G:42) instead of the raw name (e.g. G:STATION5). + /// When null, DispatchListBuilder falls back to . + /// + public string ResolvedId { get; set; } + + /// + /// True when this dispatch code has been resolved to a numeric ID + /// and is ready to be included in the DispatchList sent to SaveCall. + /// + public bool IsResolved => !String.IsNullOrWhiteSpace(ResolvedId); + + /// + /// Returns the value that should appear in a DispatchList token. + /// Prefers the resolved ID, falls back to the code name. + /// + public string DispatchToken => IsResolved ? ResolvedId : Code; } public static class DispatchListBuilder { + /// + /// Builds a pipe-delimited DispatchList string suitable for the + /// Resgrid v4 SaveCall API. + /// + /// Prefix conventions: + /// G:{id} — Group dispatch (numeric group ID) + /// U:{id} — Unit dispatch (numeric unit ID) + /// R:{id} — Role dispatch (numeric role ID) + /// + /// Department-type codes are excluded — they dispatch to the + /// entire department and should not appear in the DispatchList. + /// + /// When a dispatch code has a , + /// that numeric ID is used. Otherwise the original code string is + /// used as a best-effort fallback. + /// public static string Build(IEnumerable dispatchCodes, string departmentDispatchPrefix = "G") { if (dispatchCodes == null) return null; var tokens = dispatchCodes - .Where(x => x != null && !String.IsNullOrWhiteSpace(x.Code)) - .Select(x => Format(x.Code, x.Type, departmentDispatchPrefix)) + .Where(x => x != null && !String.IsNullOrWhiteSpace(x.Code) && x.Type != DispatchCodeType.Department && x.Type != DispatchCodeType.DistributionList) + .Select(x => Format(x, departmentDispatchPrefix)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -35,17 +108,25 @@ public static string Build(IEnumerable dispatchCodes, string depar return String.Join("|", tokens); } - public static string Format(string code, DispatchCodeType type, string departmentDispatchPrefix = "G") + /// + /// Formats a dispatch code into a single DispatchList token. + /// + public static string Format(DispatchCode dispatchCode, string departmentDispatchPrefix = "G") { - if (String.IsNullOrWhiteSpace(code)) - throw new ArgumentException("A dispatch code is required.", nameof(code)); + if (dispatchCode == null) + throw new ArgumentNullException(nameof(dispatchCode)); + if (String.IsNullOrWhiteSpace(dispatchCode.Code) && String.IsNullOrWhiteSpace(dispatchCode.ResolvedId)) + throw new ArgumentException("A dispatch code or resolved ID is required.", nameof(dispatchCode)); - var normalizedCode = code.Trim(); - var prefix = type == DispatchCodeType.Department - ? NormalizePrefix(departmentDispatchPrefix) - : "G"; + var token = dispatchCode.DispatchToken; + var prefix = dispatchCode.Type switch + { + DispatchCodeType.Group => "G", + DispatchCodeType.GroupMessage => "G", + _ => NormalizePrefix(departmentDispatchPrefix) + }; - return $"{prefix}:{normalizedCode}"; + return $"{prefix}:{token}"; } private static string NormalizePrefix(string prefix) diff --git a/Providers/Resgrid.Providers.ApiClient/V4/LookupsApi.cs b/Providers/Resgrid.Providers.ApiClient/V4/LookupsApi.cs new file mode 100644 index 0000000..6233300 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/LookupsApi.cs @@ -0,0 +1,127 @@ +using Resgrid.Providers.ApiClient.V4.Models; +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Providers.ApiClient.V4 +{ + /// + /// Provides v4 API methods to resolve dispatch codes (names like "STATION5") + /// into numeric IDs required by the DispatchList parameter in SaveCall. + /// + /// These endpoints correspond to the new lookup APIs that need to be built + /// in the Resgrid API project. Until they exist, callers should handle the + /// expected 404 gracefully and fall back to code-based DispatchList values. + /// + public static class LookupsApi + { + /// + /// Resolves a group dispatch email code to its numeric group ID. + /// Maps to: GET /api/v4/Groups/GetGroupByDispatchCode?code={code}&departmentId={departmentId} + /// + /// Behind the scenes this queries DepartmentGroups.DispatchEmail. + /// Returns null when the endpoint returns 404 (group not found or API + /// endpoint not yet deployed). + /// + public static async Task LookupGroupByDispatchCodeAsync( + string code, + string departmentId, + CancellationToken cancellationToken = default) + { + if (String.IsNullOrWhiteSpace(code)) + return null; + + var url = $"Groups/GetGroupByDispatchCode?code={Uri.EscapeDataString(code)}"; + + if (!String.IsNullOrWhiteSpace(departmentId)) + url += $"&departmentId={Uri.EscapeDataString(departmentId)}"; + + return await TryGetAsync(url, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves a group message email code to its numeric group ID. + /// Maps to: GET /api/v4/Groups/GetGroupByMessageCode?code={code}&departmentId={departmentId} + /// + /// Behind the scenes this queries DepartmentGroups.MessageEmail. + /// Returns null when the endpoint returns 404. + /// + public static async Task LookupGroupByMessageCodeAsync( + string code, + string departmentId, + CancellationToken cancellationToken = default) + { + if (String.IsNullOrWhiteSpace(code)) + return null; + + var url = $"Groups/GetGroupByMessageCode?code={Uri.EscapeDataString(code)}"; + + if (!String.IsNullOrWhiteSpace(departmentId)) + url += $"&departmentId={Uri.EscapeDataString(departmentId)}"; + + return await TryGetAsync(url, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves a unit name to its numeric unit ID. + /// Maps to: GET /api/v4/Units/GetUnitByName?name={name}&departmentId={departmentId} + /// + /// Behind the scenes this queries Units.UnitName. + /// Returns null when the endpoint returns 404. + /// + public static async Task LookupUnitByNameAsync( + string name, + string departmentId, + CancellationToken cancellationToken = default) + { + if (String.IsNullOrWhiteSpace(name)) + return null; + + var url = $"Units/GetUnitByName?name={Uri.EscapeDataString(name)}"; + + if (!String.IsNullOrWhiteSpace(departmentId)) + url += $"&departmentId={Uri.EscapeDataString(departmentId)}"; + + return await TryGetAsync(url, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resolves a role name to its numeric role ID. + /// Maps to: GET /api/v4/Roles/GetRoleByName?name={name}&departmentId={departmentId} + /// + /// Behind the scenes this queries PersonnelRoles.Name. + /// Returns null when the endpoint returns 404. + /// + public static async Task LookupRoleByNameAsync( + string name, + string departmentId, + CancellationToken cancellationToken = default) + { + if (String.IsNullOrWhiteSpace(name)) + return null; + + var url = $"Roles/GetRoleByName?name={Uri.EscapeDataString(name)}"; + + if (!String.IsNullOrWhiteSpace(departmentId)) + url += $"&departmentId={Uri.EscapeDataString(departmentId)}"; + + return await TryGetAsync(url, cancellationToken).ConfigureAwait(false); + } + + private static async Task TryGetAsync(string url, CancellationToken cancellationToken) where T : class + { + try + { + var response = await ResgridV4ApiClient.GetAsync>(url, cancellationToken).ConfigureAwait(false); + return response?.Data; + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Entity not found, or the API endpoint doesn't exist yet. + return null; + } + } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/Models/CallModels.cs b/Providers/Resgrid.Providers.ApiClient/V4/Models/CallModels.cs index b3c7f5b..4e3c409 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/Models/CallModels.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/Models/CallModels.cs @@ -30,6 +30,13 @@ public sealed class NewCallInput public DateTimeOffset? DispatchOn { get; set; } public string CallFormData { get; set; } public bool? CheckInTimersEnabled { get; set; } + + /// + /// When set, scopes the call to a specific department. + /// Required in hosted (multi-department) mode where the system-level + /// API key can create calls for any department. + /// + public string DepartmentId { get; set; } } public sealed class SaveCallFileInput @@ -42,6 +49,13 @@ public sealed class SaveCallFileInput public string Latitude { get; set; } public string Longitude { get; set; } public string Note { get; set; } + + /// + /// When set, scopes the file upload to a specific department. + /// Required in hosted (multi-department) mode where the system-level + /// API key can upload files for any department. + /// + public string DepartmentId { get; set; } } public sealed class SaveOperationResult diff --git a/Providers/Resgrid.Providers.ApiClient/V4/Models/LookupModels.cs b/Providers/Resgrid.Providers.ApiClient/V4/Models/LookupModels.cs new file mode 100644 index 0000000..a7b5c01 --- /dev/null +++ b/Providers/Resgrid.Providers.ApiClient/V4/Models/LookupModels.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Serialization; + +namespace Resgrid.Providers.ApiClient.V4.Models +{ + /// + /// Generic API response wrapper from the Resgrid v4 API. + /// + public sealed class LookupResponse + { + [JsonPropertyName("Data")] + public T Data { get; set; } + + [JsonPropertyName("Status")] + public string Status { get; set; } + } + + /// + /// Result of looking up a group by its dispatch email code + /// (GET /api/v4/Groups/GetGroupByDispatchCode). + /// + public sealed class GroupLookupResult + { + /// + /// The numeric group ID used in DispatchList (G:{GroupId}). + /// + [JsonPropertyName("GroupId")] + public string GroupId { get; set; } + + [JsonPropertyName("Name")] + public string Name { get; set; } + + [JsonPropertyName("DepartmentId")] + public string DepartmentId { get; set; } + + [JsonPropertyName("Type")] + public int Type { get; set; } + } + + /// + /// Result of looking up a unit by its name + /// (GET /api/v4/Units/GetUnitByName). + /// + public sealed class UnitLookupResult + { + /// + /// The numeric unit ID used in DispatchList (U:{UnitId}). + /// + [JsonPropertyName("UnitId")] + public string UnitId { get; set; } + + [JsonPropertyName("Name")] + public string Name { get; set; } + + [JsonPropertyName("Type")] + public string Type { get; set; } + + [JsonPropertyName("DepartmentId")] + public string DepartmentId { get; set; } + } + + /// + /// Result of looking up a role by its name + /// (GET /api/v4/Roles/GetRoleByName). + /// + public sealed class RoleLookupResult + { + /// + /// The numeric role ID used in DispatchList (R:{RoleId}). + /// + [JsonPropertyName("RoleId")] + public string RoleId { get; set; } + + [JsonPropertyName("Name")] + public string Name { get; set; } + + [JsonPropertyName("DepartmentId")] + public string DepartmentId { get; set; } + } +} diff --git a/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs b/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs index 346f2b8..1219d36 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/ResgridApiClientOptions.cs @@ -2,6 +2,34 @@ namespace Resgrid.Providers.ApiClient.V4 { + public enum ResgridAuthGrantType + { + /// + /// Standard OAuth 2.0 refresh_token grant. + /// Requires ClientId, ClientSecret, and RefreshToken. + /// Used when the application runs on behalf of a specific user/department. + /// + RefreshToken = 1, + + /// + /// OAuth 2.0 client_credentials grant. + /// Requires only ClientId and ClientSecret — no refresh token. + /// The application exchanges its own credentials for an access token. + /// Used for single-department SMTP/Docker deployments where a + /// refresh token is not provisioned. + /// + ClientCredentials = 2, + + /// + /// System-level API key authentication that bypasses the standard + /// OAuth 2.0 / OIDC token flow entirely. + /// Requires ClientId and ClientSecret, plus a SystemApiKey. + /// Used in Resgrid Hosted (multi-department) mode where the relay + /// must create calls and upload files for any department. + /// + SystemApiKey = 3 + } + public sealed class ResgridApiClientOptions { public string BaseUrl { get; set; } = "https://api.resgrid.com"; @@ -12,6 +40,29 @@ public sealed class ResgridApiClientOptions public string Scope { get; set; } = "openid profile email offline_access mobile"; public string TokenCachePath { get; set; } + /// + /// The authentication grant type to use when connecting to the Resgrid API. + /// Defaults to for backward compatibility. + /// + public ResgridAuthGrantType GrantType { get; set; } = ResgridAuthGrantType.RefreshToken; + + /// + /// A system-level API key used when is + /// . This key is + /// sent as an HTTP header on every request and bypasses the + /// standard OAuth 2.0 token flow entirely. + /// + public string SystemApiKey { get; set; } + + /// + /// When set, all API calls are scoped to this department. + /// In hosted (multi-department) mode this identifies which + /// department a call or file upload belongs to. When unset + /// the department is inferred from the access token or + /// dispatch address. + /// + public string DepartmentId { get; set; } + public void Validate() { if (String.IsNullOrWhiteSpace(BaseUrl)) @@ -26,8 +77,27 @@ public void Validate() if (String.IsNullOrWhiteSpace(ClientSecret)) throw new InvalidOperationException("A Resgrid API client secret is required."); - if (String.IsNullOrWhiteSpace(RefreshToken) && String.IsNullOrWhiteSpace(TokenCachePath)) - throw new InvalidOperationException("A refresh token or token cache path is required."); + switch (GrantType) + { + case ResgridAuthGrantType.RefreshToken: + if (String.IsNullOrWhiteSpace(RefreshToken) && String.IsNullOrWhiteSpace(TokenCachePath)) + throw new InvalidOperationException( + "A refresh token or token cache path is required when using the refresh_token grant type."); + break; + + case ResgridAuthGrantType.ClientCredentials: + // Only ClientId and ClientSecret are required — already validated above. + break; + + case ResgridAuthGrantType.SystemApiKey: + if (String.IsNullOrWhiteSpace(SystemApiKey)) + throw new InvalidOperationException( + "A system API key is required when using the SystemApiKey grant type."); + break; + + default: + throw new InvalidOperationException($"Unsupported Resgrid authentication grant type '{GrantType}'."); + } } } } diff --git a/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs b/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs index dd96f49..158893b 100644 --- a/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs +++ b/Providers/Resgrid.Providers.ApiClient/V4/ResgridV4ApiClient.cs @@ -17,6 +17,8 @@ namespace Resgrid.Providers.ApiClient.V4 { public static class ResgridV4ApiClient { + private const string SystemApiKeyHeaderName = "X-Resgrid-SystemApiKey"; + private static readonly SemaphoreSlim AuthLock = new SemaphoreSlim(1, 1); private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions() { @@ -30,7 +32,16 @@ public static class ResgridV4ApiClient private static ResgridApiClientOptions _options = new ResgridApiClientOptions(); private static ResgridApiTokenState _tokenState = new ResgridApiTokenState(); - public static string CurrentUserId => _tokenState?.UserId; + public static string CurrentUserId + { + get + { + if (_options.GrantType == ResgridAuthGrantType.SystemApiKey && !String.IsNullOrWhiteSpace(_options.DepartmentId)) + return _options.DepartmentId; + + return _tokenState?.UserId; + } + } public static void Init(ResgridApiClientOptions options) { @@ -82,7 +93,9 @@ private static async Task SendRawAsync(HttpMethod method, s var relativeUrl = BuildRelativeApiPath(url); var response = await SendAuthorizedRequestAsync(method, relativeUrl, data, _tokenState.AccessToken, cancellationToken).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.Unauthorized) + // 401 retry is only meaningful for token-based auth flows. + // SystemApiKey mode uses a static key — retrying would just fail again. + if (response.StatusCode == HttpStatusCode.Unauthorized && _options.GrantType != ResgridAuthGrantType.SystemApiKey) { response.Dispose(); _tokenState.AccessToken = null; @@ -98,7 +111,15 @@ private static async Task SendRawAsync(HttpMethod method, s private static async Task SendAuthorizedRequestAsync(HttpMethod method, string url, object data, string accessToken, CancellationToken cancellationToken) { var request = new HttpRequestMessage(method, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + if (_options.GrantType == ResgridAuthGrantType.SystemApiKey) + { + request.Headers.Add(SystemApiKeyHeaderName, _options.SystemApiKey); + } + else + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + } if (data != null) { @@ -129,6 +150,11 @@ private static async Task ReadResponseAsync(HttpResponseMessage response, private static async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken) { + // SystemApiKey mode does not use OAuth tokens — the key is + // attached directly to every request. + if (_options.GrantType == ResgridAuthGrantType.SystemApiKey) + return; + if (HasValidAccessToken()) return; @@ -139,18 +165,37 @@ private static async Task EnsureAuthenticatedAsync(CancellationToken cancellatio return; _openIdConfiguration ??= await GetOpenIdConfigurationAsync(cancellationToken).ConfigureAwait(false); - var refreshToken = ResolveRefreshToken(); - if (String.IsNullOrWhiteSpace(refreshToken)) - throw new InvalidOperationException("No Resgrid refresh token is available."); var formData = new Dictionary() { - ["grant_type"] = "refresh_token", - ["refresh_token"] = refreshToken, ["client_id"] = _options.ClientId, ["client_secret"] = _options.ClientSecret }; + switch (_options.GrantType) + { + case ResgridAuthGrantType.RefreshToken: + { + var refreshToken = ResolveRefreshToken(); + if (String.IsNullOrWhiteSpace(refreshToken)) + throw new InvalidOperationException("No Resgrid refresh token is available."); + + formData["grant_type"] = "refresh_token"; + formData["refresh_token"] = refreshToken; + break; + } + + case ResgridAuthGrantType.ClientCredentials: + { + formData["grant_type"] = "client_credentials"; + break; + } + + default: + throw new InvalidOperationException( + $"The Resgrid authentication grant type '{_options.GrantType}' requires an OAuth token but no supported grant flow is defined."); + } + if (!String.IsNullOrWhiteSpace(_options.Scope)) formData["scope"] = _options.Scope; @@ -159,19 +204,31 @@ private static async Task EnsureAuthenticatedAsync(CancellationToken cancellatio if (!response.IsSuccessStatusCode) { throw new HttpRequestException( - $"The Resgrid token refresh request failed with status code {(int)response.StatusCode} ({response.ReasonPhrase}). Response: {payload}", + $"The Resgrid token request failed with status code {(int)response.StatusCode} ({response.ReasonPhrase}). Response: {payload}", null, response.StatusCode); } var tokenResponse = JsonSerializer.Deserialize(payload, JsonOptions); if (tokenResponse == null || String.IsNullOrWhiteSpace(tokenResponse.AccessToken)) - throw new InvalidOperationException("The Resgrid token refresh response did not include an access token."); + throw new InvalidOperationException("The Resgrid token response did not include an access token."); _tokenState.AccessToken = tokenResponse.AccessToken; - _tokenState.RefreshToken = String.IsNullOrWhiteSpace(tokenResponse.RefreshToken) - ? refreshToken - : tokenResponse.RefreshToken; + + if (_options.GrantType == ResgridAuthGrantType.RefreshToken) + { + // Refresh token rotation: use the new refresh token if provided, + // otherwise retain the existing one. + _tokenState.RefreshToken = String.IsNullOrWhiteSpace(tokenResponse.RefreshToken) + ? ResolveRefreshToken() + : tokenResponse.RefreshToken; + } + else + { + // client_credentials typically does not return a refresh token. + _tokenState.RefreshToken = tokenResponse.RefreshToken; + } + _tokenState.ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn > 0 ? tokenResponse.ExpiresIn : 300); _tokenState.UserId = TryReadUserId(tokenResponse.AccessToken); diff --git a/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs b/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs index 8f78c69..e2db41e 100644 --- a/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs +++ b/Resgrid.Audio.Relay.Console/Configuration/RelayHostOptions.cs @@ -44,7 +44,94 @@ public sealed class SmtpRelayOptions public int MaxMessageBytes { get; set; } = 26214400; // 25 MB public bool SaveRawMessages { get; set; } = true; public string DepartmentDispatchPrefix { get; set; } = "G"; + + // ─── Email Domain Configuration (aligned with Postmark InboundEmailConfig) ─── + // + // These domains determine what TYPE of dispatch an incoming email triggers. + // They mirror InboundEmailConfig from the Resgrid API: + // + // Domain Type Creates Postmark Config Property + // ──────────────────────── ──────────────────── ───────────────────────── + // DepartmentAddressDomains Department-wide call DispatchDomain + // GroupAddressDomains Group-scoped call GroupsDomain + // GroupMessageAddressDomains Group message GroupMessageDomain + // ListAddressDomains Distribution list ListsDomain + // + // In hosted mode, the department ID is extracted from the subdomain + // prefix: {code}.{departmentId}.{baseDomain} + // → station5.dept123.dispatch.resgrid.com + + /// + /// Domains for department-wide dispatch calls (Type 1 in Postmark pipeline). + /// Emails to these domains create calls dispatched to all department users. + /// Default: [ "dispatch.resgrid.com" ] + /// public string[] DepartmentAddressDomains { get; set; } = new[] { "dispatch.resgrid.com" }; + + /// + /// Domains for group dispatch calls (Type 3 in Postmark pipeline). + /// Emails to these domains create calls dispatched to a specific group. + /// The local-part is matched against DepartmentGroups.DispatchEmail. + /// Default: [ "groups.resgrid.com" ] + /// public string[] GroupAddressDomains { get; set; } = new[] { "groups.resgrid.com" }; + + /// + /// Domains for group messages (Type 4 in Postmark pipeline). + /// Emails to these domains create a group message (not a call). + /// The local-part is matched against DepartmentGroups.MessageEmail. + /// Default: [ "gm.resgrid.com" ] + /// + public string[] GroupMessageAddressDomains { get; set; } = new[] { "gm.resgrid.com" }; + + /// + /// Domains for distribution list forwarding (Type 2 in Postmark pipeline). + /// Emails to these domains are forwarded to distribution list members. + /// The local-part is matched against DistributionLists.EmailAddress. + /// Default: [ "lists.resgrid.com" ] + /// + public string[] ListAddressDomains { get; set; } = new[] { "lists.resgrid.com" }; + + // ─── Hosted (Multi-Department) Mode ─── + + /// + /// When true, the SMTP relay is running in hosted (multi-department) mode. + /// In this mode the recipient domain is parsed to extract a department + /// identifier so that calls are created in the correct department. + /// + public bool HostedMode { get; set; } + + /// + /// In hosted mode, email domains are expected to follow the pattern + /// {code}.{departmentId}.{baseDomain}. This separator is used to + /// split the domain into department and base parts. + /// Defaults to "." (dot). + /// + /// Example: For "station5.dept123.dispatch.resgrid.com" with + /// DepartmentAddressDomains containing "dispatch.resgrid.com", + /// the department ID extracted is "dept123". + /// + public string DepartmentDomainSeparator { get; set; } = "."; + + /// + /// When set, overrides department detection from email domains. + /// All calls will be created under this department regardless of + /// the recipient address. Useful when the relay serves a single + /// department but uses hosted (SystemApiKey) authentication. + /// + public string DefaultDepartmentId { get; set; } + + // ─── Code-to-ID Resolution ─── + + /// + /// When true (the default), the relay calls the lookup APIs to + /// resolve dispatch codes (names like "STATION5") into numeric + /// entity IDs required by the SaveCall DispatchList format. + /// + /// When false, codes are sent directly to the API (legacy mode, + /// only works with numeric codes or if the API itself resolves + /// names). + /// + public bool ResolveDispatchCodes { get; set; } = true; } } diff --git a/Resgrid.Audio.Relay.Console/Program.cs b/Resgrid.Audio.Relay.Console/Program.cs index 9f87181..1cacc10 100644 --- a/Resgrid.Audio.Relay.Console/Program.cs +++ b/Resgrid.Audio.Relay.Console/Program.cs @@ -64,6 +64,10 @@ private static async Task RunAsync() var hostOptions = LoadHostOptions(); var mode = (hostOptions.Mode ?? "smtp").Trim().ToLowerInvariant(); + // Validate required settings before starting (fail fast with clear messages). + if (!ValidateOptions(hostOptions)) + return 1; + using var cancellationTokenSource = new CancellationTokenSource(); ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => { @@ -102,7 +106,7 @@ private static RelayHostOptions LoadHostOptions() var configuration = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: true) - .AddEnvironmentVariables("RELAY_") + .AddEnvironmentVariables("RESGRID__RELAY__") .Build(); var options = new RelayHostOptions(); @@ -117,6 +121,58 @@ private static RelayHostOptions LoadHostOptions() return options; } + private static bool ValidateOptions(RelayHostOptions options) + { + var errors = new System.Collections.Generic.List(); + + if (String.IsNullOrWhiteSpace(options.Resgrid.BaseUrl)) + errors.Add("RESGRID__RELAY__Resgrid__BaseUrl is required. Set it to your Resgrid API URL (e.g. https://api.resgrid.com)."); + + if (String.IsNullOrWhiteSpace(options.Resgrid.ClientId)) + errors.Add("RESGRID__RELAY__Resgrid__ClientId is required. Set it to your OIDC client ID."); + + if (String.IsNullOrWhiteSpace(options.Resgrid.ClientSecret)) + errors.Add("RESGRID__RELAY__Resgrid__ClientSecret is required. Set it to your OIDC client secret."); + + var grantType = options.Resgrid.GrantType; + + switch (grantType) + { + case ResgridAuthGrantType.RefreshToken: + if (String.IsNullOrWhiteSpace(options.Resgrid.RefreshToken)) + errors.Add("RESGRID__RELAY__Resgrid__RefreshToken is required when GrantType is RefreshToken."); + break; + case ResgridAuthGrantType.SystemApiKey: + if (String.IsNullOrWhiteSpace(options.Resgrid.SystemApiKey)) + errors.Add("RESGRID__RELAY__Resgrid__SystemApiKey is required when GrantType is SystemApiKey."); + break; + } + + if (options.Mode == "smtp") + { + var hasDomains = (options.Smtp.DepartmentAddressDomains?.Length ?? 0) > 0 + || (options.Smtp.GroupAddressDomains?.Length ?? 0) > 0 + || (options.Smtp.GroupMessageAddressDomains?.Length ?? 0) > 0 + || (options.Smtp.ListAddressDomains?.Length ?? 0) > 0; + + if (!hasDomains) + errors.Add("At least one dispatch domain must be configured. Set RESGRID__RELAY__Smtp__DepartmentAddressDomains__0, RESGRID__RELAY__Smtp__GroupAddressDomains__0, etc."); + } + + if (errors.Count > 0) + { + foreach (var error in errors) + Cli.Error.WriteLine($"Configuration error: {error}"); + + Cli.Error.WriteLine(); + Cli.Error.WriteLine("All settings are driven by environment variables prefixed with RESGRID__RELAY__."); + Cli.Error.WriteLine("Run 'Resgrid.Audio.Relay.Console help' for the full list."); + return false; + } + + return true; + } + private static void ShowVersion() { var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; @@ -135,14 +191,24 @@ private static void ShowHelp() Cli.WriteLine(" version Prints the application version"); Cli.WriteLine(); Cli.WriteLine("Environment variables:"); - Cli.WriteLine(" RELAY_Mode=smtp|audio"); - Cli.WriteLine(" RELAY_Resgrid__ClientId=..."); - Cli.WriteLine(" RELAY_Resgrid__ClientSecret=..."); - Cli.WriteLine(" RELAY_Resgrid__RefreshToken=..."); - Cli.WriteLine(" RELAY_Telemetry__Sentry__Dsn=..."); - Cli.WriteLine(" RELAY_Telemetry__Countly__Url=https://countly.example.com"); - Cli.WriteLine(" RELAY_Telemetry__Countly__AppKey=..."); - Cli.WriteLine(" RELAY_Smtp__Port=2525"); + Cli.WriteLine(" RESGRID__RELAY__Mode=smtp|audio"); + Cli.WriteLine(" RESGRID__RELAY__Resgrid__ClientId=..."); + Cli.WriteLine(" RESGRID__RELAY__Resgrid__ClientSecret=..."); + Cli.WriteLine(" RESGRID__RELAY__Resgrid__RefreshToken=..."); + Cli.WriteLine(" RESGRID__RELAY__Resgrid__GrantType=RefreshToken|ClientCredentials|SystemApiKey"); + Cli.WriteLine(" RESGRID__RELAY__Resgrid__SystemApiKey=..."); + Cli.WriteLine(" RESGRID__RELAY__Resgrid__DepartmentId=..."); + Cli.WriteLine(" RESGRID__RELAY__Telemetry__Sentry__Dsn=..."); + Cli.WriteLine(" RESGRID__RELAY__Telemetry__Countly__Url=https://countly.example.com"); + Cli.WriteLine(" RESGRID__RELAY__Telemetry__Countly__AppKey=..."); + Cli.WriteLine(" RESGRID__RELAY__Smtp__Port=2525"); + Cli.WriteLine(" RESGRID__RELAY__Smtp__HostedMode=false"); + Cli.WriteLine(" RESGRID__RELAY__Smtp__DefaultDepartmentId=..."); + Cli.WriteLine(" RESGRID__RELAY__Smtp__ResolveDispatchCodes=true|false"); + Cli.WriteLine(" RESGRID__RELAY__Smtp__DepartmentAddressDomains__0=dispatch.resgrid.com"); + Cli.WriteLine(" RESGRID__RELAY__Smtp__GroupAddressDomains__0=groups.resgrid.com"); + Cli.WriteLine(" RESGRID__RELAY__Smtp__GroupMessageAddressDomains__0=gm.resgrid.com"); + Cli.WriteLine(" RESGRID__RELAY__Smtp__ListAddressDomains__0=lists.resgrid.com"); } #if NET10_0_WINDOWS diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs b/Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs index 8c00b13..3cb63a4 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs +++ b/Resgrid.Audio.Relay.Console/Smtp/SmtpDispatchAddressParser.cs @@ -6,6 +6,17 @@ namespace Resgrid.Audio.Relay.Console.Smtp { + /// + /// Parses SMTP recipient addresses into dispatch codes, determining the + /// dispatch type from the recipient domain — matching the logic used by + /// the Postmark EmailController in the Resgrid API. + /// + /// Domain → DispatchCodeType mapping: + /// DepartmentAddressDomains → Department (Type 1 — department-wide call) + /// GroupAddressDomains → Group (Type 3 — group-scoped call) + /// GroupMessageAddressDomains → GroupMessage (Type 4 — group message) + /// ListAddressDomains → DistributionList (Type 2 — list forwarding) + /// public sealed class SmtpDispatchAddressParser { private readonly SmtpRelayOptions _options; @@ -15,27 +26,46 @@ public SmtpDispatchAddressParser(SmtpRelayOptions options) _options = options ?? throw new ArgumentNullException(nameof(options)); } - public List ParseRecipients(IEnumerable addresses) + /// + /// Parses a list of recipient addresses into dispatch codes. + /// In hosted mode, also extracts the department identifier from + /// the recipient domain. + /// + public DispatchParseResult ParseRecipients(IEnumerable addresses) { - var dispatchCodes = new List(); + var result = new DispatchParseResult(); if (addresses == null) - return dispatchCodes; + return result; foreach (var address in addresses) { - if (TryParse(address, out var dispatchCode) && - dispatchCodes.All(x => !String.Equals(x.Code, dispatchCode.Code, StringComparison.OrdinalIgnoreCase))) + if (TryParse(address, out var dispatchCode, out var departmentId) && + result.DispatchCodes.All(x => !String.Equals(x.Code, dispatchCode.Code, StringComparison.OrdinalIgnoreCase))) { - dispatchCodes.Add(dispatchCode); + result.DispatchCodes.Add(dispatchCode); + + if (!String.IsNullOrWhiteSpace(departmentId) && String.IsNullOrWhiteSpace(result.DepartmentId)) + result.DepartmentId = departmentId; } } - return dispatchCodes; + // A static DefaultDepartmentId overrides domain-based detection. + if (!String.IsNullOrWhiteSpace(_options.DefaultDepartmentId)) + result.DepartmentId = _options.DefaultDepartmentId; + + return result; } public bool TryParse(string address, out DispatchCode dispatchCode) + { + return TryParse(address, out dispatchCode, out _); + } + + public bool TryParse(string address, out DispatchCode dispatchCode, out string departmentId) { dispatchCode = null; + departmentId = null; + if (String.IsNullOrWhiteSpace(address)) return false; @@ -45,22 +75,72 @@ public bool TryParse(string address, out DispatchCode dispatchCode) var code = parts[0].Trim(); var domain = parts[1].Trim(); - if (_options.DepartmentAddressDomains.Any(x => String.Equals(x, domain, StringComparison.OrdinalIgnoreCase))) + + // Exact domain match (single-department or non-hosted). + if (TryMatchDomain(domain, _options.DepartmentAddressDomains, DispatchCodeType.Department, code, ref dispatchCode)) + return true; + + if (TryMatchDomain(domain, _options.GroupAddressDomains, DispatchCodeType.Group, code, ref dispatchCode)) + return true; + + if (TryMatchDomain(domain, _options.GroupMessageAddressDomains, DispatchCodeType.GroupMessage, code, ref dispatchCode)) + return true; + + if (TryMatchDomain(domain, _options.ListAddressDomains, DispatchCodeType.DistributionList, code, ref dispatchCode)) + return true; + + // Hosted mode: the domain may have a department prefix. + // Example: station5.dept123.dispatch.resgrid.com + // → code = "station5", departmentId = "dept123" + if (_options.HostedMode) { - dispatchCode = new DispatchCode + var domainParts = domain.Split(new[] { _options.DepartmentDomainSeparator }, StringSplitOptions.RemoveEmptyEntries); + if (domainParts.Length >= 3) { - Code = code, - Type = DispatchCodeType.Department - }; - return true; + // The first segment is the department ID, the rest is the base domain. + var extractedDepartmentId = domainParts[0]; + var baseDomain = String.Join(_options.DepartmentDomainSeparator, domainParts.Skip(1)); + + if (TryMatchDomain(baseDomain, _options.DepartmentAddressDomains, DispatchCodeType.Department, code, ref dispatchCode)) + { + departmentId = extractedDepartmentId; + return true; + } + + if (TryMatchDomain(baseDomain, _options.GroupAddressDomains, DispatchCodeType.Group, code, ref dispatchCode)) + { + departmentId = extractedDepartmentId; + return true; + } + + if (TryMatchDomain(baseDomain, _options.GroupMessageAddressDomains, DispatchCodeType.GroupMessage, code, ref dispatchCode)) + { + departmentId = extractedDepartmentId; + return true; + } + + if (TryMatchDomain(baseDomain, _options.ListAddressDomains, DispatchCodeType.DistributionList, code, ref dispatchCode)) + { + departmentId = extractedDepartmentId; + return true; + } + } } - if (_options.GroupAddressDomains.Any(x => String.Equals(x, domain, StringComparison.OrdinalIgnoreCase))) + return false; + } + + private static bool TryMatchDomain(string domain, string[] configuredDomains, DispatchCodeType type, string code, ref DispatchCode dispatchCode) + { + if (configuredDomains == null || configuredDomains.Length == 0) + return false; + + if (configuredDomains.Any(x => String.Equals(x, domain, StringComparison.OrdinalIgnoreCase))) { dispatchCode = new DispatchCode { Code = code, - Type = DispatchCodeType.Group + Type = type }; return true; } @@ -68,4 +148,45 @@ public bool TryParse(string address, out DispatchCode dispatchCode) return false; } } + + /// + /// The result of parsing SMTP recipient addresses into dispatch targets. + /// + public sealed class DispatchParseResult + { + /// + /// The dispatch codes extracted from recipient addresses. + /// + public List DispatchCodes { get; } = new List(); + + /// + /// The department identifier extracted from the recipient domain + /// in hosted mode, or the + /// when configured. Null when not in hosted mode. + /// + public string DepartmentId { get; set; } + + /// + /// Returns true if at least one dispatchable target was parsed. + /// GroupMessage and DistributionList codes are included here + /// (they are valid targets), but group messages and list + /// forwarding are handled differently than calls. + /// + public bool HasTargets => DispatchCodes.Count > 0; + + /// + /// True when ALL dispatch codes are call-dispatchable (Department or Group). + /// + public bool HasCallTargets => DispatchCodes.Any(x => x.Type == DispatchCodeType.Department || x.Type == DispatchCodeType.Group); + + /// + /// True when any code is a group message target. + /// + public bool HasGroupMessageTargets => DispatchCodes.Any(x => x.Type == DispatchCodeType.GroupMessage); + + /// + /// True when any code is a distribution list target. + /// + public bool HasDistributionListTargets => DispatchCodes.Any(x => x.Type == DispatchCodeType.DistributionList); + } } diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs b/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs index 97ebb2c..6364406 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs +++ b/Resgrid.Audio.Relay.Console/Smtp/SmtpRelayInfrastructure.cs @@ -163,14 +163,37 @@ public override async Task SaveAsync(ISessionContext context, IMes messageSummary.RawMessagePath = rawMessagePath; } - var dispatchTargets = GetDispatchTargets(message); - messageSummary.SetDispatchTargets(dispatchTargets); - if (dispatchTargets.Count == 0) + var dispatchResult = ParseDispatchTargets(message); + messageSummary.SetDispatchTargets(dispatchResult.DispatchCodes); + + if (!dispatchResult.HasTargets) + { + _telemetry.UnroutableMessage(context, messageSummary); + return SmtpResponse.Ok; + } + + // Group messages (Type 4) and distribution list messages (Type 2) + // are not yet handled by the SMTP relay — log and skip. + if (dispatchResult.HasGroupMessageTargets) + _telemetry.MessageProcessingStarted(context, messageSummary); + + if (dispatchResult.HasDistributionListTargets) + _telemetry.MessageProcessingStarted(context, messageSummary); + + if (!dispatchResult.HasCallTargets) { _telemetry.UnroutableMessage(context, messageSummary); return SmtpResponse.Ok; } + // Resolve dispatch code names to numeric IDs required by SaveCall. + // Department-type codes are excluded from DispatchList by + // DispatchListBuilder (they dispatch to the entire department). + if (_options.ResolveDispatchCodes) + { + await ResolveDispatchCodesAsync(dispatchResult, cancellationToken).ConfigureAwait(false); + } + var fromMailbox = message.From.Mailboxes.FirstOrDefault(); var attachments = ExtractAttachments(message).ToList(); messageSummary.SetAttachments(attachments, _options.MaxAttachmentBytes); @@ -188,11 +211,12 @@ public override async Task SaveAsync(ISessionContext context, IMes Name = Trim(subject, 200), Nature = Trim(nature, 500), Note = BuildNote(message, messageBody, skippedAttachments), - DispatchList = DispatchListBuilder.Build(dispatchTargets, _options.DepartmentDispatchPrefix), + DispatchList = DispatchListBuilder.Build(dispatchResult.DispatchCodes, _options.DepartmentDispatchPrefix), ContactName = fromMailbox == null ? null : Trim(String.IsNullOrWhiteSpace(fromMailbox.Name) ? fromMailbox.Address : fromMailbox.Name, 200), ContactInfo = fromMailbox?.Address, ExternalId = stableMessageId, - ReferenceId = message.MessageId + ReferenceId = message.MessageId, + DepartmentId = dispatchResult.DepartmentId }, cancellationToken).ConfigureAwait(false); messageSummary.CallId = callId; @@ -201,7 +225,10 @@ public override async Task SaveAsync(ISessionContext context, IMes if (uploadableAttachments.Count > 0) { var userId = ResgridV4ApiClient.CurrentUserId; - if (String.IsNullOrWhiteSpace(userId)) + // In SystemApiKey (hosted) mode the department ID serves as + // the user identifier for file uploads. In token-based modes + // the user id must come from the access token JWT. + if (String.IsNullOrWhiteSpace(userId) && String.IsNullOrWhiteSpace(dispatchResult.DepartmentId)) throw new InvalidOperationException("The Resgrid access token did not contain a user id required to upload SMTP message attachments."); foreach (var attachment in uploadableAttachments) @@ -209,11 +236,12 @@ public override async Task SaveAsync(ISessionContext context, IMes await _callsClient.SaveCallFileAsync(new SaveCallFileInput { CallId = callId, - UserId = userId, + UserId = String.IsNullOrWhiteSpace(userId) ? dispatchResult.DepartmentId : userId, Type = (int)attachment.Type, Name = attachment.Name, Data = Convert.ToBase64String(attachment.Data), - Note = $"SMTP attachment imported from message {stableMessageId}" + Note = $"SMTP attachment imported from message {stableMessageId}", + DepartmentId = dispatchResult.DepartmentId }, cancellationToken).ConfigureAwait(false); } } @@ -241,7 +269,72 @@ await _callsClient.SaveCallFileAsync(new SaveCallFileInput } } + /// + /// Resolves dispatch code names (like "STATION5") to numeric entity IDs + /// by calling the v4 lookup APIs. Updates each + /// in place so that emits the + /// correct numeric IDs required by SaveCall. + /// + /// Resolution strategy: + /// Department → No lookup (excluded from DispatchList) + /// Group → GET /Groups/GetGroupByDispatchCode + /// GroupMessage → GET /Groups/GetGroupByMessageCode + /// + /// Lookups that fail (404 or API not deployed yet) are logged and + /// the code falls back to its raw Code value. + /// + private async Task ResolveDispatchCodesAsync(DispatchParseResult dispatchResult, CancellationToken cancellationToken) + { + foreach (var code in dispatchResult.DispatchCodes) + { + switch (code.Type) + { + case DispatchCodeType.Department: + // Department dispatch codes identify the department itself, + // not a specific group. They are excluded from DispatchList + // by DispatchListBuilder — no lookup needed here. + break; + + case DispatchCodeType.Group: + { + var group = await LookupsApi.LookupGroupByDispatchCodeAsync( + code.Code, dispatchResult.DepartmentId, cancellationToken).ConfigureAwait(false); + + if (group != null && !String.IsNullOrWhiteSpace(group.GroupId)) + code.ResolvedId = group.GroupId; + + break; + } + + case DispatchCodeType.GroupMessage: + { + var group = await LookupsApi.LookupGroupByMessageCodeAsync( + code.Code, dispatchResult.DepartmentId, cancellationToken).ConfigureAwait(false); + + if (group != null && !String.IsNullOrWhiteSpace(group.GroupId)) + code.ResolvedId = group.GroupId; + + break; + } + + case DispatchCodeType.DistributionList: + // Distribution list codes are not resolved via API lookups yet. + break; + } + } + } + private List GetDispatchTargets(MimeMessage message) + { + var parseResult = _dispatchAddressParser.ParseRecipients( + message.To.Mailboxes + .Concat(message.Cc.Mailboxes) + .Select(x => x.Address)); + + return parseResult.DispatchCodes; + } + + private DispatchParseResult ParseDispatchTargets(MimeMessage message) { return _dispatchAddressParser.ParseRecipients( message.To.Mailboxes @@ -400,9 +493,29 @@ public Task CanAcceptFromAsync(ISessionContext context, IMailbox from, int public Task CanDeliverToAsync(ISessionContext context, IMailbox to, IMailbox from, CancellationToken token) { + var host = to.Host; var accepted = - (_options.DepartmentAddressDomains ?? Array.Empty()).Any(x => String.Equals(x, to.Host, StringComparison.OrdinalIgnoreCase)) || - (_options.GroupAddressDomains ?? Array.Empty()).Any(x => String.Equals(x, to.Host, StringComparison.OrdinalIgnoreCase)); + IsConfiguredDomain(host, _options.DepartmentAddressDomains) || + IsConfiguredDomain(host, _options.GroupAddressDomains) || + IsConfiguredDomain(host, _options.GroupMessageAddressDomains) || + IsConfiguredDomain(host, _options.ListAddressDomains); + + if (!accepted && _options.HostedMode) + { + // In hosted mode, the recipient domain follows the pattern + // {departmentId}.{baseDomain} (e.g. dept123.dispatch.resgrid.com). + // Strip the leading segment and check against the configured domains. + var parts = host.Split(new[] { _options.DepartmentDomainSeparator }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 3) + { + var baseDomain = String.Join(_options.DepartmentDomainSeparator, parts.Skip(1)); + accepted = + IsConfiguredDomain(baseDomain, _options.DepartmentAddressDomains) || + IsConfiguredDomain(baseDomain, _options.GroupAddressDomains) || + IsConfiguredDomain(baseDomain, _options.GroupMessageAddressDomains) || + IsConfiguredDomain(baseDomain, _options.ListAddressDomains); + } + } _telemetry.RecipientEvaluated( context, @@ -414,6 +527,11 @@ public Task CanDeliverToAsync(ISessionContext context, IMailbox to, IMailb return Task.FromResult(accepted); } + private static bool IsConfiguredDomain(string host, string[] domains) + { + return (domains ?? Array.Empty()).Any(x => String.Equals(x, host, StringComparison.OrdinalIgnoreCase)); + } + public IMailboxFilter CreateInstance(ISessionContext context) { return this; diff --git a/Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs b/Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs index 0a87f58..a8547d5 100644 --- a/Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs +++ b/Resgrid.Audio.Relay.Console/Smtp/SmtpTelemetry.cs @@ -686,23 +686,29 @@ private SmtpMessageSummary() public int BodyLength { get; private set; } public int DepartmentDispatchCount { get; private set; } public int GroupDispatchCount { get; private set; } + public int GroupMessageDispatchCount { get; private set; } + public int DistributionListDispatchCount { get; private set; } public int AttachmentCount => AttachmentNames.Length; public int OversizedAttachmentCount => OversizedAttachmentNames.Length; - public int DispatchTargetCount => DepartmentDispatchCount + GroupDispatchCount; + public int DispatchTargetCount => DepartmentDispatchCount + GroupDispatchCount + GroupMessageDispatchCount + DistributionListDispatchCount; public string SenderDomain => GetDomain(FromAddresses.FirstOrDefault()); public string RouteKind { get { - if (DepartmentDispatchCount > 0 && GroupDispatchCount > 0) - return "mixed"; - if (DepartmentDispatchCount > 0) - return "department"; - if (GroupDispatchCount > 0) - return "group"; - - return "none"; + var kinds = new List(4); + if (DepartmentDispatchCount > 0) kinds.Add("department"); + if (GroupDispatchCount > 0) kinds.Add("group"); + if (GroupMessageDispatchCount > 0) kinds.Add("groupmessage"); + if (DistributionListDispatchCount > 0) kinds.Add("distributionlist"); + + return kinds.Count switch + { + 0 => "none", + 1 => kinds[0], + _ => "mixed" + }; } } @@ -729,7 +735,9 @@ public void SetDispatchTargets(IEnumerable dispatchTargets) var items = dispatchTargets?.ToList() ?? new List(); DepartmentDispatchCount = items.Count(x => x.Type == DispatchCodeType.Department); GroupDispatchCount = items.Count(x => x.Type == DispatchCodeType.Group); - DispatchTargets = items.Select(x => $"{x.Type}:{x.Code}").ToArray(); + GroupMessageDispatchCount = items.Count(x => x.Type == DispatchCodeType.GroupMessage); + DistributionListDispatchCount = items.Count(x => x.Type == DispatchCodeType.DistributionList); + DispatchTargets = items.Select(x => $"{(int)x.Type}:{x.Code}").ToArray(); } public void SetAttachments(IEnumerable attachments, int maxAttachmentBytes) diff --git a/Resgrid.Audio.Relay.Console/appsettings.json b/Resgrid.Audio.Relay.Console/appsettings.json index 21a2758..964da19 100644 --- a/Resgrid.Audio.Relay.Console/appsettings.json +++ b/Resgrid.Audio.Relay.Console/appsettings.json @@ -2,13 +2,16 @@ "Mode": "smtp", "AudioConfigPath": "settings.json", "Resgrid": { - "BaseUrl": "https://api.resgrid.com", + "BaseUrl": "", "ApiVersion": "4", "ClientId": "", "ClientSecret": "", "RefreshToken": "", "Scope": "openid profile email offline_access mobile", - "TokenCachePath": ".\\data\\resgrid-token.json" + "TokenCachePath": "./data/resgrid-token.json", + "GrantType": "RefreshToken", + "SystemApiKey": "", + "DepartmentId": "" }, "Telemetry": { "Environment": "", @@ -27,13 +30,20 @@ "Smtp": { "ServerName": "resgrid-relay", "Port": 2525, - "DataDirectory": ".\\data", + "DataDirectory": "./data", "DuplicateWindowHours": 72, "DefaultCallPriority": 1, "MaxAttachmentBytes": 10485760, + "MaxMessageBytes": 26214400, "SaveRawMessages": true, "DepartmentDispatchPrefix": "G", - "DepartmentAddressDomains": [ "dispatch.resgrid.com" ], - "GroupAddressDomains": [ "groups.resgrid.com" ] + "DepartmentAddressDomains": [], + "GroupAddressDomains": [], + "GroupMessageAddressDomains": [], + "ListAddressDomains": [], + "HostedMode": false, + "DepartmentDomainSeparator": ".", + "DefaultDepartmentId": "", + "ResolveDispatchCodes": true } } diff --git a/Resgrid.Audio.Tests/DispatchListBuilderTests.cs b/Resgrid.Audio.Tests/DispatchListBuilderTests.cs index 459bd30..2825618 100644 --- a/Resgrid.Audio.Tests/DispatchListBuilderTests.cs +++ b/Resgrid.Audio.Tests/DispatchListBuilderTests.cs @@ -8,15 +8,66 @@ namespace Resgrid.Audio.Tests public class DispatchListBuilderTests { [Test] - public void Build_Should_Format_Group_And_Department_Dispatch_Codes() + public void Build_Should_Exclude_Department_Codes_Because_They_Dispatch_To_Entire_Department() { + // Department-type codes dispatch to ALL department users — they should + // NOT appear in the DispatchList (which targets specific groups/units/roles). var result = DispatchListBuilder.Build(new[] { new DispatchCode { Code = "GRP001", Type = DispatchCodeType.Group }, new DispatchCode { Code = "DEPT01", Type = DispatchCodeType.Department } }, "G"); - result.Should().Be("G:GRP001|G:DEPT01"); + result.Should().Be("G:GRP001"); + } + + [Test] + public void Build_Should_Format_Group_And_GroupMessage_Dispatch_Codes() + { + var result = DispatchListBuilder.Build(new[] + { + new DispatchCode { Code = "GRP001", Type = DispatchCodeType.Group }, + new DispatchCode { Code = "MSG001", Type = DispatchCodeType.GroupMessage } + }, "G"); + + result.Should().Be("G:GRP001|G:MSG001"); + } + + [Test] + public void Build_Should_Exclude_DistributionList_Codes() + { + // Distribution list codes are forwarded, not dispatched — they should + // NOT appear in the DispatchList sent to SaveCall. + var result = DispatchListBuilder.Build(new[] + { + new DispatchCode { Code = "GRP001", Type = DispatchCodeType.Group }, + new DispatchCode { Code = "LIST01", Type = DispatchCodeType.DistributionList } + }, "G"); + + result.Should().Be("G:GRP001"); + } + + [Test] + public void Build_Should_Prefer_ResolvedId_Over_Code() + { + var result = DispatchListBuilder.Build(new[] + { + new DispatchCode { Code = "station5", Type = DispatchCodeType.Group, ResolvedId = "42" } + }, "G"); + + result.Should().Be("G:42"); + } + + [Test] + public void Build_Should_Return_Null_When_No_Call_Dispatchable_Codes() + { + var result = DispatchListBuilder.Build(new[] + { + new DispatchCode { Code = "DEPT01", Type = DispatchCodeType.Department }, + new DispatchCode { Code = "LIST01", Type = DispatchCodeType.DistributionList } + }, "G"); + + result.Should().BeNull(); } [Test] diff --git a/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs b/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs index 761ae60..b5cf6a1 100644 --- a/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs +++ b/Resgrid.Audio.Tests/SmtpDispatchAddressParserTests.cs @@ -3,6 +3,7 @@ using Resgrid.Audio.Relay.Console.Configuration; using Resgrid.Audio.Relay.Console.Smtp; using Resgrid.Providers.ApiClient.V4; +using System; namespace Resgrid.Audio.Tests { @@ -21,6 +22,91 @@ public void TryParse_Should_Map_Department_Address_To_Department_Dispatch_Code() dispatchCode.Type.Should().Be(DispatchCodeType.Department); } + [Test] + public void TryParse_Should_Map_Group_Address_To_Group_Dispatch_Code() + { + var parser = new SmtpDispatchAddressParser(new SmtpRelayOptions()); + + var result = parser.TryParse("station5@groups.resgrid.com", out var dispatchCode); + + result.Should().BeTrue(); + dispatchCode.Code.Should().Be("station5"); + dispatchCode.Type.Should().Be(DispatchCodeType.Group); + } + + [Test] + public void TryParse_Should_Map_GroupMessage_Address_To_GroupMessage_Code() + { + var parser = new SmtpDispatchAddressParser(new SmtpRelayOptions()); + + var result = parser.TryParse("team1@gm.resgrid.com", out var dispatchCode); + + result.Should().BeTrue(); + dispatchCode.Code.Should().Be("team1"); + dispatchCode.Type.Should().Be(DispatchCodeType.GroupMessage); + } + + [Test] + public void TryParse_Should_Map_List_Address_To_DistributionList_Code() + { + var parser = new SmtpDispatchAddressParser(new SmtpRelayOptions()); + + var result = parser.TryParse("inbound@lists.resgrid.com", out var dispatchCode); + + result.Should().BeTrue(); + dispatchCode.Code.Should().Be("inbound"); + dispatchCode.Type.Should().Be(DispatchCodeType.DistributionList); + } + + [Test] + public void TryParse_Should_Reject_Unknown_Domain() + { + var parser = new SmtpDispatchAddressParser(new SmtpRelayOptions()); + + var result = parser.TryParse("abc123@example.com", out _); + + result.Should().BeFalse(); + } + + [Test] + public void TryParse_Should_Honor_Custom_Domains() + { + var options = new SmtpRelayOptions + { + DepartmentAddressDomains = new[] { "pager.company.local" }, + GroupAddressDomains = Array.Empty(), + GroupMessageAddressDomains = Array.Empty(), + ListAddressDomains = Array.Empty() + }; + var parser = new SmtpDispatchAddressParser(options); + + var result = parser.TryParse("dispatch@pager.company.local", out var dispatchCode); + + result.Should().BeTrue(); + dispatchCode.Code.Should().Be("dispatch"); + dispatchCode.Type.Should().Be(DispatchCodeType.Department); + } + + [Test] + public void TryParse_HostedMode_Should_Extract_DepartmentId_From_Domain() + { + var options = new SmtpRelayOptions + { + HostedMode = true, + DepartmentAddressDomains = new[] { "dispatch.resgrid.com" }, + GroupAddressDomains = new[] { "groups.resgrid.com" }, + DepartmentDomainSeparator = "." + }; + var parser = new SmtpDispatchAddressParser(options); + + var result = parser.TryParse("station5@dept123.dispatch.resgrid.com", out var dispatchCode, out var departmentId); + + result.Should().BeTrue(); + dispatchCode.Code.Should().Be("station5"); + dispatchCode.Type.Should().Be(DispatchCodeType.Department); + departmentId.Should().Be("dept123"); + } + [Test] public void ParseRecipients_Should_Ignore_Unknown_Domains_And_Deduplicate_Valid_Ones() { @@ -34,9 +120,9 @@ public void ParseRecipients_Should_Ignore_Unknown_Domains_And_Deduplicate_Valid_ "invalid@example.com" }); - result.Should().HaveCount(2); - result[0].Type.Should().Be(DispatchCodeType.Department); - result[1].Type.Should().Be(DispatchCodeType.Group); + result.DispatchCodes.Should().HaveCount(2); + result.DispatchCodes.Should().Contain(x => x.Type == DispatchCodeType.Department); + result.DispatchCodes.Should().Contain(x => x.Type == DispatchCodeType.Group); } } } diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c0bd953..bda9297 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,33 +1,60 @@ #!/bin/bash # Entrypoint for the Resgrid Relay container. # -# Optionally starts a LocalXpose TCP tunnel before launching the relay, -# allowing the SMTP port to be reached from outside a firewall. +# Configuration is driven entirely by environment variables (prefixed RESGRID__RELAY__). +# See the Dockerfile for the complete list of available settings and defaults. +# The bundled appsettings.json acts as a reference template only — it contains +# no operational values. Everything must come from docker run -e or docker-compose. # -# Configuration — choose one approach: -# -# APPROACH 1: environment variables +# Optional: LocalXpose TCP tunnel # LOCLX_ENABLED=true enable the tunnel (default: false) # LOCLX_TOKEN= LocalXpose access token (required) # LOCLX_RESERVED_ENDPOINT= optional reserved endpoint, e.g. smtp.loclx.io:25 -# RELAY_Smtp__Port=2525 SMTP port the relay listens on (default: 2525) -# -# APPROACH 2: config file (takes precedence over env vars when present) -# Mount a tunnels YAML file into the container: -# -v /host/loclx-tunnels.yaml:/etc/resgrid/loclx-tunnels.yaml -# and set LOCLX_ENABLED=true and LOCLX_TOKEN=. -# -# Example loclx-tunnels.yaml: -# tunnels: -# - name: smtp -# type: tcp -# to: localhost:2525 -# reserved: smtp.loclx.io:25 # +# Alternatively, mount a tunnels YAML file at /etc/resgrid/loclx-tunnels.yaml +# and set LOCLX_ENABLED=true. set -e +# ─── Validation ─────────────────────────────────────────────────────────── +echo "[relay] Resgrid Relay starting..." +echo "[relay] Mode: ${RESGRID__RELAY__Mode:-smtp}" +echo "[relay] API: ${RESGRID__RELAY__Resgrid__BaseUrl:-(NOT SET — required)}" +echo "[relay] Port: ${RESGRID__RELAY__Smtp__Port:-2525}" + +if [ -z "${RESGRID__RELAY__Resgrid__BaseUrl}" ]; then + echo "[relay] ERROR: RESGRID__RELAY__Resgrid__BaseUrl is required." + exit 1 +fi + +if [ -z "${RESGRID__RELAY__Resgrid__ClientId}" ]; then + echo "[relay] ERROR: RESGRID__RELAY__Resgrid__ClientId is required." + exit 1 +fi + +if [ -z "${RESGRID__RELAY__Resgrid__ClientSecret}" ]; then + echo "[relay] ERROR: RESGRID__RELAY__Resgrid__ClientSecret is required." + exit 1 +fi + +grant_type="${RESGRID__RELAY__Resgrid__GrantType:-RefreshToken}" +case "${grant_type}" in + RefreshToken) + if [ -z "${RESGRID__RELAY__Resgrid__RefreshToken}" ]; then + echo "[relay] ERROR: RESGRID__RELAY__Resgrid__RefreshToken is required when GrantType=RefreshToken." + exit 1 + fi + ;; + SystemApiKey) + if [ -z "${RESGRID__RELAY__Resgrid__SystemApiKey}" ]; then + echo "[relay] ERROR: RESGRID__RELAY__Resgrid__SystemApiKey is required when GrantType=SystemApiKey." + exit 1 + fi + ;; +esac + +# ─── LocalXpose tunnel ──────────────────────────────────────────────────── if [ "${LOCLX_ENABLED:-false}" = "true" ]; then - SMTP_PORT="${RELAY_Smtp__Port:-2525}" + SMTP_PORT="${RESGRID__RELAY__Smtp__Port:-2525}" if [ -n "${LOCLX_TOKEN}" ]; then echo "[localxpose] Authenticating..."