Building a Self-Contained Tenant Payment Portal with Email-to-Lambda Automation

We recently completed a significant infrastructure refactor for a rental property management system, creating a completely independent tenant portal on the dangerouscentaur.com domain with automated payment logging via email forwarding. This post details the technical decisions, architecture patterns, and deployment strategy used to achieve a production-ready system that requires minimal ongoing babysitting.

The Problem Statement

The original system had several critical issues:

  • Tenant credentials and communications were originating from queenofsandiego.com, creating domain confusion and trust issues
  • Payment logging required manual data entry into a chat interface—error-prone and unscalable
  • No mechanism to automatically capture and record Zelle payments forwarded from the property manager's bank
  • The tenant portal was tightly coupled to an unrelated domain, violating separation of concerns

Architecture Overview

The solution implements a multi-layered serverless architecture centered on the tenant hub at 3028fiftyfirststreet.92105.dangerouscentaur.com:

  • Frontend Layer: Static HTML tenant portal hosted on S3 with CloudFront distribution
  • Lambda Functions: Two specialized functions handling receipt logging and email parsing
  • Email Pipeline: SES for outbound credentials + Google Apps Script for inbound payment email handling
  • Data Storage: S3-hosted receipts.json as the source of truth for payment records
  • Domain Isolation: All communications and credentials now originate from dangerouscentaur.com via ImprovMX aliases

Step 1: Portal Regeneration and Credential Management

The first task was regenerating the tenant hub with fresh credentials isolated to the dangerouscentaur.com domain.

File Updates:

  • /Users/cb/Documents/repos/sites/dangerouscentaur/demos/3028fiftyfirststreet.92105.dangerouscentaur.com/index.html — Regenerated with fresh temporary passwords and updated credential table
  • /Users/cb/Documents/repos/agent_handoffs/projects/3028_51st_rental.md — Updated project documentation with new credentials and system architecture

Passwords were generated using cryptographically secure methods and hashed before storage. The index.html file contains the credential table used by the authentication system, deployed to S3 bucket dc-sites and cached through CloudFront distribution E2MTVN3QNTH7HV.

After updating the portal HTML, we invalidated the CloudFront cache to ensure tenants received the updated version immediately:

aws cloudfront create-invalidation \
  --distribution-id E2MTVN3QNTH7HV \
  --paths "/*"

Step 2: SES Domain Configuration and Credential Distribution

To eliminate domain confusion, we configured dangerouscentaur.com as a verified SES sender domain. This required:

  • Initiating domain verification through AWS SES console
  • Setting up DKIM tokens in the domain's DNS (handled through Namecheap)
  • Configuring ImprovMX aliases for professional inbox management

The email delivery pipeline was implemented as follows:

# Sending tenant credentials via SES
aws ses send-email \
  --from "noreply@dangerouscentaur.com" \
  --to [tenant-email] \
  --subject "Your Tenant Portal Credentials" \
  --html "[HTML body with credentials]"

Both tenants received their credentials from the new dangerouscentaur.com domain, establishing a clear brand/domain boundary.

Step 3: Lambda Functions for Payment Logging

Two specialized Lambda functions were created to handle different aspects of the payment pipeline:

Receipt Action Lambda (lambda-receipt-action/lambda_function.py)

This function serves as the primary API endpoint for payment logging. It implements an admin token-based authentication system to prevent unauthorized payment entries:

def lambda_handler(event, context):
    # Validate admin token from environment
    provided_token = event.get('headers', {}).get('Authorization', '')
    if provided_token != f"Bearer {os.environ['ADMIN_TOKEN']}":
        return {'statusCode': 401, 'body': 'Unauthorized'}
    
    action = event.get('action')
    
    if action == 'log_rent_payment':
        # Process Zelle payment entry
        tenant_id = event['tenant_id']
        amount = event['amount']
        payment_date = event['payment_date']
        
        # Update receipts.json in S3
        update_receipts(tenant_id, amount, payment_date)
        return {'statusCode': 200, 'body': 'Payment logged'}

The function reads and writes to s3://dc-sites/receipts.json, maintaining the authoritative payment record. The function is deployed as a Lambda Function URL for direct HTTPS access, eliminating the need for API Gateway infrastructure.

Email Parser Lambda (lambda-email-parser/lambda_function.py)

This function is triggered by SES receipt events and parses inbound Zelle payment confirmations:

def parse_zelle_confirmation(email_body):
    """Extract payment amount and date from Zelle email"""
    # Regex patterns to match typical Zelle confirmation text
    amount_match = re.search(r'\$[\d,]+\.\d{2}', email_body)
    date_match = re.search(r'\d{1,2}/\d{1,2}/\d{4}', email_body)
    
    if amount_match and date_match:
        return {
            'amount': amount_match.group(),
            'date': date_match.group()
        }
    return None

This function is wired into the SES receipt rule set, automatically triggering when forwarded Zelle emails arrive at a designated ImprovMX alias.

Step 4: Google Apps Script Command Handler

The existing Google Apps Script infrastructure in WarmLeadResponder.gs was extended with payment processing logic. The command handler now intercepts forwarded Zelle emails and calls the receipt-action Lambda:

function executeAdminCommand(command, tenant_id, details) {
  if (command === 'log_rent_payment') {
    const payload = {
      action: 'log_rent_payment',
      tenant_id: tenant_id,
      amount: details.amount,
      payment_date: details.date
    };
    
    const options = {
      method: 'post',
      headers: {
        'Authorization': `Bearer ${ADMIN_TOKEN}`
      },
      payload: JSON.stringify(payload)
    };
    
    UrlFetchApp.fetch(RECEIPT_LAMBDA_URL, options);
  }
}

The ADMIN_TOKEN environment variable is stored in a secured project property and matches the Lambda environment variable set during deployment.