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
- Settings → Email intake shows the agency's unique address, e.g.
harbor-x9f2k@inbound.pmfriend.com. Permanent — won't change. - 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).
- 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:
| Confidence | Outcome | What the PM sees |
|---|---|---|
>= 0.85 (HIGH) | DRAFT created | Top of inbox; one-click promote |
0.5 – 0.85 (MEDIUM) | DRAFT with "Needs review" badge | Surfaced for PM scrutiny |
< 0.5 (LOW) | NOT_MAINTENANCE | Archived; 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:
- Per-agency daily rate limit hit — see "Cost guards" below.
- Transport failure — timeout, 5xx, unparseable response. Same crude circuit breaker pattern as the maintenance triage Claude path: 3 consecutive failures → 60-second skip window.
- API key missing — degrades silently if
ANTHROPIC_API_KEYisn'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)));
| Setting | Value | Why |
|---|---|---|
| Per-agency daily cap | 100 emails | Below this, Claude classifies. Above, fall back. |
| Worst case per agency | ~$0.05/day at Haiku 4.5 prices | Bounded — even a flooded mailing list can't escalate. |
| Fallback above cap | Heuristic | Emails 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-Toheaders 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
documentstable 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) andInboundEmailWebhookController.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}