Skip to content

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:

Terminal window
COMM_EMAIL_DOMAIN=email.loquent.io
RESEND_API_KEY=re_your_api_key_here

Run migrations to create the org_email_address table, add the subject column to message, and seed the domain into core_conf:

Terminal window
just migrate && just generate

Configure a Resend webhook pointing to your server’s /resend/events endpoint for inbound email and delivery status events.

Organizations create email addresses in Settings > Email. Each address combines a username with the shared domain: sales@email.loquent.io.

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.

POST /api/org-email-addresses
ParameterTypeRequiredDescription
usernameStringYesLocal part of the email address
display_nameStringNoFriendly name shown in “From” header

Returns 409 Conflict if the username is already taken.

GET /api/org-email-addresses

Returns all OrgEmailAddress records for the authenticated org.

DELETE /api/org-email-addresses/:id

Validates org ownership before deletion.

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.

Email compose is available in both views when two conditions are met:

  1. The contact has at least one email address on file.
  2. 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
ParameterTypeRequiredDescription
contact_idUuidYesTarget contact
channelMessageChannelYesEmail
bodyStringYesMarkdown message body
email_address_idUuidYesOrg email address to send from
subjectStringYesEmail subject line
contact_email_idUuidNoSpecific recipient email (defaults to preferred)

The response includes the created Message with status: Queued and an external_id from Resend.

  1. Resolve the org email address and format the sender: "Sales Team <sales@email.loquent.io>"
  2. Resolve the recipient’s email (explicit, preferred, or first available)
  3. Convert markdown → HTML, wrap in branded template
  4. Send via Resend API → receive external_id
  5. Create Message record with channel: Email, direction: Outbound, status: Queued
  6. Publish messaging.message.new SSE event
  7. Resend webhooks update status: Queued → Sent → Delivered

Inbound emails arrive at POST /resend/events as webhook payloads. The handler is registered in src/main.rs.

pub struct ResendWebhookPayload {
pub event_type: String, // "email.received", "email.sent", etc.
pub data: serde_json::Value,
}
  1. Resend sends email.received webhook to /resend/events
  2. Parse the recipient username and match it against org_email_address to identify the org
  3. Resolve or create a contact from the sender’s email via resolve_contact_by_email()
  4. Fetch the full email body from Resend: GET https://api.resend.com/emails/receiving/{email_id}
  5. Normalize RFC 2822 hard-wrapped text (collapse single newlines, preserve paragraph breaks)
  6. Create Message with channel: Email, direction: Inbound, status: Received
  7. Publish messaging.message.new SSE event
  8. Update contact.last_contacted_at, wake active plans, trigger text agent processing

Resend sends status webhooks for outbound emails. The handler maps them to MessageStatus:

Resend EventMessageStatus
email.sentSent
email.deliveredDelivered
email.bouncedFailed
email.complainedFailed

Each status update publishes a messaging.message.status SSE event so the UI reflects delivery state in real time.

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.

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>,
}

HTML bodies are captured in three places:

SourceHow
Inbound emailsresend_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

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:, and mailto: 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.

Renders sanitized HTML inside a style-isolated container:

#[component]
pub fn HtmlEmailBody(
html: String,
#[props(default)] plain_text: Option<String>,
) -> Element

If 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.

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>>,
) -> Element

The 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.

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.

FilePurpose
src/mods/org_email_address/Email address CRUD module
src/mods/messaging/api/resend_events_api.rsInbound webhook handler
src/mods/messaging/types/resend_webhook_type.rsWebhook payload types
src/bases/email/services/send_email_service.rssend_email_from() for custom sender
src/bases/email/conf/core/comm_email_core_conf.rsDomain configuration
src/bases/email/templates/HTML email template builder
src/mods/contact/services/resolve_contact_by_email_service.rsAuto-create contacts from inbound email
src/mods/settings/components/org_email_addresses_section_component.rsSettings UI for managing addresses
src/mods/contact/components/contact_details_component.rsContact details email prop wiring
src/mods/contact/views/contact_details_view.rsFetches org emails for contact details
src/bases/email/sanitize.rsHTML sanitization via ammonia
src/mods/messaging/components/html_email_body_component.rsHtmlEmailBody renderer component
src/mods/plan/services/execute_send_email_service.rsPlan executor email support
migration/src/m20260325_120000_message_add_html_body.rsMigration adding html_body column