Triggered by Fede's questions while live-testing the new escalation emails: "can't we use SendGrid for both outbound and inbound?", "what's the architecture?", and "it does go to spam a lot — how else can we improve our spam score?" Researched by three parallel agents over every ADR (157, incl. GitHub's closed-PR record), the full git/PR history, and all retained session transcripts, plus live checks of DNS, the SES account, and the receiving rules tonight.
The foundation was laid in March 2026, before the team had PRs or decision records — this page is the first document that reasons about it as a whole. Email today is three receiving rails and two sending transports with no single chokepoint. The most fragile link is a mail-forwarding hop through Clara's Google mailbox that adds 15–35 seconds of latency, is configured by hand where no code review can see it, and has no alarm. The most urgent risks are not the rails but the trust layer: the domain's anti-spoofing policy is monitor-only (hurts spam placement, blocks the inbox logo), a held security P0 leaves the property-mailbox rail with zero sender authentication, and one send path bypasses the do-not-contact list. Recommended: fix the trust layer now (deliverability program below), re-point replies onto the Amazon rail we own (Option B), and leave the big mail-host migration (Option D) for after the Camellia escalation rollout.
pre-March 2026 — A Lambda exists in AWS whose whole job
is forwarding inbound mail into a Gmail inbox. The receiving rule is named
forward-to-gmail — a name that survives to this day as a fossil.
Mar 12, 2026 — The real rail is born: that Lambda is
rewritten to POST mail into PropFlow instead of Gmail. Amazon receives at
clara@inbound.propflowai.co, archives to storage, and hands the app each message.
Built as direct commits — the repo didn't use pull requests until Mar 30.
Mar 22–25, 2026 — Three production incidents harden the rail into what it is now: a restart wiped the duplicate-guard and Clara re-sent replies to real tenants (→ durable dedup records); concurrent processing caused 78% of delivery failures (→ a strict one-at-a-time-per-sender queue); a proxy timeout caused retries (→ a third dedup layer). Each protection maps to a named incident.
Mar 26, 2026 — SendGrid becomes the sender. The recorded rationale, in full: Gmail app-passwords kept getting revoked without warning, breaking all of Clara's replies. Amazon's sending option had been deleted the day before as never-activated dead code. SendGrid-vs-Amazon for sending was never actually compared — the real contest was SendGrid vs Gmail, and Gmail lost on fragility.
Apr 17, 2026 — The first eleven ADRs, all in one day, all downstream of two incidents (a wrong-address reply and stale code re-processing old leads). Among them: the property-mailbox rail goes webhook-only and its safety-net poller is retired — partly because the poller itself caused an incident by "catching up" on a two-day-old email and answering a prospect with the wrong property. The accepted trade-off, recorded then: if the webhook misses, nothing recovers the mail. The July audit later cashed that trade-off in.
Jul 20, 2026 — Two things on one day. Fede locks the
sender-identity invariant: Clara must never email a prospect from generic
clara@propflowai.co when the property has its own connected mailbox — property-voiced
mail moves to the property's Microsoft mailbox with SendGrid as fallback. And a session measuring
demo latency finds the smoking gun: 17–40 s from send to reply, nearly all of it upstream in the
Google→Amazon forwarding hop. The investigation it dispatched died mid-session and
never reported — the question this page answers has been open since.
Jul 24, 2026 — The dropped-lead audit: 76% of 8,182 processed emails ended in silent skips; a real Zillow lead was dropped. Result: ADR-0114 rebuilds the decision layer deterministic-first with a never-drop review state. The audit also surfaces the held security P0 (see below).
Jul 29, 2026 — ADR-0119 maps sending end-to-end and concludes: "email has no chokepoint" — three entry points, two transports, two sender identities, one of the paths skipping suppression entirely.
Aug 14, 2026 — Tonight: live Willows demo runs the whole escalation loop into Fede's real inbox; AWS confirms the SES production-sending application status is DENIED (no effect on today's sending — SendGrid sends — and receiving never needed it); the deliverability audit below is run.
The third door is the problem: three code sites call the Microsoft transport directly, bypassing the shared path — and with it the do-not-contact list (the ADR-0092 finding). An opted-out tenant can still receive those emails. The escalation emails Fede reviewed tonight ride the Clara-voiced rail.
Rail 1, Clara's address.
~5 messages/day. The ⚠ hop: whoever holds the Google mailbox forwards its mail to the Amazon subdomain. It is configured inside the mailbox settings — outside code review, outside monitoring, owned by an account with a stale password — and it is where the measured 15–35 s of demo latency lives. If it breaks, replies to Clara silently stop reaching the app; no alarm exists.
Rail 2, property mailboxes (Camellia etc.): Microsoft push-notifications, webhook-only since April — no safety-net if the webhook misses, and zero sender authentication (the held P0: trust is an unanchored text match, deterministic handlers run before any AI gate, and a crafted email could file a fake move-out or unpublish listings; fix specced, held for Fede because it changes trust on Camellia's live mailbox).
Rail 3, Gmail API: a narrow read-only rail (vendor-quote polling; the sales-machine's prospect-thread capture on fede@). Predates everything else.
Name: changed tonight from "Clara by PropFlowAI" to just Clara (a coworker's email doesn't carry a product suffix). Lives in the SendGrid sender code; property-voiced mail already shows the property's own name.
Photo — how it actually works and how to set it: Gmail shows a sender's avatar from the sender's Google account. For clara@propflowai.co that means her Workspace profile photo. Setting it (done tonight, from the admin side): admin.google.com → Directory → Users → Clara → click the circular avatar → upload → save. Two honest caveats: Gmail caches sender photos and can take 24–48 h to show a new one — there is no force-refresh; and if it is still blank after that, the fallback is to set it from Clara's own account (reset her password from the admin console, log in once, set the photo at myaccount.google.com — admin-set photos occasionally populate only the directory while Gmail prefers the account's own profile photo). Recipients outside Google (Outlook etc.) don't use this mechanism at all.
Logo everywhere (BIMI): the cross-provider standard that puts a verified logo next to every email. Requires: the anti-spoofing policy at full enforcement (ours is monitor-only — the blocker), the logo as a trademarked SVG, and for Gmail a paid verification certificate (~$1,000–1,500/yr, registered trademark required). Real, but a project — the enforcement step is worth doing regardless (next section) and is the prerequisite.
Fede, tonight: "it does go to spam a lot." Audit findings, in priority order — all within our control:
| Fix | Finding & action |
|---|---|
| 1. Enforce anti-spoofing (DMARC) FEDE ENDORSED TONIGHT | Policy is "monitor only" — anyone can forge propflowai.co addresses and inboxes will deliver it; providers also score us down for it. Plan: read the reports (already flowing to fede@) for ~2 weeks, confirm all three legitimate senders pass (Google, SendGrid, Microsoft property mailboxes), then quarantine → reject. One DNS line per step. |
| 2. Un-shadow the branded-link records | Found tonight: a catch-all DNS rule points every unclaimed subdomain at the website host, and it swallows SendGrid's link-branding records — so if click-tracking is on, links go out wrapped in generic sendgrid.net (a classic spam signal: links that don't match the sender). Bounce-address record is correctly set. Fix: two explicit DNS entries — or turn click-tracking off for Clara's mail, which fits the coworker persona anyway. |
| 3. Close the suppression bypass | The direct-Microsoft send sites skip the do-not-contact list. Opted-out people who still get mail click "spam" — the most punishing signal a sender can accrue. Route those three sites through the shared path (finishes ADR-0092). |
| 4. Google Postmaster Tools | postmaster.google.com, add propflowai.co (verifies instantly for a Workspace admin). Turns "goes to spam a lot" into a measured spam rate + domain reputation we watch improve. Free. |
| 5. Keep what already helps | The plain-note redesign (text-forward, no marketing card) reads less like bulk mail to filters; property-mailbox sending borrows an established mailbox's reputation; and question-first escalation emails that earn replies actively teach Gmail the sender is wanted. |
Everything below was checked against the real records tonight, not assumed. DNS lives where the website's records do (the catch-all that answers for every unclaimed name is the giveaway). Apply in order; each step is one entry.
| # | Record | Change to | Why / proof |
|---|---|---|---|
| 1 | em8280.propflowai.co (CNAME) | u61510933.wl184.sendgrid.net |
Confirmed tonight it answers with the website host instead — the catch-all is swallowing it. This is SendGrid's sender-identity record; the exact target is shown on app.sendgrid.com → Settings → Sender Authentication (verify there before saving). |
| 2 | url8034.propflowai.co (CNAME) | sendgrid.net |
Same problem, same proof — this is the branded-link record. (Alternative that also fixes it: turn off click-tracking for Clara's mail; plain links fit the coworker persona.) |
| 3 | _dmarc.propflowai.co (TXT) | v=DMARC1; p=quarantine; pct=25; rua=mailto:fede@propflowai.co |
Currently p=none (monitor-only — forgeries still deliver). Apply ~2 weeks after step 1–2, once the reports show all three legit senders passing. Then raise pct → 100, then p=reject, watching Postmaster between steps. |
| — | postmaster.google.com → add propflowai.co | Not DNS — two clicks as a Workspace admin, verifies instantly. Gives us a measured spam rate instead of "it goes to spam a lot". | |
Healthy already, no action: DKIM keys (s1/s2._domainkey) point at SendGrid correctly; MX stays Google; bounce-address record fine. The suppression-bypass fix is code, not DNS — tracked in the phase plan above.
Confirmed from the AWS account tonight: status DENIED on the transactional use-case application ("PropFlow… under 1,000 emails/day"). No trace of the application or a reason exists in any retained session; AWS hides the reason behind a paid support plan. Impact today: none — sending is SendGrid's job, and receiving never needed production access. It only matters if we later want Amazon as a second sending rail (redundancy) — in that case we reapply with a fuller application (specific bounce/complaint handling, suppression mechanics, opt-out story — the usual reasons thin applications get denied). Not urgent; parked as an option.
The forward hop stays load-bearing; we add a canary (a scheduled self-email that alerts when the loop goes quiet) and the deliverability program above. Cheapest; the latency and the invisible-config risk remain.
Outbound keeps From: clara@propflowai.co; Reply-To becomes
clara@inbound.propflowai.co — the address Amazon already receives. Replies (the
escalation loop, the matter releases — the traffic that matters most and is most latency-sensitive)
skip Google entirely: the 15–35 s hop disappears from exactly the loop tonight's demo exercised, and
the hand-configured forward stops being load-bearing for it. The Amazon rule gains one action
forwarding a copy into the Google mailbox, so Clara's inbox stays a complete human-readable archive —
Gmail becomes a mirror instead of a dependency. Fresh (non-reply) mail to clara@ still arrives via
Google until/unless D. Cost: a one-line sender change + one rule action + the canary.
Fede's question, answered honestly: yes, SendGrid can receive on a subdomain and POST to us — but it would rebuild, on a second vendor, exactly what the Amazon rail already does (receive → store → webhook → queue), minus the three battle-tested dedup layers. No prior discussion of it exists in any record. Only sensible as part of consolidating everything onto SendGrid, which nothing else argues for.
Point Clara's receiving straight at Amazon, mirror into a mailbox for humans. Cleanest end state, kills the hop for all mail, but touches live domain mail routing mid-rollout and interacts with the "real mailbox" coworker property. (The Cloudflare variant researched Aug 13 is blocked outright: their mail receiving demands their name servers own the domain's mail records — can't coexist with Google Workspace.) Schedule after Camellia escalations settle.
"Create the long-term plan tonight, but let's focus on making it work end to end first with current email infra." So: the escalation reply loop ships on today's plumbing — SendGrid sends from clara@propflowai.co, replies come back through the Gmail forwarding rule onto the Amazon rail — exactly the path proven live tonight. Nothing about mail routing changes during the escalation rollout. Option B is adopted as the target, sequenced after Camellia's escalations settle. Option C is rejected. Option D stays a later-still end state.
| Phase | What changes | Gate to start | Owner / effort |
|---|---|---|---|
| 0 — Make it work end to end on current infra now |
Willows-only coworker escalation email live (merged tonight). Smoke alarm on the Gmail forward hop: a scheduled self-email that must be ingested within 5 minutes or an alert fires — turns the invisible forwarding rule into a watched dependency. Live-wire harness: one command that texts a real phone, drives Clara's reply, triggers the escalation email, and checks the reply-releases-it loop. Reminder cadence verified from a production-shaped runner, not a laptop. | none — in progress | Fede's sessions · 1–2 nights |
| 1 — Trust layer (deliverability) this week |
Fix the shadowed SendGrid link records; close the suppression bypass; register Google Postmaster Tools; DMARC ramp p=none → quarantine (pct=25 → 100) → reject, watching Postmaster between steps. Ready-to-apply DNS checklist lives in the deliverability section above. | Fede applies DNS + Postmaster (needs his logins); code parts need nothing | Fede 30 min + code PR |
| 2 — Camellia escalation rollout | Coworker format opens to Camellia additively: legacy alerts stay intact; recipient list starts as fede@propflowai.co only for a few days, then the team. Still on current mail infra. | Fede's explicit go after watching Willows | one-line gate change + recipient config |
| 3 — Option B: replies ride the Amazon rail | Reply-To on outbound becomes the address Amazon already receives; the Amazon rule gains a copy-to-Gmail action so Clara's inbox stays a complete archive. The 15–35 s hop and the hand-configured forward stop being load-bearing for the loop that matters. Smoke alarm from Phase 0 stays and now guards the mirror. | Phase 2 stable for ~1 week; Postmaster shows healthy reputation | one-line sender change + one rule action + verify |
| 4 — Option D: Google out of Clara's mail path later |
Point clara@ receiving straight at Amazon; mailbox becomes a mirror for humans. Revisit only after B has run clean and the "Clara has a real mailbox" coworker property is settled. | separate decision doc | domain mail-routing change — schedule deliberately |
Still open for Fede (unchanged): un-hold the sender-authentication P0 on the property-mailbox rail (touches Camellia's live mailbox); reapply to Amazon for sending (default: park).
Goal: get the Google consent screen out of Testing mode so a real customer on Google Workspace (Situs Group) can connect a mailbox and calendar. We request only sensitive scopes, not restricted ones, so no CASA security audit and no assessor fee applies. Google's stated review is 2–3 business days for brand verification plus 3–5 business days for scope review (Google, OAuth verification requirements). Third-party reports say plan 4–8 weeks door to door with one clarification round.
Read from src/lib/integrations/google/oauth.ts on main, 2026-09-05.
| Scope | Class | Used by | Working in product? |
|---|---|---|---|
openid, email, profile | non-sensitive | identify which Google account was connected; shown in settings | yes |
gmail.send | sensitive | Clara sends prospect/tenant email from the property's own address | no — sender code has a Microsoft branch only; Google-connected properties fall back to SendGrid (property-graph-sender.ts) |
calendar.readonly | sensitive | read busy/free before offering tour times | yes (calendar/provider/google.ts) |
calendar.events | sensitive | create/cancel the tour event on the manager's calendar | no — adapter is read-only by design; provider-guard.ts blocks Google tokens from the booking path |
| Requirement | Status | Evidence |
|---|---|---|
| Domain verified with Google | done | two google-site-verification TXT records on propflowai.co (live DNS) |
| Homepage | done | propflowai.co |
| Privacy policy URL | done, needs a read | propflowai.co/privacy returns 200. Confirm it names Gmail and Calendar data handling explicitly and includes the Google API Services User Data Policy "Limited Use" sentence — Google looks for both. |
| Terms of service URL | done | propflowai.co/terms returns 200 |
| Authorized domains on the consent screen | confirm in console | must list propflowai.co |
| App name / logo match the homepage | confirm in console | reviewers compare the consent screen brand to the homepage |
| Support email + developer contact | confirm in console | a monitored propflowai.co address |
| Per-scope justifications | done | below |
| Demo video (unlisted YouTube) | to record | script below; 2–5 min |
| Gmail send branch for Google-connected properties | open | small PR, Gera |
| "Connect Google" on the settings page | open | today the buttons exist only in the onboarding wizard; managers of a property we set up never see them |
gmail.send
PropFlow is an AI leasing and maintenance assistant for property management companies. A property manager connects the property's own Google Workspace mailbox so that replies to prospects and residents are sent from the property's real address rather than a third-party domain, which is what recipients expect and what keeps the property's conversation record whole. The send-only scope is the minimum for this: we never read, list, or modify messages in the mailbox. Messages are composed in PropFlow, sent once through the Gmail API, and the sent copy is retained only in PropFlow's own conversation log.
calendar.readonly
When a prospect asks for a tour, PropFlow reads the connected manager's calendar to determine which time slots are free before offering times. We read only busy/free and event start and end times for the scheduling window; we do not display or store event titles, attendees, or descriptions. Read-only access is the minimum needed to avoid offering a time the manager already has booked.
calendar.events (include only if the booking write path ships before submission)
After a prospect confirms a tour, PropFlow creates the tour appointment on the connected manager's calendar and updates or cancels that same event if the prospect reschedules or cancels. We create and modify only events PropFlow created, identified by an event property we set; we do not alter the manager's other events. This is the minimum scope that allows writing an event.
openid, email, profile
Used to show the manager which Google account is connected and to detect when the connected account changes. No other use.
Google's checklist: show the app's public homepage; show the OAuth consent screen with the browser URL bar visible so the client ID is readable; show each requested scope in actual use; keep it in one continuous take where possible.
Record against a test Google Workspace account we own, not a customer's. Use the Willows test property. Upload as Unlisted; paste the link in the verification form.
gmail.send + calendar.readonly; add calendar.events when the write path exists.Not needed for this submission: CASA assessment, assessor fees, Marketplace listing. Those apply only if we later ask to read inboxes (gmail.readonly/gmail.modify, restricted scopes): 6–12 weeks realistic and roughly $675/year at the cheapest approved assessor per third-party pricing reports.
Sources: 157 ADRs + closed-PR sweep on GitHub (no unmerged email ADRs exist), full git history (the rail's commits predate the repo's first PR, 2026-03-30), all retained session transcripts (window starts 2026-07-15; the March setup session no longer exists), the July 24 audit + ADR-0114/0119, live DNS + SES receipt-rule + Lambda-config + SES-account checks run tonight, and the live Willows demo. The one link no record explains is the Gmail-side forward config itself — it can only be read from inside the Google mailbox settings.
Plain English, six lines.
1. Camellia's mailbox tells us the instant mail arrives (push). Western Slope's shared
leasing@ address cannot be watched that way, so we check it every minute instead (pull).
2. That split is a real Microsoft rule, not a shortcut: Microsoft only lets you watch a mailbox you are
signed into directly. Western Slope's address is shared and reached through a leasing agent's own sign-in.
3. Camellia's mail flow has not changed. A test pins the exact lines that must stay untouched, and it still
passes.
4. Live risk one: a leftover job keeps trying to "watch" the shared mailbox every 30 minutes. It always fails,
and it can collide with the new minute-by-minute checker on the same database row, stalling it.
5. Live risk two: the two paths do not share a "we already saw this email" list. On a slow patch, that can make
an old email look new and get delivered twice.
6. Plan: ship small, safe fixes now that do not touch Camellia (Option A), then work toward putting every
mailbox on the same minute-by-minute check (Option B) once it is proven safe for Camellia too.
Microsoft will only let an app "subscribe" to a mailbox the signed-in user owns. Camellia signs in as its own
mailbox, so a subscription works. Western Slope's leasing@ is a shared mailbox reached through an
agent's own login, and Microsoft's sharing permission explicitly does not extend to subscriptions, only to
ordinary reads. So Western Slope is polled: every minute we ask "what changed since the last time," using a
saved bookmark (the delta cursor).
| Push (Camellia) | Pull (Western Slope) | |
|---|---|---|
| Works on a shared mailbox | no | yes |
| Speed | 1 to 5 seconds | up to 60 seconds, plus round time |
| Cost when idle | zero calls | ~1,440 calls/day/mailbox |
| If we miss a delivery | gone for good, nothing recovers it | the bookmark replays it next round |
| Main failure mode | subscription expiry, renewal outage | stale bookmark, rate limiting, stuck token refresh |
| Can be spoofed by an outside call | only if the shared secret check is skipped | no, we make the call |
Camellia's mail is untouched. The shared code path that both lanes run through treats Camellia's mailbox
exactly as it always has, and a drift test (per-property-channel-lane.drift.test.ts)
pins the exact lines so a change there fails CI. Outbound is already correct on both sides: mail goes out from
a property's own mailbox first, the shared company mailbox second, and generic clara@propflowai.co
last.
| Risk | What happens | Where |
|---|---|---|
| R1 Double delivery, half-guarded | A property with its own different mailbox, inside an org that also has a shared company mailbox, gets billed to both. Only exact-address matches are caught. | org-mailbox-inbound.ts:153 |
| R2 Dedup does not span the two lanes | Each lane keeps its own "already seen" list of 200 ids with no expiry. A busy inbox can push an id out of the list before the other lane ever saw it, so a stuck message gets redelivered as new. | webhook-processors.ts:1345, org-mailbox-inbound.ts:262 |
| R3 A dead job fights the live one, every 30 minutes | A leftover renewal job still tries to "watch" the shared mailbox on a schedule. Microsoft always refuses it, but the failed attempt writes to the exact same database row the minute-checker uses, which can stall the checker until the next try. | handler.ts:394-521 |
| R4 Dead code left behind | A full subscription system was built for the shared mailbox before we knew Microsoft would always refuse it. It cannot work, it is not harmless (feeds R3), and it will read as the "real" path to the next person who looks. | handler.ts:394 |
| R5 Login token contention | More callers than ever share one stored login. The safety lock is best-effort only, so two callers can still refresh at once; the loser's copy goes stale and can read as "no mailbox." Camellia's older storage has no lock at all, so two concurrent refreshes can silently lose a login. | fresh-channel-token.ts:46-53, property.ts:476 |
| R6 Camellia stays byte-identical | Confirmed safe: a pinned test proves the shared code path still writes exactly where it wrote before for Camellia. | per-property-channel-lane.drift.test.ts |
| R7 Blind on arrival | Drops are logged, but the failing 30-minute job has alerts off, a missed delivery only logs a warning with no metric, and no measure exists anywhere for how fast or complete either lane is. | automations.toml:1276 |
| R8 In-memory guard undercounts | The "already processing" guard only works within one warm server instance; the real protection is the database write, which is sound, but the guard's own count will understate itself. | company-mailbox-poller.ts |
One entry point, two drivers. Green is live today. The reader (right) is deployed but switched OFF for Western Slope: its once-a-minute schedule is disabled and the on/off flag is unset. "Arming" = turning those two on. It starts reading; it does not start replying to prospects (that is the separate per-company live switch at the bottom).
flowchart TB
subgraph MS["Microsoft 365"]
P1["Camellia mailbox
Clara signs in AS the mailbox"]
P2["Shared leasing@ mailbox (Western Slope, Fairhaven test)
reached through a staff member's sign-in"]
end
P1 -->|"Microsoft PUSHES a notification
the moment mail arrives"| WH["Push driver
webhook endpoint"]
P2 -.->|"push notification REFUSED
by Microsoft for shared mailboxes"| X((✗))
P2 -->|"we PULL: 'what changed since
my last bookmark?'"| POLL["Pull driver
AWS Lambda, every 1 minute"]
ARM{"ARMED?
schedule enabled
+ poll flag on"} -.->|"OFF today for Western Slope"| POLL
PROBE["Feed-mode probe at connect time
push accepted → push · refused → pull"] --> WH
PROBE --> POLL
POLL --> OWN{"Reply on a thread
Clara started from
this mailbox?"}
OWN -->|"no: vendor CC, misdirected,
cold mail → dropped, counted"| DROP["not ours"]
OWN -->|"yes"| SEAM
WH --> SEAM["ONE entry point: 'I got an email'
shared already-handled record
(mailbox + message id)"]
SEAM --> PIPE["Same pipeline for both
classify · thread · person"]
PIPE --> LIVE{"Company live
for prospects?"}
LIVE -->|"yes"| SEND["Clara replies from the property mailbox"]
LIVE -->|"no"| REC["Record only, visible in the app, no reply"]
classDef live fill:#e6f4ea,stroke:#2e7d32,color:#1b5e20;
classDef off fill:#fff4e5,stroke:#e65100,color:#7a3e00;
class P1,WH,SEAM,PIPE,LIVE,SEND,REC,PROBE live;
class ARM off;
Proof 2026-09-16 05:58Z on the Fairhaven test tenant: a prospect's thread reply was pulled, passed the ownership check, went through the entry point, was classified as a tour reply and answered. Evidence on the Western Slope go-live tracker, row "shared leasing inbox reader".
DECIDED, Fede 2026-09-15 Option A: one seam, two drivers. Fede: the consumer just gets "I got an email"; push or pull is an implementation detail behind one interface. Keep the current shared pipeline, but let each mailbox connection declare how it gets fed: push or pull. Every step is independently dark and mergeable, and none touch Camellia except one:
DESTINATION Option B: everything on the minute-checker. Microsoft's own recommended pattern: every mailbox gets a bookmark, and the instant notification just triggers an immediate check rather than carrying the mail itself. One path, one "already seen" list, and missed or expired notifications stop being permanent data loss.
Option C: leave as is, add two guards. Ship what already exists plus A2 and A4 only. Leaves the two lanes split, the dead code in place, and Camellia's silent-loss problem unfixed. Not recommended: the 30-minute job is an active, unalerted failure against a customer's mailbox, every 30 minutes.
Recommendation: A now, B as the destination. A is small, every step ships dark on its own, and it fixes the three risks that can actually lose or duplicate a customer's mail (R2, R3, R4) without touching Camellia. B is the better end state, the only option that fixes Camellia's silent missed-delivery loss, but it changes Camellia's live path, so it earns a proven shadow run rather than a same-day merge. A's push-or-pull flag is exactly the piece B needs later, so none of A is throwaway work.
Proposed, pending Fede's pick
Decided in part (Fede, 2026-09-15); two picks open
Fede, 2026-09-15: "seems like we are not ready to jump into implementation here... create an architecture / design diagram first." This section is that diagram: the path a lead takes today, from landing in a company's shared inbox to the moment Clara does anything, or does not. No code changes proposed here, this is the map to build against.
A shared mailbox receives leads from six places: a Zillow first-contact email, a Zillow relay thread (reply traffic that keeps flowing through Zillow's own masking address), an Apartments.com network lead, an AppFolio website-form email, an AppFolio guest card that arrives through PropFlow's own API sync rather than email, and a direct email from someone who found the property another way.
Two feed modes exist. Push: Microsoft sends a change notification the instant mail lands, and only works when PropFlow is signed in as the mailbox itself (Camellia). Pull: an AWS EventBridge Lambda reads a shared mailbox on a schedule, because Microsoft never lets a delegated sign-in subscribe to a shared inbox (Western Slope). Which mode applies is a fact about the mailbox's own permission, never a human's choice, and both modes should land in the same "an email arrived" envelope so nothing downstream needs to know which feed it came from. Today that seam is implicit: which code path a mailbox runs is decided by whether it is a property mailbox (push) or an org mailbox reached through pull, not by a stored flag, and there is no shared "already handled this message" record spanning both feeds yet (flagged as R2 in the audit above). This design assumes that record lands first.
| Stage | What happens | Where in code today | What is missing |
|---|---|---|---|
| Ingestion entrypoint | The webhook path validates and dedups a Graph notification; the SES/API path does the same for direct or SES-relayed mail. | webhook-processors.ts processNotification (line 736);
process-inbound-email.ts processInboundEmail (line 244) |
Two separate entrypoints for push and pull mail, not one seam |
| Identity extraction | Each lead source has its own labeled-field parser for name, email, and phone, with a generic regex fallback. | parse-lead-source.ts parseLeadEmail /
parseLeadEmailInner (line 400/592): parseZillowLead,
parseApartmentsComLead, parseAppFolioLeadNotification |
No listing id or guest-card id extraction; identity is text-parsed for every source except AppFolio's own guest-card sync |
| Home resolution | Tries a dark address-head lookup, then an exact per-property inbound address, then a named-home text match, then a subject-line fallback; never guesses when a dedicated address exists. | process-inbound-email.ts tryRouteSesLeadToInquiryPipeline
(line 943): matchPropertyByInboundAddress, matchPropertyByNamedHome,
matchPropertyByName |
No lead email carries a usable listing id; AppFolio guest-card ids exist only in the API sync, never in the email body; the org-level fallback resolves which building owns a mailbox, not which specific home a lead is about in a multi-home portfolio |
| Staff-already-handled check | A narrow, vendor-scoped version exists for vendor-scheduling threads only. | thread-provenance.ts threadOutboundReachedSender, used by
email-schedule-intake.ts |
No generic check for "a staffer already replied to this lead from Sent Items" before Clara acts on lead or prospect mail; a likely gap, not confirmed absent everywhere |
| Reply-from resolution | The company mailbox is the default sender; a property with its own connected mailbox wins over the company mailbox. | company-mailbox.ts resolveCompanyMailboxToken (line 36),
used from reply-mailbox.ts; property-graph-sender.ts
sendPropertyEmail (line 280) |
Nothing found; this stage answers "who sends," not "should we send" |
| Live gate | A single strict flag on the organization decides whether Clara can reply at all; everything upstream runs unconditionally. | clara-live.ts resolveClaraLive (line 44):
organization.claraLive === true, else dark |
Nothing; this is the one deliberate gate and it works as designed |
| Camellia's different path | One mailbox per property, push feed, and the property is the mailbox's own identity, so there is no home-resolution guess to make. | org-mailbox-inbound.ts resolveOrgMailboxProperty maps a
company mailbox to a single property today (exact match, org has exactly one property, or exactly
one scattered-site portfolio property) |
This code path is being asked to do the harder job, a shared mailbox serving many homes, that Camellia never needed |
Solid green is the live path, decided 2026-09-15: guest cards only. Dashed grey is not live: the pull mailbox reader (built and proven 2026-09-16, switched off), relay-email parsing, and text-based home resolution (not built).
flowchart TB Z1["Zillow first contact email"] Z2["Zillow relay reply thread"] AC["Apartments.com network lead"] AF1["AppFolio website form email"] DE["Direct email"] AF2["AppFolio guest card
property + unit, via syndication"] subgraph FEED["Built 2026-09-16, dark: pull mailbox reader (replies on Clara-started threads only)"] PULL["Pull: EventBridge Lambda
shared mailbox via an agent's login"] PARSE["Relay / per-source email parsing"] HOME["Home resolution
address text vs org home list"] PULL --> PARSE --> HOME end Z1 -.-> FEED Z2 -.-> FEED AC -.-> FEED AF1 -.-> FEED DE -.-> FEED HOME -.-> ING AF2 --> SWITCH{"Created after org's
switch-on timestamp?"} SWITCH -->|"no, existing or in-progress"| HUMAN["Stays with a human"] SWITCH -->|"yes"| ING["Ingestion"] ING --> LIVE{"claraLive?"} LIVE -->|"yes"| SEND["Clara replies"] LIVE -->|"no"| RECORD["Record only, no reply"] classDef parked fill:#eeeeee,stroke:#999999,stroke-dasharray: 5 5,color:#777777; class PULL,PARSE,HOME,FEED,Z1,Z2,AC,AF1,DE parked; classDef live fill:#e6f4ea,stroke:#2e7d32,color:#1b5e20; class AF2,SWITCH,ING,LIVE,SEND,RECORD,HUMAN live;
| Vendor | Leads arrive | Property resolved | Already-handled | Replies | Source |
|---|---|---|---|---|---|
| Zillow | HTTP POST webhook to Tech Connect partners, or masked relay email | Structured fields: listingId, listingStreet, listingUnit, listingCity, listingPostalCode | n/a | Relay zms-XXXX@reply.zillow.com masks both parties | Lead API Guide, Zillow help |
| Apartments.com | Email to @assist.rent / @aptleasing.info, up to 6 destinations, or CRM API | Schema unpublished; some CRMs use a switch-code URL param | Not found | Proprietary relay | Apartments.com help |
| Zumper | Email or API/webhook/Zapier | Not found | Not found | Direct email | Zumper help |
| Realtor.com | Lead Delivery API to CRM partners | Not found | Not found | Not found | InsideRealEstate help |
| Funnel Leasing | Unique forwarding email per community | The address itself is the property key | Not found | Not documented | Funnel docs |
| EliseAI | PMS + Apartments.com PropertyID attribution | PropertyID passed by the ILS | Not found | Not found | Elise help |
| Entrata | sendMitsLeads API (MITS guest cards) | Property id in the call | Not found | Not found | Entrata docs |
| RealPage ILM | Guest cards auto-populated; form endpoint ?property=<code> | Property code as URL param | Not found | Not found | RealPage endpoint |
| AppFolio | Native API delivery from Zillow, Realm-X inbox | Property id via API, no text parsing | Not found | Not found | AppFolio Zillow integration docs |
| LeadSimple | One forwarding email per ILS source | Parses street address from body, matches property list ("use only the street address for property matching") | Property merge dedup on address | Not found | LeadSimple docs |
| Follow Up Boss | One account-wide @followupboss.me address | Parses address from body / Zillow payload | Source-based routing rules | Captures the Zillow relay address for continued messaging | Follow Up Boss docs |
| MITS | NMHC XML guest-card standard | Property/unit fields in schema | n/a | n/a | MITS data models |
Patterns, ranked by how common they are: (1) a structured feed that carries a property id (Zillow's API, Entrata, RealPage, AppFolio, EliseAI); (2) a per-property or per-community inbound address (Funnel); (3) a per-source address plus a street-address text match against the live property list (LeadSimple, Follow Up Boss, and general tools like Parseur/Mailparser); (4) sender-email matching as a last resort (HubSpot, Salesforce). No vendor in this survey documents a "staff already handled this" detection step, that appears to be an industry-wide gap, not something we are missing that others have solved.
Superseded by the decision below: the table above shaped the thinking, but the direction chosen for Western Slope is narrower than any single row in it. Intake is AppFolio guest cards only, for listings syndicated through AppFolio. No address-text matching, no listing-id parsing, no relay handling. "Already handled" becomes state on the guest card itself, since no vendor surveyed here has solved that problem either.
Western Slope is syndicated through AppFolio, roughly 30 listing sites. Zillow, Apartments.com, and website inquiries all become AppFolio guest cards with property and unit, continuously since September 2025. Over the last 90 days: 193 guest cards, 85 of them tagged Zillow (12 in June, 32 in July, 16 in August, 25 so far in September). AppFolio's lead-notification email destination is set company-wide to clara@propflowai.co, but nothing reads that inbox today; zero of those emails have ever been ingested. The stoppage on record for 2025-11-29 was the notification emails only, the guest-card feed itself kept running.
There is a real gap between the two channels. In the 14 days to today, 52 distinct prospects sent a first-touch Zillow relay email to the leasing inbox, against 25 Zillow guest cards created in the same window. We have not confirmed why. Candidates: some listings are posted directly in Zillow Rental Manager instead of through the AppFolio feed; the AppFolio feed shows "Submitted" rather than "Live" on a unit we sampled; or Zillow's own dedupe drops some inquiries before they reach AppFolio.
Data quality differs by source. Zillow-sourced guest cards carry a usable email about 72% of the time and a phone about 74%. Every other source lands at 93 to 96% on both. The application link on a guest card is a one-time URL issued for that specific application, not a link good for the whole unit.
Our own daily sync job for Western Slope's guest cards runs, reports success, and has written zero rows. That is being diagnosed now, separately from this design.
The Zillow relay email itself is thin: of 246 relay emails over three months, only a name and the property address are reliable. One carried a phone number, none carried a real reply email, and five carried a proposed tour date.
Decided
Proposed, pending Fede's pick
Set aside for this launch: the inbox-reader stack (PRs 8658, 8666, 8679, 8681, 8682, 8649, 8674, 8662, 8670), address-to-home text matching, Zillow relay-email handling, a test harness for this flow, and writing back into the AppFolio guest card from our side.
PropFlow Docs