Skip to main content

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 every to is force-rewritten to AGENTMAIL_OVERRIDE_TO (default stefan@heylua.ai). A brief not arriving is a Lua-channel issue, not AgentMail.

How it works

  • "From" is property@highfieldproperty.ie for every path routed through notifyByEmail / notifyTicketThread (set via LUA_EMAIL_CHANNEL_ID, falling back to the platform default chat@heylua.ai if unset — src/utils/email-notifications.ts:104-111).
  • notifyTicketThread anchors a send onto the per-{ticket, party} thread; notifyByEmail sends standalone (no thread anchor). The synthetic threading Message-IDs are computed in src/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

TriggerFromTo / how resolvedThreadingSource
Native Lua email channel — any inbound emailtenant/vendor/adminthe agent (preprocessors → LLM)Reads Lua.request.webhook.payload headers to bind to a ticketsrc/preprocessors/email-channel-handler.preprocessor.ts:129-490
AgentMail webhook message.receivedtenant/vendor/approverwebhook (BC lookup → AI → reply)threadId: in_reply_toreferences[0]thread_id → header In-Reply-To/References[0]ticket:MT-… from subject → cleaned subject → message_idsrc/webhooks/inbound-email.webhook.ts:41-150
Approver decision reply (rides inbound-email)APPROVER_EMAILhandled internallywebhook threadId; intent parsed from subject+bodyinbound-email.webhook.ts:181-208; src/utils/approver-decision.ts:67-197
Vendor JSON-action reply (rides inbound-email)vendorinternal → tool dispatchsame threadIdinbound-email.webhook.ts:843-983

Outbound — ticket lifecycle

TriggerToSubject / templateTransportSource
Ticket created → vendor auto-assignedvendorMT — New Job Assigned — property / vendorJobAssignedEmailnotifyTicketThread (vendor)ticket-creation-service.ts:175-203; tpl email-templates.ts:241-273
Ticket created → tenant confirmation (opt-in)tenantMT — Maintenance Request Received / tenantTicketCreatedEmailnotifyByEmailticket-creation-service.ts:220-277; tpl email-templates.ts:413-448
Inbound webhook reply to sendersenderRe: <subject> / vendorReplyEmail HTMLnotifyByEmailinbound-email.webhook.ts:1049-1066
Inbound webhook — AI generation failedsenderRe: <subject> / awayReplyEmail (warm "we're away")notifyByEmailinbound-email.webhook.ts:655-672; copy error-reply-copy.ts:28-32
Vendor submits quote (auto-approved, legacy single-time)tenantMT — Confirmation needednotifyTicketThread (tenant)SubmitQuoteTool.ts:237-247
Vendor submits quote (> threshold) → finance approvalapproval emailsMT — Approval Required — €X / approvalRequestEmailnotifyByEmailSubmitQuoteTool.ts:251-294; tpl email-templates.ts:142-182
Offer visit slots → tenant menutenantMT — Choose your visit time / slotMenuEmailnotifyTicketThread (tenant)OfferVisitSlotsTool.ts:175-187; tpl email-templates.ts:831-862
Tenant confirms time → lock-in vendor "proceed"vendorMT — You're cleared to proceed / vendorLockInConfirmationEmailnotifyTicketThread (vendor)lock-in.ts:107-127; tpl email-templates.ts:370-400
Lock-in → tenant "you're all set"tenantMT — Visit confirmed / plain, NO pricenotifyTicketThread (tenant)lock-in.ts:135-149
Tenant confirms, cost still pending → vendor "hold"vendorMT — Maintenance / plainnotifyTicketThread (vendor)ConfirmVisitTimeTool.ts:190-201
Tenant accepts multiple slots → vendor "which one?"vendorMT — Maintenance / plainnotifyTicketThread (vendor)ConfirmVisitTimeTool.ts:109-129
Tenant reschedule request → vendor relayvendorMT — tenant would like to adjust… / buildVendorRescheduleEmailnotifyTicketThread (vendor)RelayTenantRescheduleTool.ts:194-216,241-280
Vendor reschedules visit → tenant asktenantMT — Visit time update / outcome.tenantCopynotifyTicketThread (tenant)RescheduleVisitTool.ts:141-157
Vendor starts work → tenant noticetenantMT — Maintenance / plainnotifyTicketThread (tenant)StartWorkTool.ts:114-118
Vendor pauses work → tenant noticetenantMT — Maintenance / plainnotifyTicketThread (tenant)PauseWorkTool.ts:107-111
Vendor completes → tenant "work completed"tenantMT — Work Completed / tenantWorkCompletedEmail (money-blind)notifyTicketThread (tenant)CompleteJobTool.ts:283-303; tpl email-templates.ts:569-600
Vendor completes → vendor completion receiptvendorMT — Completion confirmed / plain (tool-sent, survives 60s fallback)notifyTicketThread (vendor)CompleteJobTool.ts:309-324
Cost overrun > threshold → escalationescalation emailsCost Overrun Alert: MT / plainnotifyByEmailCompleteJobTool.ts:249-256
Vendor declines job → admin alertescalation emailsVendor Declined Job - MT / plainnotifyByEmailDeclineJobTool.ts:120-129
Vendor declines job → tenant "reassigning"tenantMT — Maintenance / plain (no reason/vendor name)notifyTicketThread (tenant)DeclineJobTool.ts:133-144
New issue photo uploaded → vendor noticevendorMT — Maintenance / inline <img> HTMLnotifyTicketThread (vendor)UploadIssueImagesTool.ts:165-172
Vendor-response webhook quote → tenanttenantMT — 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 → tenanttenantMT — Maintenance / plainnotifyTicketThread (tenant)vendor-response.webhook.ts:280-283,336-339
Vendor-response webhook work_completed → tenanttenantMT — Work Completed / tenantWorkCompletedEmailnotifyTicketThread (tenant)vendor-response.webhook.ts:429-448

