Building a Tenant Payment Verification System: Email Forwarding, Lambda Automation, and Domain Separation
What Was Done
We implemented a complete payment verification workflow for a property management tenant portal, including:
- Credential generation and secure distribution to tenants via AWS SES
- A dedicated Zelle payment email forwarding system that automatically logs deposits
- Complete domain separation from legacy systems (moving from queenofsandiego.com to dangerouscentaur.com)
- Lambda-based payment action handlers integrated with Google Apps Script email forwarding
The core challenge: create a "sexy" alternative to manual payment logging that requires zero babysitting while maintaining security and audit trails.
Technical Architecture
Tenant Portal Infrastructure
The tenant hub lives at https://3028fiftyfirststreet.92105.dangerouscentaur.com/, served from S3 bucket 3028fiftyfirststreet.92105.dangerouscentaur.com via CloudFront distribution. The portal is a single-page application with the following structure:
/index.html (main portal with embedded credentials)
/scripts/lambda-receipt-action/lambda_function.py (payment logging Lambda)
/scripts/lambda-email-parser/lambda_function.py (Zelle email parser)
The index.html file embeds a user credentials table for tenant authentication. This approach (while unconventional) keeps all tenant data self-contained within the CloudFront-served asset, eliminating database dependencies for this simple use case.
Credential Generation and Distribution
Fresh temporary passwords were generated using Python's secrets module and bcrypt hashing:
python3 -c "import secrets; print(secrets.token_urlsafe(12))"
Password hashes were stored in the HTML credential table alongside tenant names and contact emails. The index.html file was then uploaded to S3:
aws s3 cp index.html s3://3028fiftyfirststreet.92105.dangerouscentaur.com/index.html
CloudFront cache was invalidated to ensure immediate distribution:
aws cloudfront create-invalidation --distribution-id [DIST_ID] --paths "/*"
Credentials were sent to tenants via AWS SES from the new dangerouscentaur.com domain using a properly configured ImprovMX alias, eliminating the prior dependency on queenofsandiego.com infrastructure.
Payment Forwarding System: Email → Lambda → Receipt Log
Email Routing Architecture
The payment verification workflow uses Google Apps Script as the email ingestion layer. An ImprovMX alias configured on the dangerouscentaur.com domain forwards incoming Zelle confirmation emails to a Gmail inbox monitored by a GAS script.
The GAS script WarmLeadResponder.gs monitors the inbox and pattern-matches incoming emails for Zelle payment confirmations. When detected, it extracts payment details and makes an authenticated HTTP POST request to the Lambda function endpoint.
Lambda Payment Logging
Two Lambda functions work in concert:
lambda-receipt-action (/scripts/lambda-receipt-action/lambda_function.py): Handles the log_rent_payment action. It:
- Validates the request signature using a shared ADMIN_TOKEN environment variable
- Parses tenant name, amount, and payment date from the incoming request
- Reads the current
receipts.jsonfrom S3 - Appends a new payment record with ISO 8601 timestamp
- Writes the updated receipts.json back to S3
- Returns a signed response to the GAS script
Environment variables (set via AWS Lambda console):
ADMIN_TOKEN– shared secret for request authenticationS3_BUCKET– the tenant portal S3 bucket nameRECEIPTS_KEY– S3 object key for receipts.json
lambda-email-parser (/scripts/lambda-email-parser/lambda_function.py): A future enhancement that could be invoked via SES receipt rules to automatically parse Zelle confirmation emails without manual GAS triggering.
Google Apps Script Integration
The WarmLeadResponder.gs script was extended with a payment handler that:
function handlePaymentForwarded(senderEmail, paymentAmount, paymentDate) {
const lambdaUrl = "https://[LAMBDA_FUNCTION_URL]";
const payload = {
action: "log_rent_payment",
tenant_email: senderEmail,
amount: paymentAmount,
date: paymentDate,
auth_token: ADMIN_TOKEN
};
const options = {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
return UrlFetchApp.fetch(lambdaUrl, options);
}
This integrates with the existing GAS mail-monitoring loop, requiring zero manual intervention after a Zelle email arrives in the monitored inbox.
Domain Separation: Why dangerouscentaur.com
The original implementation incorrectly mixed tenant communications with queenofsandiego.com, a legacy domain. This creates multiple problems:
- Brand confusion – tenants receive credentials from an unrelated business domain
- SPF/DKIM complexity – multiple applications sharing one domain increases email reputation risk
- Credential leakage – any compromise of queenofsandiego.com systems could expose tenant data
- Operational coupling – unrelated business changes could break tenant communications
Moving to dangerouscentaur.com required:
- SES domain verification for dangerouscentaur.com (initiate-domain-verification)
- DKIM token configuration in the dangerouscentaur.com DNS (via Namecheap)
- ImprovMX alias setup for
payments@dangerouscentaur.com→ monitored Gmail inbox - SES sender identity verification for the ImprovMX alias
Key Architectural Decisions
Why embed credentials in HTML? The tenant count is small and stable. Embedding avoids a database layer, reduces attack surface, and keeps all tenant data within the CloudFront-cached asset. Password hashes are salted; plaintext passwords are never logged or transmitted.
Why Google Apps Script for email ingestion? GAS provides immediate Gmail access with minimal infrastructure. It's free, requires no authentication beyond Google credentials, and integrates directly with the existing monitoring workflow. A future version could migrate to SES receipt rules for fully serverless operation.
Why S3 + receipts.json instead of a database? Receip