Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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=<host:port> 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

Expand Down
8 changes: 6 additions & 2 deletions Providers/Resgrid.Providers.ApiClient/V4/CallsApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ public static async Task<string> SaveCallAsync(NewCallInput call, CancellationTo
return result.Id;
}

public static async Task<GetCallResult> GetCallAsync(string callId, CancellationToken cancellationToken = default)
public static async Task<GetCallResult> 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<GetCallResult>($"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<GetCallResult>(url, cancellationToken).ConfigureAwait(false);
}

public static async Task<string> SaveCallFileAsync(SaveCallFileInput file, CancellationToken cancellationToken = default)
Expand Down
103 changes: 92 additions & 11 deletions Providers/Resgrid.Providers.ApiClient/V4/DispatchListBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,99 @@ namespace Resgrid.Providers.ApiClient.V4
{
public enum DispatchCodeType
{
/// <summary>
/// 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.
/// </summary>
Department = 1,
Group = 2

/// <summary>
/// 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.
/// </summary>
Group = 2,

/// <summary>
/// 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.
/// </summary>
GroupMessage = 3,

/// <summary>
/// Distribution list email (Type 2 in the Postmark pipeline).
/// The local-part is the list address stored in
/// DistributionLists.EmailAddress. Results in list forwarding.
/// </summary>
DistributionList = 4
}

public sealed class DispatchCode
{
/// <summary>
/// The dispatch code (name) extracted from the email local-part.
/// Example: "station5" from station5@dispatch.resgrid.com
/// </summary>
public string Code { get; set; }

/// <summary>
/// The type of dispatch this code represents.
/// </summary>
public DispatchCodeType Type { get; set; }

/// <summary>
/// (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 <see cref="Code"/>.
/// </summary>
public string ResolvedId { get; set; }

/// <summary>
/// True when this dispatch code has been resolved to a numeric ID
/// and is ready to be included in the DispatchList sent to SaveCall.
/// </summary>
public bool IsResolved => !String.IsNullOrWhiteSpace(ResolvedId);

/// <summary>
/// Returns the value that should appear in a DispatchList token.
/// Prefers the resolved ID, falls back to the code name.
/// </summary>
public string DispatchToken => IsResolved ? ResolvedId : Code;
}

public static class DispatchListBuilder
{
/// <summary>
/// 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 <see cref="DispatchCode.ResolvedId"/>,
/// that numeric ID is used. Otherwise the original code string is
/// used as a best-effort fallback.
/// </summary>
public static string Build(IEnumerable<DispatchCode> 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();

Expand All @@ -35,17 +108,25 @@ public static string Build(IEnumerable<DispatchCode> dispatchCodes, string depar
return String.Join("|", tokens);
}

public static string Format(string code, DispatchCodeType type, string departmentDispatchPrefix = "G")
/// <summary>
/// Formats a dispatch code into a single DispatchList token.
/// </summary>
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)
Expand Down
127 changes: 127 additions & 0 deletions Providers/Resgrid.Providers.ApiClient/V4/LookupsApi.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public static class LookupsApi
{
/// <summary>
/// Resolves a group dispatch email code to its numeric group ID.
/// Maps to: GET /api/v4/Groups/GetGroupByDispatchCode?code={code}&amp;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).
/// </summary>
public static async Task<GroupLookupResult> 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<GroupLookupResult>(url, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Resolves a group message email code to its numeric group ID.
/// Maps to: GET /api/v4/Groups/GetGroupByMessageCode?code={code}&amp;departmentId={departmentId}
///
/// Behind the scenes this queries DepartmentGroups.MessageEmail.
/// Returns null when the endpoint returns 404.
/// </summary>
public static async Task<GroupLookupResult> 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<GroupLookupResult>(url, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Resolves a unit name to its numeric unit ID.
/// Maps to: GET /api/v4/Units/GetUnitByName?name={name}&amp;departmentId={departmentId}
///
/// Behind the scenes this queries Units.UnitName.
/// Returns null when the endpoint returns 404.
/// </summary>
public static async Task<UnitLookupResult> 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<UnitLookupResult>(url, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Resolves a role name to its numeric role ID.
/// Maps to: GET /api/v4/Roles/GetRoleByName?name={name}&amp;departmentId={departmentId}
///
/// Behind the scenes this queries PersonnelRoles.Name.
/// Returns null when the endpoint returns 404.
/// </summary>
public static async Task<RoleLookupResult> 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<RoleLookupResult>(url, cancellationToken).ConfigureAwait(false);
}

private static async Task<T> TryGetAsync<T>(string url, CancellationToken cancellationToken) where T : class
{
try
{
var response = await ResgridV4ApiClient.GetAsync<LookupResponse<T>>(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;
}
}
}
}
14 changes: 14 additions & 0 deletions Providers/Resgrid.Providers.ApiClient/V4/Models/CallModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ public sealed class NewCallInput
public DateTimeOffset? DispatchOn { get; set; }
public string CallFormData { get; set; }
public bool? CheckInTimersEnabled { get; set; }

/// <summary>
/// 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.
/// </summary>
public string DepartmentId { get; set; }
}

public sealed class SaveCallFileInput
Expand All @@ -42,6 +49,13 @@ public sealed class SaveCallFileInput
public string Latitude { get; set; }
public string Longitude { get; set; }
public string Note { get; set; }

/// <summary>
/// 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.
/// </summary>
public string DepartmentId { get; set; }
}

public sealed class SaveOperationResult
Expand Down
Loading
Loading