Outbound — approval, escalation, admin

TriggerToSubject / templateTransportSource
send_for_approval (> threshold) → financeapproval emailsMT — Approval Required — €X / approvalRequestEmail; Reply-To = AGENT_REPLY_TO_EMAIL or property@highfieldproperty.ienotifyByEmailSendForApprovalTool.ts:248-285
Approver replies YES/NO → confirmationapproverRe: <subject> / plainnotifyByEmailapprover-decision.ts:203-216
escalate_ticket → admin teamescalation emails🚨 Escalation: MT — <Type> / escalationNotificationEmailnotifyByEmailEscalateTicketTool.ts:117-137; tpl email-templates.ts:622-660
Escalation job (SLA/no-response/stale/expired/overrun) → adminescalation emails🚨 Escalation: MT — <Type> / escalationNotificationEmailnotifyByEmailescalation.job.ts:101-124
record_tenant_dispute → escalation contactsescalation emailsDispute raised on ticket MT / plainnotifyByEmailRecordTenantDisputeTool.ts:108-129
notify_tenant_access → tenanttenantMT — Maintenance / plainnotifyTicketThread (tenant)NotifyTenantAccessTool.ts:43-48
admin_resolve_escalation → tenant and/or vendortenant / vendorMT — Maintenance / admin's verbatim messagenotifyTicketThreadAdminResolveEscalationTool.ts:80-112
System cancel (reschedule-limit / no-response) → tenant + vendortenant + vendorMT — Request closed / MT — Maintenance / plainnotifyTicketThread (both)system-cancel.ts:85-107
Completion-feedback job — vendor "time to start"vendorMT — Time to start / vendorVisitStartReminderEmailnotifyTicketThread (vendor)completion-feedback.job.ts:93-111; tpl email-templates.ts:757-787
Completion-feedback job — vendor proof chasevendorMT — Completion photos & invoice / vendorCompletionReminderEmailnotifyTicketThread (vendor)completion-feedback.job.ts:114-128; tpl email-templates.ts:794-816
Completion-feedback job — tenant feedbacktenantMT — How was your maintenance experience? / tenantFeedbackEmailnotifyTicketThread (tenant)completion-feedback.job.ts:143-165; tpl email-templates.ts:715-744
Daily report job (09:00) → adminadmin emails📊 Daily Maintenance Report — <date> / dailyBriefEmail HTMLnotifyByEmail (once-per-day guard)daily-report.job.ts:358-397; tpl email-templates.ts:898-970
Weekly report job (Fri 17:00) → adminadmin emails📊 Weekly Maintenance Brief — <range> / weeklyBriefEmail HTMLnotifyByEmail (once-per-week guard)weekly-report.job.ts:80-97; tpl email-templates.ts:1003-1110
Admin Q&A reply rendered to HTML (postprocessor)adminconverts the LLM's Markdown reply to email-safe HTML in placegated on user.isAdminadmin-reply-format.postprocessor.ts:10-19; markdown-email.ts:172-286

