Email Channel
The email channel lets organizations send outbound emails from the messaging UI and receive inbound emails via Resend webhooks. Each org manages its own email addresses under a shared domain.
Add these variables to seed.env before running migrations:
COMM_EMAIL_DOMAIN=email.loquent.ioRESEND_API_KEY=re_your_api_key_hereRun migrations to create the org_email_address table, add the subject column to message, and seed the domain into core_conf:
just migrate && just generateConfigure a Resend webhook pointing to your server’s /resend/events endpoint for inbound email and delivery status events.
Org Email Addresses
Section titled “Org Email Addresses”Organizations create email addresses in Settings > Email. Each address combines a username with the shared domain: sales@email.loquent.io.
Data Model
Section titled “Data Model”pub struct OrgEmailAddress { pub id: Uuid, pub username: String, // "sales", "support" pub display_name: Option<String>, // "Sales Team" pub email: String, // computed: "{username}@{domain}" pub created_at: String,}The username field is globally unique across all organizations. Validation rules: 3–64 characters, lowercase alphanumeric with hyphens and dots, no leading/trailing/consecutive special characters.
API Endpoints
Section titled “API Endpoints”Create an Email Address
Section titled “Create an Email Address”POST /api/org-email-addresses| Parameter | Type | Required | Description |
|---|---|---|---|
username | String | Yes | Local part of the email address |
display_name | String | No | Friendly name shown in “From” header |
Returns 409 Conflict if the username is already taken.
List Email Addresses
Section titled “List Email Addresses”GET /api/org-email-addressesReturns all OrgEmailAddress records for the authenticated org.
Delete an Email Address
Section titled “Delete an Email Address”DELETE /api/org-email-addresses/:idValidates org ownership before deletion.
Sending Email
Section titled “Sending Email”Toggle the compose bar to Email mode from either the messaging view (/messaging/:id) or the contact details view (/contacts/:id). You provide a subject line, markdown body, and select which org email address to send from. If the contact has multiple email addresses, you choose the recipient.
Availability
Section titled “Availability”Email compose is available in both views when two conditions are met:
- The contact has at least one email address on file.
- The organization has at least one org email address configured.
The contact details view fetches org email addresses via get_org_email_addresses_api() and passes email_address_id, org_emails, contact_phones, and contact_emails as props to CommunicationFeed. The first org email address is selected as the default “From” address.
The backend converts the markdown body to styled HTML using styled_markdown_to_html(), wraps it in a branded email template via build_email_html(), and sends through the Resend API. The plain-text fallback is the raw markdown.
POST /api/messages/send| Parameter | Type | Required | Description |
|---|---|---|---|
contact_id | Uuid | Yes | Target contact |
channel | MessageChannel | Yes | Email |
body | String | Yes | Markdown message body |
email_address_id | Uuid | Yes | Org email address to send from |
subject | String | Yes | Email subject line |
contact_email_id | Uuid | No | Specific recipient email (defaults to preferred) |
The response includes the created Message with status: Queued and an external_id from Resend.
Outbound Flow
Section titled “Outbound Flow”- Resolve the org email address and format the sender:
"Sales Team <sales@email.loquent.io>" - Resolve the recipient’s email (explicit, preferred, or first available)
- Convert markdown → HTML, wrap in branded template
- Send via Resend API → receive
external_id - Create
Messagerecord withchannel: Email,direction: Outbound,status: Queued - Publish
messaging.message.newSSE event - Resend webhooks update status:
Queued → Sent → Delivered
Receiving Email
Section titled “Receiving Email”Inbound emails arrive at POST /resend/events as webhook payloads. The handler is registered in src/main.rs.
Webhook Payload
Section titled “Webhook Payload”pub struct ResendWebhookPayload { pub event_type: String, // "email.received", "email.sent", etc. pub data: serde_json::Value,}Inbound Flow
Section titled “Inbound Flow”- Resend sends
email.receivedwebhook to/resend/events - Parse the recipient username and match it against
org_email_addressto identify the org - Resolve or create a contact from the sender’s email via
resolve_contact_by_email() - Fetch the full email body from Resend:
GET https://api.resend.com/emails/receiving/{email_id} - Normalize RFC 2822 hard-wrapped text (collapse single newlines, preserve paragraph breaks)
- Create
Messagewithchannel: Email,direction: Inbound,status: Received - Publish
messaging.message.newSSE event - Update
contact.last_contacted_at, wake active plans, trigger text agent processing
Status Updates
Section titled “Status Updates”Resend sends status webhooks for outbound emails. The handler maps them to MessageStatus:
| Resend Event | MessageStatus |
|---|---|
email.sent | Sent |
email.delivered | Delivered |
email.bounced | Failed |
email.complained | Failed |
Each status update publishes a messaging.message.status SSE event so the UI reflects delivery state in real time.
HTML Email Rendering
Section titled “HTML Email Rendering”Emails in the conversation feed render their full HTML formatting — tables, styled text, links — directly inside message bubbles. Users toggle between rendered and plain-text views.
Data Model
Section titled “Data Model”The message table includes an html_body column (nullable text) that stores the raw HTML content:
pub struct Message { // ... existing fields ... /// Sanitized HTML body for email messages. pub html_body: Option<String>,}When creating a message, pass html_body in CreateMessageInput:
pub struct CreateMessageInput { // ... existing fields ... /// HTML version of the message body (emails only). pub html_body: Option<String>,}When HTML is Stored
Section titled “When HTML is Stored”HTML bodies are captured in three places:
| Source | How |
|---|---|
| Inbound emails | resend_events_api.rs fetches the full email via Resend API and stores the html_body from the response |
| Outbound emails (compose) | send_message_api.rs converts the markdown body to styled HTML and stores the result |
| Plan emails (automation) | execute_send_email_service.rs stores the HTML body generated for the plan email |
Sanitization
Section titled “Sanitization”All HTML is sanitized server-side when the Message struct is constructed from the database model. The sanitize_email_html() function in src/bases/email/sanitize.rs uses the ammonia crate to:
- Allow safe tags:
p,br,div,span, headings, lists,table/tr/td/th,a,strong,em,b,i,u,blockquote,pre,code,hr,font, and more - Allow safe attributes:
style,class,align,width,height,bgcolor,color,colspan,rowspan, and table-related attributes - Strip dangerous content:
<script>,<style>,<head>,<title>,<noscript>tags and their text content are fully removed - Block unsafe URLs: only
http:,https:, andmailto:schemes are allowed on<a>links - Add link safety: all links get
rel="noopener noreferrer"
use crate::bases::email::sanitize_email_html;
let safe_html = sanitize_email_html(raw_html);Sanitization runs at read time in Message::from_model(), not at write time, so the original HTML is preserved in the database.
UI Components
Section titled “UI Components”HtmlEmailBody
Section titled “HtmlEmailBody”Renders sanitized HTML inside a style-isolated container:
#[component]pub fn HtmlEmailBody( html: String, #[props(default)] plain_text: Option<String>,) -> ElementIf html is empty, falls back to displaying plain_text. The HTML is rendered via dangerous_inner_html inside a div.email-html-body container that uses all: initial to reset inherited styles.
Plain-Text Toggle
Section titled “Plain-Text Toggle”CommunicationFeedFilterBar includes a toggle button (visible on the “All” and “Emails” tabs) that switches all email messages between rendered HTML and plain text:
#[component]pub fn CommunicationFeedFilterBar( active: CommunicationFeedFilter, on_change: EventHandler<CommunicationFeedFilter>, #[props(default)] prefer_plain_text: bool, #[props(default)] on_toggle_plain_text: Option<EventHandler<bool>>,) -> ElementThe prefer_plain_text signal is owned by CommunicationFeed and passed down to each MessageEntry. When true, all email bubbles show the plain-text body regardless of whether an HTML body exists.
Style Isolation
Section titled “Style Isolation”The .email-html-body CSS class in tailwind.css resets all inherited styles so email HTML renders predictably:
.email-html-body { all: initial; display: block; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.5; color: var(--color-foreground);}Tables, links, blockquotes, headings, and lists all have scoped styles under this class that use the app’s CSS variables for consistent theming.
Key Files
Section titled “Key Files”| File | Purpose |
|---|---|
src/mods/org_email_address/ | Email address CRUD module |
src/mods/messaging/api/resend_events_api.rs | Inbound webhook handler |
src/mods/messaging/types/resend_webhook_type.rs | Webhook payload types |
src/bases/email/services/send_email_service.rs | send_email_from() for custom sender |
src/bases/email/conf/core/comm_email_core_conf.rs | Domain configuration |
src/bases/email/templates/ | HTML email template builder |
src/mods/contact/services/resolve_contact_by_email_service.rs | Auto-create contacts from inbound email |
src/mods/settings/components/org_email_addresses_section_component.rs | Settings UI for managing addresses |
src/mods/contact/components/contact_details_component.rs | Contact details email prop wiring |
src/mods/contact/views/contact_details_view.rs | Fetches org emails for contact details |
src/bases/email/sanitize.rs | HTML sanitization via ammonia |
src/mods/messaging/components/html_email_body_component.rs | HtmlEmailBody renderer component |
src/mods/plan/services/execute_send_email_service.rs | Plan executor email support |
migration/src/m20260325_120000_message_add_html_body.rs | Migration adding html_body column |