Email Notifications
SalesArck sends transactional emails via the Resend REST API. Implementation is in apps/api/src/lib/email.ts.
Architecture
- Provider: Resend (REST API, no SDK dependency — uses plain
fetch) - Pattern: Fire-and-forget — failures are logged but never throw or block the caller
- Configuration: Optional — if
RESEND_API_KEYis not set, all sends are silently skipped
Configuration
| Variable | Required | Description |
|---|---|---|
RESEND_API_KEY | For email sends | Resend API key |
RESEND_FROM_EMAIL | No | Sender address (default: SalesArck <noreply@salesarck.io>) — must be a verified Resend domain |
Email Templates
All templates use a shared baseLayout() function that produces responsive HTML email with:
- SalesArck branded header (indigo #4f46e5)
- Content area (white background)
- Footer with unsubscribe context
- Mobile-safe table layout
Points Earned
Trigger: After creditWallet() successfully credits points to a consumer's wallet.
Sent by: sendPointsEarnedEmail() — called fire-and-forget from the ingestion pipeline.
Content:
- Points earned (e.g., "+15 pts")
- Merchant name
- Purchase amount (formatted with Intl.NumberFormat)
- Two-column: points earned vs new balance
- CTA: "View my wallet" link to
https://salesarc-web.vercel.app/wallet
Subject: +{points} pts at {tenantName}
Redemption Code
Trigger: After consumer confirms a redemption via POST /consumer/wallets/:walletId/redeem/confirm.
Sent by: sendRedemptionCodeEmail() — called from the consumer redemption route.
Content:
- 6-character redemption code (large monospace display)
- 15-minute expiry window
- Two-column: points redeemed vs discount value
- Security notice ("If you didn't request this...")
Subject: Your redemption code: {code}
Wallet Frozen
Trigger: After admin freezes a consumer's wallet via POST /admin/wallets/:walletId/freeze.
Sent by: sendWalletFrozenEmail() — called from the admin wallet freeze route.
Content:
- Notification that wallet has been temporarily frozen
- Optional reason
- Contact support message
Subject: Your SalesArck wallet has been frozen
Integration Points
Ingestion Pipeline
In services/ingestion.ts, after a successful wallet credit:
creditWallet() → success
↓
Async IIFE (non-blocking):
1. Fetch user email from users table
2. Fetch tenant name from tenants table
3. Fetch updated wallet balance
4. sendPointsEarnedEmail({ to, points, tenantName, amountMinorUnits, currency, newBalance })
Email lookup failures are caught and logged — they never affect the transaction outcome.
Consumer Redemption
In routes/consumer.ts, after redemption confirmation:
Wallet debit → Generate 6-char code → sendRedemptionCodeEmail()
Admin Actions
In routes/admin.ts, wallet freeze triggers:
Set wallet status → Audit log → sendWalletFrozenEmail()
Operational Behavior
- If
RESEND_API_KEYis not set: all email functions return immediately with a debug log - If Resend returns a non-2xx status: error is logged with recipient and status code, but execution continues
- Successful sends are logged at
infolevel with recipient and subject - No retry logic for failed sends (fire-and-forget pattern)
- PII redaction in logs prevents email addresses from appearing in standard log output (handled by
@salesarc/observability)