AgentMail-direct paths (src/services/agentmail.ts)

OperationToNotesSource
sendEmail()always AGENTMAIL_OVERRIDE_TO (default stefan@heylua.ai); real recipient prefixed into subject [To: …]Safety override so live customers never get AgentMail-direct mailagentmail.ts:87-117
replyToEmail()thread of messageIdPOST /inboxes/{id}/messages/{msgId}/replyagentmail.ts:122-146
registerWebhook()n/aregisters message.received, idempotent via client_idagentmail.ts:179-191

⚠️ Note: agentmail.ts sendEmail/replyToEmail are not on any lifecycle path found via grep (only getAttachmentInfo is used, by the inbound webhook, which itself replies via notifyByEmail). 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 return skipped:'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 in auto_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_FORCE bypass.
  • Completion-feedback idempotency: startReminderSentAt/completionReminderSentAt/feedbackTenantSentAt stamps written only on successful send.
  • Escalation dedup: hasDuplicateEscalation + escalatedAt stamp.

Recipient resolution + fallbacks

  • Tenant: resolveTenantEmail(ticket)ticket.tenantEmailUser.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() = parsed APPROVER_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 & < >; markdownToEmailHtml escapes all text and allows only http(s)/mailto links; brief renderers esc() 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 use suppressTenantNotify/suppressVendorNotify source-level skips instead.

Gotchas & failure modes

  1. 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 reads Lua.request.webhook.payload (and payload.headers may be nested or flattened).
  2. Outbound threading is best-effort and undocumented. notifyByEmail sends inReplyTo/in_reply_to/parentMessageId/references under several guessed names. If Lua honors none, emails arrive unthreaded. The RFC-3834 outbound headers may be silently ignored.
  3. LUA_EMAIL_CHANNEL_ID unset → all outbound comes from chat@heylua.ai not property@highfieldproperty.ie — a real incident class.
  4. Anchor pins to the EARLIEST inbound, not newest — deliberately, to avoid threading into the platform's auto-titled "Intake message N" junk thread. resolveThreadSubject rejects "intake/email message" subjects.
  5. 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.
  6. Platform ~60s fallback drops the real reply on heavy turns (completion). CompleteJobTool sends a tool-level vendor receipt that pre-apologizes for any "technical difficulties" message.
  7. Expiring S3 attachment URLs poison history. email-channel-handler re-uploads every non-CDN image URL to cdn.heylua.ai on every turn; skipping this re-introduces the "technical difficulties every email" incident.
  8. Dead imports / no-op sends. escalation-response.webhook.ts imports email helpers but sends no email (all in-app User.send()). satisfactionSurveyEmail was removed. Don't assume these fire.
  9. Daily brief log table is capped (DAILY_BRIEF_MAX_LOG_ROWS) because 157 open tickets produced a 400 "request entity too large".
  10. Personal-domain filter asymmetry (rule above) silently drops a personal escalation inbox while the same address as APPROVER_EMAIL still receives briefs.

Full header tables: see Appendix — Headers.