Skip to main content

Email-to-Maintenance Intake

Tenants don't need to learn a new address or install an app. They email your existing support inbox the way they always have. PMFriend classifies each message and surfaces a one-click "promote to request" flow inside the admin.

What it does

tenant emails support@your-agency.com.au

│ (one-time forward rule on your existing inbox)

<your-agency-token>@inbound.pmfriend.com

│ AWS SES inbound → S3 → SNS → backend webhook

PMFriend ingests
├── resolves which agency owns the address
├── matches sender's email to a tenant on file
├── classifies via Claude (or heuristic fallback)
└── persists with one of three outcomes:

DRAFT tenant matched + maintenance issue
UNRESOLVED sender unknown — PM attaches manually
NOT_MAINTENANCE tenant matched + non-maintenance content
(rent query, lease admin, etc.)

The PM sees DRAFT and UNRESOLVED items in the Email inbox tab inside Maintenance. One click promotes a DRAFT to a real maintenance request; attaching a tenant to an UNRESOLVED email also remembers the sender's address for next time so future mail from the same address auto-resolves.

How a PM sets it up

  1. Settings → Email intake shows the agency's unique address, e.g. harbor-x9f2k@inbound.pmfriend.com. Permanent — won't change.
  2. The PM adds a forwarding rule on their existing support inbox (Office 365, Gmail Workspace, or cPanel — copy-paste instructions are on the same Settings panel).
  3. From this point on, every email arriving at the agency's existing support address gets forwarded to the PMFriend intake address and processed.

Tenants change nothing.

What the AI does

For every inbound email, the classifier produces structured JSON:

{
"isMaintenance": true,
"confidence": 0.92,
"category": "PLUMBING",
"urgency": "URGENT",
"suggestedScope": "Hot water service banging noise plus no hot water — dispatch plumber for diagnostic + likely thermostat replacement",
"extractedLocation": "hot water unit",
"classifierName": "claude-haiku-4-5@prompt-v1"
}

Three confidence buckets drive the UI:

ConfidenceOutcomeWhat the PM sees
>= 0.85 (HIGH)DRAFT createdTop of inbox; one-click promote
0.5 – 0.85 (MEDIUM)DRAFT with "Needs review" badgeSurfaced for PM scrutiny
< 0.5 (LOW)NOT_MAINTENANCEArchived; only visible in the "Other" filter

Two classifiers, one port

Both implementations conform to the same InboundEmailClassifier port. The active one is selected by app.inbound-email.provider:

email-heuristic-v0 (default)

Pure keyword matching — no LLM spend, runs entirely in-process. Hits on words like "leak", "no hot water", "no power", "smell of gas". Fast, deterministic, and free, but capped at MEDIUM confidence by design — keyword matching can't read intent. Always available as a fallback even when Claude is enabled.

claude-haiku-4-5@prompt-v1 (when provider=claude)

Calls Anthropic Claude with a small typed prompt. Produces meaningful HIGH-confidence classifications and far better suggestedScope strings (specific, contractor-ready). Spend is bounded by a per-agency rate limit (see below).

The Claude adapter falls back to the heuristic on three independent failure modes:

  1. Per-agency daily rate limit hit — see "Cost guards" below.
  2. Transport failure — timeout, 5xx, unparseable response. Same crude circuit breaker pattern as the maintenance triage Claude path: 3 consecutive failures → 60-second skip window.
  3. API key missing — degrades silently if ANTHROPIC_API_KEY isn't set; logs once at startup.

Fallback emissions are tagged heuristic-fallback-<reason> in the classifierName field of the saved classification, so audit logs can distinguish "we used Claude" from "we wanted to use Claude but couldn't".

Cost guards

Every Claude call is gated by the rate limiter at scope inbound-email-classify-daily, keyed on agency_id.

// AbuseProtectionConfig.java
scopes.put("inbound-email-classify-daily",
new Bucket4jRateLimiter.ScopeSpec(100, Duration.ofDays(1)));
SettingValueWhy
Per-agency daily cap100 emailsBelow this, Claude classifies. Above, fall back.
Worst case per agency~$0.05/day at Haiku 4.5 pricesBounded — even a flooded mailing list can't escalate.
Fallback above capHeuristicEmails still get classified; just less precise. Status assignment still works (DRAFT / UNRESOLVED / NOT_MAINTENANCE).

To raise / lower the cap, edit the ScopeSpec and redeploy. Bucket4j tokens reset on the same daily window per agency.

The cap is shared across the whole agency — not per-PM, not per-property. "This agency has classified 100 emails today" is the bar.

Privacy

Only the email subject and plaintext body are sent to Anthropic. Specifically NOT sent:

  • Sender email address (lives in our DB only)
  • Sender name
  • Recipient address (the agency's intake token)
  • Tenant or agency identifiers
  • Any attachments — bytes never leave the bucket

The body sent is truncated to ~4000 chars (~1000 tokens) so a forwarded 50-message thread doesn't blow the prompt budget. The truncation marker is […truncated].

This matches the privacy posture of the other Claude features (see Fallbacks and Privacy).

What's deferred to v2

These are deliberately out of scope for v1 to keep the surface area small. Each has a roadmap entry in Product Roadmap §8a.

  • Threading — matching In-Reply-To headers so a follow-up email ("AC still not working") attaches to the existing maintenance request instead of creating a new draft each time.
  • Two-way reply from app — PM clicks Reply on a draft, message goes out from the intake address, tenant's reply lands back in the same thread.
  • Auto-confirm reply to tenant — automated "we got your report" responses. Risks include feedback loops with auto-responders and doubled outbound volume; held until the manual flow proves it.
  • Per-tenant intake addresses (Pattern A from the design brainstorm) — useful as a Scale-tier feature for agencies that want sender verification baked into the address.
  • Photos auto-attached as documents — the MIME parser extracts attachment metadata today, but linking to the existing documents table is one extra wiring step.

Operational runbook

AWS infrastructure setup (SES domain identity, S3 bucket, SNS topic, receipt rule, DNS records) is documented in deploy/inbound-email-runbook.md.

Code locations

  • Domain — backend/modules/domain/src/main/java/au/com/pmfriend/domain/inboundemail/
  • Use cases — backend/modules/application/src/main/java/au/com/pmfriend/application/inboundemail/
  • Heuristic classifier — infra/ai/HeuristicInboundEmailClassifier.java
  • Claude classifier — infra/ai/ClaudeInboundEmailClassifier.java
  • Persistence — infra/persistence/inboundemail/
  • HTTP — web/inboundemail/InboundEmailController.java (PM-facing) and InboundEmailWebhookController.java (SNS subscription endpoint)
  • Migration — infra/.../db/migration/V23__email_intake.sql

Configuration reference

app:
inbound-email:
provider: ${APP_INBOUND_EMAIL_PROVIDER:heuristic} # or "claude"
domain: ${APP_INBOUND_EMAIL_DOMAIN:inbound.pmfriend.com}
bucket: ${APP_INBOUND_EMAIL_BUCKET:} # SES → this S3 bucket
region: ${APP_INBOUND_EMAIL_REGION:ap-southeast-2}
sns-topic-arn: ${APP_INBOUND_EMAIL_SNS_TOPIC_ARN:} # webhook validates
http-timeout-ms: ${APP_INBOUND_EMAIL_HTTP_TIMEOUT_MS:6000}