Portal Community

How It Works

Unlike messaging channels, email HIL doesn't use a platform interaction webhook. Instead, the action buttons in the email are standard hyperlinks pointing to a BizFirst-hosted endpoint. When the recipient clicks a button, their browser makes a GET request to that endpoint, which validates the token and calls ContinueAsync.

Advantages

  • No platform account or API registration needed
  • Works for anyone with an email address
  • No 24-hour messaging window
  • Deliverable via any SMTP provider

Limitations

  • No inline text inputs — editable fields require a separate web form
  • Email clients may block images; use plain CSS styling
  • Action links can be forwarded and clicked by anyone — tokens must be single-use

Signed Token Design

The token in each action URL must be:

// Token payload (sign with HMAC-SHA256 using a per-tenant secret)
{
    "resId":     "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "action":    "approve",      // "approve" | "reject"
    "expiresAt": 1748000000,     // Unix timestamp
    "nonce":     "x8k2pQ"        // random 6-char nonce for single-use tracking
}

// URL format
https://your-domain/hil/action?t={base64url(payload)}.{hmac}

Building the Email

private string BuildHilEmail(
    IReadOnlyList<ResolvedHilField> fields,
    string approveUrl,
    string rejectUrl)
{
    var rows = new StringBuilder();
    foreach (var field in fields)
    {
        if (field.DisplayMode == HilDisplayMode.Concealed) continue;
        string val = field.DisplayMode == HilDisplayMode.ReadableMasked
            ? "••••••••"
            : HtmlEncode(field.CurrentValue?.ToString() ?? "—");

        string editNote = field.InputMode != HilInputMode.Locked
            ? "<span style='color:#f59e0b'> (editable via form)</span>"
            : "";

        rows.Append($@"
            <tr>
              <td style='padding:8px 16px;font-weight:600;color:#94a3b8;white-space:nowrap'>
                {HtmlEncode(field.Label)}
              </td>
              <td style='padding:8px 16px;color:#e2e8f0'>
                {val}{editNote}
              </td>
            </tr>");
    }

    return $@"
    <!DOCTYPE html>
    <html>
    <body style='font-family:Segoe UI,sans-serif;background:#0f1117;color:#e2e8f0;padding:32px'>
      <div style='max-width:600px;margin:0 auto;background:#1a1d27;border-radius:12px;overflow:hidden'>
        <div style='background:#6c8cff;padding:20px 28px'>
          <h2 style='margin:0;color:#fff'>Action Required — Approval</h2>
        </div>
        <div style='padding:24px 28px'>
          <p style='color:#94a3b8'>Please review the following and take action:</p>
          <table style='width:100%;border-collapse:collapse;margin:16px 0'>
            {rows}
          </table>
          <div style='margin-top:24px;display:flex;gap:12px'>
            <a href='{approveUrl}'
               style='background:#34d399;color:#000;padding:12px 28px;border-radius:6px;
                      text-decoration:none;font-weight:700;display:inline-block'>
              Approve ✅
            </a>
            <a href='{rejectUrl}'
               style='background:#f87171;color:#000;padding:12px 28px;border-radius:6px;
                      text-decoration:none;font-weight:700;display:inline-block;margin-left:12px'>
              Reject ❌
            </a>
          </div>
          <p style='color:#4a5568;font-size:12px;margin-top:20px'>
            This link expires in 72 hours. Do not forward this email.
          </p>
        </div>
      </div>
    </body>
    </html>";
}

Resume Endpoint

[HttpGet("hil/action")]
public async Task<IActionResult> HandleEmailAction([FromQuery] string t)
{
    // 1. Parse and verify the signed token
    if (!_tokenVerifier.TryVerify(t, out var tokenData))
        return BadRequest("Invalid or expired token.");

    // 2. Check single-use nonce (prevent replay)
    if (await _nonceStore.IsConsumedAsync(tokenData.Nonce))
        return BadRequest("This action has already been recorded.");
    await _nonceStore.ConsumeAsync(tokenData.Nonce);

    // 3. Build response data
    var responseData = new Dictionary<string, object>
    {
        ["approved"]   = tokenData.Action == "approve",
        ["actionedAt"] = DateTime.UtcNow
    };

    // 4. Resume the workflow
    await _continuationOrchestrator.ContinueAsync(tokenData.ResId, responseData);

    // 5. Show a confirmation page (not a raw 200 — the human's browser is here)
    return View("HilConfirmation", new { Approved = tokenData.Action == "approve" });
}

Editable Fields — Web Form Pattern

For fields with RequiredFromHuman or EditableOptional, add a third "Edit & Submit" button in the email that links to a hosted web form. The form URL contains a signed token (different from the action token — this one doesn't commit an action immediately). The form pre-populates editable fields and submits to a POST endpoint that calls ContinueAsync.

Required Configuration

Config FieldValue
SmtpHost@{env:SMTP_HOST}
SmtpPort@{env:SMTP_PORT}
SmtpUser@{secret:SmtpUser}
SmtpPassword@{secret:SmtpPassword}
FromAddress@{env:HIL_EMAIL_FROM}
RecipientEmail@{input:approverEmail}
TokenSigningKey@{secret:HilEmailTokenKey}
TokenExpiryHours72 (or configured per workflow)
Single-use enforcement is critical Without nonce consumption, an approver can click "Approve" multiple times, or someone who received a forwarded email can act on it. Always mark the nonce as consumed atomically (within a database transaction) before calling ContinueAsync.