Building a Domain-Isolated Tenant Payment Portal with Lambda-Backed Email Forwarding
What Was Done
We built a complete tenant management portal at 3028fiftyfirststreet.92105.dangerouscentaur.com with these core features:
- Tenant credential distribution via SES from a dedicated
dangerouscentaur.comdomain inbox - A receipt logging system that accepts Zelle payment confirmations forwarded via email
- Automatic parsing and logging of payment emails into a centralized receipts ledger
- Complete domain isolation from
queenofsandiego.comto prevent cross-domain confusion and reputation issues
The core problem: sending tenant credentials through a domain they don't recognize (queenofsandiego.com) creates trust issues and operational friction. The solution: build a self-contained payment and credential workflow entirely within the dangerouscentaur.com namespace, backed by Lambda functions and Google Apps Script handlers.
Technical Architecture
Frontend: The Tenant Hub
The tenant portal lives at /Users/cb/Documents/repos/sites/dangerouscentaur/demos/3028fiftyfirststreet.92105.dangerouscentaur.com/index.html and serves as a single-page dashboard. Key sections include:
- Credentials display: Shows tenant login credentials generated with SHA-256 hashes (passwords stored client-side only for initial setup)
- Receipts section: Dynamically loads from a centralized
receipts.jsonfile stored in S3 - Payment logging interface: Form UI that triggers backend Lambda functions
The hub is deployed to S3 at a bucket managed by dangerouscentaur infrastructure and served through CloudFront for caching and HTTPS termination.
Backend: Lambda Functions
Two Lambda functions handle the payment workflow:
- lambda-receipt-action (
/scripts/lambda-receipt-action/lambda_function.py): Receives HTTP POST requests with payment details. Validates requests using anADMIN_TOKENenvironment variable. Appends new payment records toreceipts.jsonin S3. - lambda-email-parser (
/scripts/lambda-email-parser/lambda_function.py): New function created to parse forwarded Zelle emails. Extracts payment amount, sender, and timestamp. Calls the receipt-action Lambda with admin credentials to log the payment.
Both functions are deployed as standalone Lambda functions with function URLs, allowing direct HTTPS invocation without API Gateway overhead. The receipt-action Lambda is configured with:
- Resource-based policy allowing SES to invoke it
ADMIN_TOKENenvironment variable for internal authentication- S3 permissions to read/write
receipts.json
Email Forwarding Pipeline: Google Apps Script
The WarmLeadResponder.gs script (in the queenofsandiego.com repo, but with new tenant-specific handlers) has been extended to handle the 3028 property workflow:
- Email trigger: When an email arrives at a monitored inbox alias (e.g.,
payments@dangerouscentaur.com), GAS captures it - Zelle detection: Script regex-matches Zelle payment confirmations
- Lambda invocation: On match, calls the lambda-email-parser function with the forwarded email body
- Logging: Payment gets recorded in receipts.json and visible on the tenant hub within minutes
This creates a fully automated pipeline: tenant sends Zelle → bank sends confirmation email to your mail account → you forward to payments@dangerouscentaur.com → GAS parses it → Lambda logs it → tenant sees it in the hub.
Infrastructure Details
Domain and Email Setup
Critical decision: use a dedicated dangerouscentaur.com SES sender identity instead of queenofsandiego.com. This requires:
- SES domain verification: DKIM tokens added to Namecheap DNS for
dangerouscentaur.com - ImprovMX aliases: Created inbox aliases like
payments@dangerouscentaur.comthat forward to your personal mailbox - SPF/DKIM records: Proper DNS configuration prevents emails from landing in spam
Command to verify domain in SES:
aws ses verify-domain-identity --domain dangerouscentaur.com --region us-west-2
Then retrieve DKIM tokens and add them to your DNS provider (Namecheap in this case).
S3 and CloudFront
The tenant hub index.html and receipts.json are stored in an S3 bucket. CloudFront distribution caches the static content with invalidation on updates:
- S3 bucket: Houses both
index.htmlandreceipts.json - CloudFront distribution ID: Cached for quick invalidation after deploys
- Invalidation pattern: After updating files, invalidate
/*to clear all cached objects
Example invalidation command:
aws cloudfront create-invalidation --distribution-id DISTRIBUTION_ID --paths "/*" --region us-west-2
Key Decisions and Rationale
Why Lambda over API Gateway? Function URLs are simpler, cheaper, and don't require managing API Gateway resources. Since the receipt-action endpoint is internal-only (protected by ADMIN_TOKEN), we don't need Gateway's request validation or rate limiting features.
Why Apps Script for email parsing? GAS integrates natively with Gmail and Google Sheets, making it trivial to monitor an inbox and trigger webhooks. It's also free and requires minimal operational overhead compared to an SNS-to-SQS-to-Lambda pipeline.
Why separate from queenofsandiego.com? Domain reputation and SPF/DKIM records are sender-specific. Using queenofsandiego.com for dangerouscentaur property emails created brand confusion and mail deliverability risk. Tenants receiving mail from an unrelated real estate domain raises red flags. Separation ensures each property/domain can manage its own email reputation independently.
Why receipts.json instead of a database? For small volumes (a few payments per month per property), a JSON file is simpler and cheaper than running DynamoDB or RDS. S3 versioning provides audit trails, and CloudFront caching keeps read latency low. As volume grows, migration to DynamoDB is straightforward.
Deployment Process
Updated hub HTML deployed to S3:
aws s3 cp index.html s3://bucket-name