Communication & email paths
In one line: every place HIGHFIELD sends or receives a message — tenant, vendor, approver, admin — plus the cross-cutting safety rules (loop prevention, rate limits, recipient resolution, money-blindness). This is the most-instrumented and most-incident-prone area of the system.
What it is
HIGHFIELD talks to three kinds of people by email: tenants (reporting a problem), vendors/contractors (doing the work), and admins/approvers (approving spend and receiving briefs). Almost every email is outbound and sent through Lua's send-message API from the agent's mailbox (property@highfieldproperty.ie). A smaller set of inbound emails arrive either through the native Lua email channel (handled by preprocessors) or through an AgentMail webhook.
The system sends mail at every step of a ticket's life — logged, quoted, finance-approval, time-confirmation, work start/pause/complete, stalls (escalation), and daily/weekly briefs. Tenant-facing mail is deliberately money-blind (never shows cost) and avoids urgency words; vendor mail may show cost.
The big safety rules: never reply to automated/bounce mail (RFC-3834 loop guard on both inbound paths), never email our own @heylua.ai address or runaway-loop the same sender (rate-limit backstop + recipient filters), and mark our own outbound as auto-replied so recipients' servers don't bounce a vacation reply back at us. Outbound notifications are also threaded onto one conversation per {ticket, party} so a tenant/vendor sees a single ongoing thread.
Under the hood — two outbound lanes, don't conflate them. (1) Lua send-message (
src/utils/email-notifications.ts) is the production lane for all lifecycle/brief/escalation mail; it does not override the recipient. (2) AgentMail (src/services/agentmail.ts) is a separate transport whose everytois force-rewritten toAGENTMAIL_OVERRIDE_TO(defaultstefan@heylua.ai). A brief not arriving is a Lua-channel issue, not AgentMail.
How it works
- "From" is
property@highfieldproperty.iefor every path routed throughnotifyByEmail/notifyTicketThread(set viaLUA_EMAIL_CHANNEL_ID, falling back to the platform defaultchat@heylua.aiif unset —src/utils/email-notifications.ts:104-111). notifyTicketThreadanchors a send onto the per-{ticket, party}thread;notifyByEmailsends standalone (no thread anchor). The synthetic threading Message-IDs are computed insrc/utils/email-thread-anchor.ts.- Inbound has two independent implementations (native preprocessor vs AgentMail webhook) that must stay in parity — see the Gotchas & failure modes section below.
Reference — email paths (exhaustive)
Inbound (received) paths
| Trigger | From | To / how resolved | Threading | Source |
|---|---|---|---|---|
| Native Lua email channel — any inbound email | tenant/vendor/admin | the agent (preprocessors → LLM) | Reads Lua.request.webhook.payload headers to bind to a ticket | src/preprocessors/email-channel-handler.preprocessor.ts:129-490 |
AgentMail webhook message.received | tenant/vendor/approver | webhook (BC lookup → AI → reply) | threadId: in_reply_to → references[0] → thread_id → header In-Reply-To/References[0] → ticket:MT-… from subject → cleaned subject → message_id | src/webhooks/inbound-email.webhook.ts:41-150 |
| Approver decision reply (rides inbound-email) | APPROVER_EMAIL | handled internally | webhook threadId; intent parsed from subject+body | inbound-email.webhook.ts:181-208; src/utils/approver-decision.ts:67-197 |
| Vendor JSON-action reply (rides inbound-email) | vendor | internal → tool dispatch | same threadId | inbound-email.webhook.ts:843-983 |
Outbound — ticket lifecycle
| Trigger | To | Subject / template | Transport | Source |
|---|---|---|---|---|
| Ticket created → vendor auto-assigned | vendor | MT — New Job Assigned — property / vendorJobAssignedEmail | notifyTicketThread (vendor) | ticket-creation-service.ts:175-203; tpl email-templates.ts:241-273 |
| Ticket created → tenant confirmation (opt-in) | tenant | MT — Maintenance Request Received / tenantTicketCreatedEmail | notifyByEmail | ticket-creation-service.ts:220-277; tpl email-templates.ts:413-448 |
| Inbound webhook reply to sender | sender | Re: <subject> / vendorReplyEmail HTML | notifyByEmail | inbound-email.webhook.ts:1049-1066 |
| Inbound webhook — AI generation failed | sender | Re: <subject> / awayReplyEmail (warm "we're away") | notifyByEmail | inbound-email.webhook.ts:655-672; copy error-reply-copy.ts:28-32 |
| Vendor submits quote (auto-approved, legacy single-time) | tenant | MT — Confirmation needed | notifyTicketThread (tenant) | SubmitQuoteTool.ts:237-247 |
| Vendor submits quote (> threshold) → finance approval | approval emails | MT — Approval Required — €X / approvalRequestEmail | notifyByEmail | SubmitQuoteTool.ts:251-294; tpl email-templates.ts:142-182 |
| Offer visit slots → tenant menu | tenant | MT — Choose your visit time / slotMenuEmail | notifyTicketThread (tenant) | OfferVisitSlotsTool.ts:175-187; tpl email-templates.ts:831-862 |
| Tenant confirms time → lock-in vendor "proceed" | vendor | MT — You're cleared to proceed / vendorLockInConfirmationEmail | notifyTicketThread (vendor) | lock-in.ts:107-127; tpl email-templates.ts:370-400 |
| Lock-in → tenant "you're all set" | tenant | MT — Visit confirmed / plain, NO price | notifyTicketThread (tenant) | lock-in.ts:135-149 |
| Tenant confirms, cost still pending → vendor "hold" | vendor | MT — Maintenance / plain | notifyTicketThread (vendor) | ConfirmVisitTimeTool.ts:190-201 |
| Tenant accepts multiple slots → vendor "which one?" | vendor | MT — Maintenance / plain | notifyTicketThread (vendor) | ConfirmVisitTimeTool.ts:109-129 |
| Tenant reschedule request → vendor relay | vendor | MT — tenant would like to adjust… / buildVendorRescheduleEmail | notifyTicketThread (vendor) | RelayTenantRescheduleTool.ts:194-216,241-280 |
| Vendor reschedules visit → tenant ask | tenant | MT — Visit time update / outcome.tenantCopy | notifyTicketThread (tenant) | RescheduleVisitTool.ts:141-157 |
| Vendor starts work → tenant notice | tenant | MT — Maintenance / plain | notifyTicketThread (tenant) | StartWorkTool.ts:114-118 |
| Vendor pauses work → tenant notice | tenant | MT — Maintenance / plain | notifyTicketThread (tenant) | PauseWorkTool.ts:107-111 |
| Vendor completes → tenant "work completed" | tenant | MT — Work Completed / tenantWorkCompletedEmail (money-blind) | notifyTicketThread (tenant) | CompleteJobTool.ts:283-303; tpl email-templates.ts:569-600 |
| Vendor completes → vendor completion receipt | vendor | MT — Completion confirmed / plain (tool-sent, survives 60s fallback) | notifyTicketThread (vendor) | CompleteJobTool.ts:309-324 |
| Cost overrun > threshold → escalation | escalation emails | Cost Overrun Alert: MT / plain | notifyByEmail | CompleteJobTool.ts:249-256 |
| Vendor declines job → admin alert | escalation emails | Vendor Declined Job - MT / plain | notifyByEmail | DeclineJobTool.ts:120-129 |
| Vendor declines job → tenant "reassigning" | tenant | MT — Maintenance / plain (no reason/vendor name) | notifyTicketThread (tenant) | DeclineJobTool.ts:133-144 |
| New issue photo uploaded → vendor notice | vendor | MT — Maintenance / inline <img> HTML | notifyTicketThread (vendor) | UploadIssueImagesTool.ts:165-172 |
Vendor-response webhook quote → tenant | tenant | MT — Quote Received / tenantQuoteReceivedEmail (money-blind) | notifyTicketThread (tenant) | vendor-response.webhook.ts:204-223; tpl email-templates.ts:460-488 |
Vendor-response webhook schedule_update/work_started → tenant | tenant | MT — Maintenance / plain | notifyTicketThread (tenant) | vendor-response.webhook.ts:280-283,336-339 |
Vendor-response webhook work_completed → tenant | tenant | MT — Work Completed / tenantWorkCompletedEmail | notifyTicketThread (tenant) | vendor-response.webhook.ts:429-448 |
Outbound — approval, escalation, admin
| Trigger | To | Subject / template | Transport | Source |
|---|---|---|---|---|
send_for_approval (> threshold) → finance | approval emails | MT — Approval Required — €X / approvalRequestEmail; Reply-To = AGENT_REPLY_TO_EMAIL or property@highfieldproperty.ie | notifyByEmail | SendForApprovalTool.ts:248-285 |
| Approver replies YES/NO → confirmation | approver | Re: <subject> / plain | notifyByEmail | approver-decision.ts:203-216 |
escalate_ticket → admin team | escalation emails | 🚨 Escalation: MT — <Type> / escalationNotificationEmail | notifyByEmail | EscalateTicketTool.ts:117-137; tpl email-templates.ts:622-660 |
| Escalation job (SLA/no-response/stale/expired/overrun) → admin | escalation emails | 🚨 Escalation: MT — <Type> / escalationNotificationEmail | notifyByEmail | escalation.job.ts:101-124 |
record_tenant_dispute → escalation contacts | escalation emails | Dispute raised on ticket MT / plain | notifyByEmail | RecordTenantDisputeTool.ts:108-129 |
notify_tenant_access → tenant | tenant | MT — Maintenance / plain | notifyTicketThread (tenant) | NotifyTenantAccessTool.ts:43-48 |
admin_resolve_escalation → tenant and/or vendor | tenant / vendor | MT — Maintenance / admin's verbatim message | notifyTicketThread | AdminResolveEscalationTool.ts:80-112 |
| System cancel (reschedule-limit / no-response) → tenant + vendor | tenant + vendor | MT — Request closed / MT — Maintenance / plain | notifyTicketThread (both) | system-cancel.ts:85-107 |
| Completion-feedback job — vendor "time to start" | vendor | MT — Time to start / vendorVisitStartReminderEmail | notifyTicketThread (vendor) | completion-feedback.job.ts:93-111; tpl email-templates.ts:757-787 |
| Completion-feedback job — vendor proof chase | vendor | MT — Completion photos & invoice / vendorCompletionReminderEmail | notifyTicketThread (vendor) | completion-feedback.job.ts:114-128; tpl email-templates.ts:794-816 |
| Completion-feedback job — tenant feedback | tenant | MT — How was your maintenance experience? / tenantFeedbackEmail | notifyTicketThread (tenant) | completion-feedback.job.ts:143-165; tpl email-templates.ts:715-744 |
| Daily report job (09:00) → admin | admin emails | 📊 Daily Maintenance Report — <date> / dailyBriefEmail HTML | notifyByEmail (once-per-day guard) | daily-report.job.ts:358-397; tpl email-templates.ts:898-970 |
| Weekly report job (Fri 17:00) → admin | admin emails | 📊 Weekly Maintenance Brief — <range> / weeklyBriefEmail HTML | notifyByEmail (once-per-week guard) | weekly-report.job.ts:80-97; tpl email-templates.ts:1003-1110 |
| Admin Q&A reply rendered to HTML (postprocessor) | admin | converts the LLM's Markdown reply to email-safe HTML in place | gated on user.isAdmin | admin-reply-format.postprocessor.ts:10-19; markdown-email.ts:172-286 |
AgentMail-direct paths (src/services/agentmail.ts)
| Operation | To | Notes | Source |
|---|---|---|---|
sendEmail() | always AGENTMAIL_OVERRIDE_TO (default stefan@heylua.ai); real recipient prefixed into subject [To: …] | Safety override so live customers never get AgentMail-direct mail | agentmail.ts:87-117 |
replyToEmail() | thread of messageId | POST /inboxes/{id}/messages/{msgId}/reply | agentmail.ts:122-146 |
registerWebhook() | n/a | registers message.received, idempotent via client_id | agentmail.ts:179-191 |
⚠️ Note:
agentmail.tssendEmail/replyToEmailare not on any lifecycle path found via grep (onlygetAttachmentInfois used, by the inbound webhook, which itself replies vianotifyByEmail). Treat the AgentMail send transport as legacy/standalone.
Cross-cutting email rules
Loop / auto-reply prevention (RFC-3834)
detectAutomatedSender(fromField, headers) is the shared, conservative detector (src/utils/auto-reply-detection.ts:35-96). It flags automated mail on: Auto-Submitted ≠ no; X-Auto-Response-Suppress containing DR/AutoReply/All; Precedence ∈ {bulk,auto_reply,list,junk}; presence of List-Id/List-Unsubscribe/Feedback-ID; X-Autoreply: yes/X-Loop; sender/Reply-To/From matching no-reply@/do-not-reply@/mailer-daemon@/postmaster@; or a Mail Delivery … display name.
Wired into both inbound paths:
- Native channel:
auto-reply-guard.preprocessor.ts(priority 5) → on automated,action:'block', response:''(silent). - AgentMail webhook: reads
message.headers→ on automated, early returnskipped:'automated-sender'before any BC/AI/reply (inbound-email.webhook.ts:95-106).
Outbound loop suppression: every notifyByEmail sets Auto-Submitted: auto-replied, X-Auto-Response-Suppress: All, Precedence: auto_reply (email-notifications.ts:141-146) — best-effort; the inbound guard is the real backstop.
Rate-limiting / dedup / idempotency
- Reply rate-limit backstop (
reply-rate-limit.ts): default 40 replies / 10 min per sender (AUTO_REPLY_RATE_LIMIT,AUTO_REPLY_RATE_WINDOW_MIN); stored inauto_reply_rate; fail-open. Both inbound paths may record once each — halving the effective ceiling. - Daily/weekly brief guards: claim-at-start keyed on day/week; releases the claim if no admin received it;
DAILY_BRIEF_FORCE/WEEKLY_BRIEF_FORCEbypass. - Completion-feedback idempotency:
startReminderSentAt/completionReminderSentAt/feedbackTenantSentAtstamps written only on successful send. - Escalation dedup:
hasDuplicateEscalation+escalatedAtstamp.
Recipient resolution + fallbacks
- Tenant:
resolveTenantEmail(ticket)→ticket.tenantEmail→User.get(...)._luaProfile.emailAddresses[0]→u.email→''(no-op). Non-fatal (email-notifications.ts:38-52). - Vendor:
adapter.getVendorById(companyId, vendorId).email(skipped if empty). - Approval:
adapter.getApprovalEmails(companyId)(BC setup). Escalation:getEscalationEmails(companyId). - Admin / briefs:
getAdminEmails()= parsedAPPROVER_EMAIL, dropping blanks, dupes, and any@heylua.ai(loop guard) —admin-emails.ts:13-35. Admin is NOT run through the personal-domain filter;filterEscalationEmails(legacy BC path) DOES drop personal domains (gmail/yahoo/…).
Content sanitization / suppression before send
- Tenant money-blindness: tenant templates never render cost.
- Urgency anti-liability: urgency omitted from vendor/tenant templates; internal-only.
- HTML-escaping: reply/away templates escape
& < >;markdownToEmailHtmlescapes all text and allows onlyhttp(s)/mailtolinks; brief renderersesc()every value. - Email-safe HTML contract: single-
<table>fragments, inline styles only, no<head>/<style>/<script>, end with.replace(/\n/g,'')(the platform converts\n→<br>). - Hallucination guard (inbound webhook): strips fabricated
MT-…IDs from the reply, keeping only this-turn's created ID or BC-verified owner-matched IDs. modifiedResponse:''does NOT suppress delivery — tools usesuppressTenantNotify/suppressVendorNotifysource-level skips instead.
Gotchas & failure modes
- Two inbound paths must stay in parity. Native channel and AgentMail webhook independently re-implement loop guard, rate-limit, image extraction, data-URI parsing, and thread binding. A fix in one must be mirrored in the other. Header sources differ: webhook reads
message.headers; native readsLua.request.webhook.payload(andpayload.headersmay be nested or flattened). - Outbound threading is best-effort and undocumented.
notifyByEmailsendsinReplyTo/in_reply_to/parentMessageId/referencesunder several guessed names. If Lua honors none, emails arrive unthreaded. The RFC-3834 outbound headers may be silently ignored. LUA_EMAIL_CHANNEL_IDunset → all outbound comes fromchat@heylua.ainotproperty@highfieldproperty.ie— a real incident class.- Anchor pins to the EARLIEST inbound, not newest — deliberately, to avoid threading into the platform's auto-titled "Intake message N" junk thread.
resolveThreadSubjectrejects "intake/email message" subjects. - Cross-tenant thread binding is a known hazard. Binding is ownership-gated through 4 tiers; bypassing the gate re-opens the cross-tenant leak class. See Security.
- Platform ~60s fallback drops the real reply on heavy turns (completion).
CompleteJobToolsends a tool-level vendor receipt that pre-apologizes for any "technical difficulties" message. - Expiring S3 attachment URLs poison history.
email-channel-handlerre-uploads every non-CDN image URL tocdn.heylua.aion every turn; skipping this re-introduces the "technical difficulties every email" incident. - Dead imports / no-op sends.
escalation-response.webhook.tsimports email helpers but sends no email (all in-appUser.send()).satisfactionSurveyEmailwas removed. Don't assume these fire. - Daily brief log table is capped (
DAILY_BRIEF_MAX_LOG_ROWS) because 157 open tickets produced a 400 "request entity too large". - Personal-domain filter asymmetry (rule above) silently drops a personal escalation inbox while the same address as
APPROVER_EMAILstill receives briefs.
Full header tables: see Appendix — Headers.