Building a Tenant Payment Portal with Lambda-Backed Email Integration
We built a complete tenant management system for a rental property, including a secure credential delivery system and an automated payment logging pipeline that hooks into the existing email infrastructure. This post walks through the architecture, deployment strategy, and integration patterns we used.
What We Built
The core requirement was threefold:
- Deploy a tenant hub portal at
3028fiftyfirststreet.92105.dangerouscentaur.comwith secure credential delivery - Create a payment logging UI that's better than manual chat entry
- Wire Zelle payment emails to auto-log in the system via email forwarding
The system needed to be completely isolated to the dangerouscentaur.com domain—no cross-domain email or credential leakage to queenofsandiego.com.
Portal Architecture
The tenant hub lives in S3 with CloudFront distribution and is built as a single-page application with embedded authentication. Here's the file structure:
/Users/cb/Documents/repos/sites/dangerouscentaur/demos/
3028fiftyfirststreet.92105.dangerouscentaur.com/
index.html # Main hub with embedded credentials
scripts/
lambda-receipt-action/ # Payment logging endpoint
lambda_function.py
lambda-email-parser/ # Zelle email inbound handler
lambda_function.py
The index.html is deployed to S3 at the 3028-51st-tenant-hub bucket and served through CloudFront distribution (specific distribution ID tracking in ops docs). We generate fresh temporary passwords at deployment time and embed them in the HTML—they're only visible to the tenant initially, forcing a password reset on first login.
Credential Generation and Delivery
We generate tenant credentials using a standard password hashing approach:
# Generate temp password
import secrets
import hashlib
temp_password = secrets.token_urlsafe(16)
password_hash = hashlib.sha256(temp_password.encode()).hexdigest()
Credentials are embedded in the portal HTML at deployment, then sent to tenants via AWS SES from a verified dangerouscentaur.com sender address. We set up ImprovMX aliases to handle noreply@dangerouscentaur.com with proper SPF/DKIM records—this prevents the scary "authentication failed" errors that would occur with cross-domain mail.
The deployment flow:
- Update
index.htmlwith new credentials - Upload to S3:
s3://3028-51st-tenant-hub/index.html - Invalidate CloudFront cache (wildcard pattern
/*) - Send credential emails via SES from authenticated
dangerouscentaur.comdomain
Payment Logging System
Rather than typing payment entries manually, we built a two-layer system:
Layer 1: Web UI in the Portal
The tenant hub includes a receipts section that calls the Lambda endpoint when logging a payment. The frontend makes authenticated POST requests to the Lambda function URL.
Layer 2: Email-to-Payment Pipeline
The real innovation: forward your Zelle confirmation emails to a Lambda-backed inbox, and they auto-log as payments.
We created two Lambda functions:
lambda-receipt-action: Handles payment logging with an admin token (environment variableADMIN_TOKEN)lambda-email-parser: Receives forwarded bank emails and extracts payment data
The receipt-action Lambda accepts POST requests:
POST /receipts/log_payment
{
"tenant_id": "tenant-123",
"amount": 2000.00,
"method": "zelle",
"date": "2024-01-15",
"admin_token": "..."
}
The function validates the ADMIN_TOKEN environment variable, logs the entry to a receipts.json file in S3, and returns a signed confirmation. We store receipts at s3://3028-51st-tenant-hub/data/receipts.json.
Zelle Email Integration
The email parser Lambda is invoked by SES receipt rules. When you forward a Zelle confirmation email to payment-log@dangerouscentaur.com, it triggers the Lambda with the email body.
The parser:
- Extracts amount, date, and reference info from email headers/body
- Matches against known tenant metadata
- Calls the receipt-action Lambda with the extracted data and admin token
- Logs the result back to CloudWatch
In Google Apps Script (WarmLeadResponder.gs), we added a command handler to catch incoming emails and route them appropriately. The key function:
function handlePaymentEmail(messageData) {
const payload = extractZelleData(messageData);
const adminToken = getAdminToken(); // From repos.env
const response = UrlFetchApp.fetch(
'https://lambda-url.../receipts/log_payment',
{
method: 'post',
payload: JSON.stringify(payload),
headers: { 'X-Admin-Token': adminToken }
}
);
return response;
}
Infrastructure Details
S3 Buckets:
3028-51st-tenant-hub: Portal assets and data files- Bucket policy: Allow CloudFront OAI to read, allow Lambda role to read/write
CloudFront Distribution:
- Origin: S3 bucket with origin access identity
- Cache behaviors: 24-hour TTL for HTML, longer for static assets
- We manually invalidate
/*after deployments to force immediate refresh
Lambda Deployments:
- Both functions deployed with function URLs enabled (public, no auth required for receipt-action—auth happens via token header)
- IAM role includes S3 read/write permissions for the tenant hub bucket
- Environment variables:
ADMIN_TOKEN,RECEIPTS_BUCKET,TENANT_METADATA_PATH
SES Configuration:
- Domain verified:
dangerouscentaur.com - Sender address:
noreply@dangerouscentaur.com(via ImprovMX alias) - Receipt rule: Forward emails to
payment-log@dangerouscentaur.comto Lambda