Fullfinity has a complete email stack built into core: Jinja-rendered email templates (optionally with an auto-attached PDF report), an outbound queue with retry and open/click tracking, inbound routing that turns incoming mail into records, and configurable IMAP/SMTP servers. This page covers each piece and the exact APIs.
Email Templates
Section titled “Email Templates”An EmailTemplate is a data record holding a Jinja subject and body, bound to a model. When
rendered, the template is given a serialized instance of that model as its context, so any
field on the record is available as a Jinja variable.
Defining a Template (YAML)
Section titled “Defining a Template (YAML)”- depends: - ../templates/report_templates.yaml data_type: EmailTemplate identifier: financial_document_send_template name: Send Financial Document subject: '{{ type }} {{ number }} from {{ company.name }}' content: ../templates/email_financial_document.jinja model: FinancialDocument module: invoicing report: financial_document_print apply_once: trueFields
Section titled “Fields”| Field | Type | Meaning |
|---|---|---|
identifier | Char | Stable handle used to look the template up in code. |
name | Char | Human-readable label. |
subject | Char | Jinja string rendered against the record. |
content | Text | Jinja body. In YAML you point at a .jinja file (loaded as the field value). |
model | ManyToOne (ModelRegistry) | The model the template renders against. |
report | ManyToOne (ReportAction) | Optional. A report to generate as a PDF and attach to the email. |
attachments | ManyToMany (Attachment) | Optional static attachments. |
from_email / to_email / cc_email / reply_email | Char | Optional default addressing. |
Rendering
Section titled “Rendering”Look the template up by its identifier and call render_template:
EmailTemplate = get_model("EmailTemplate")template = await EmailTemplate.filter(identifier="financial_document_send_template").first()
rendered = await template.render_template(template, "FinancialDocument", document.id)# rendered == {"subject": "...", "content": "..."}render_template(self, email_template, class_name, instance_id) fetches the instance (with all
its relational fields prefetched), serializes it, and renders the subject and body. The Jinja
environment is enriched with:
- a
datefilter that respects the recipient’s locale date format, - a
moneyfilter ({{ amount | money }}) using the record’s company currency, - a
t(...)translation function plus automatic translation of the rendered output into the recipient’s language (resolved from the contact’s, then the current user’s, language).
Sending: The Outbound Queue
Section titled “Sending: The Outbound Queue”Email, SMS and WhatsApp share one outbound layer on the Message table — there is no
separate “email queue” table. A queued send is simply a Message row with
send_state="Queued" and direction="Outbound". A single cron drains it.
Queuing an email
Section titled “Queuing an email”Message.queue_email(...) is the producer. Its signature:
await get_model("Message").queue_email( author, # User instance (sender); its contact's email becomes From contact_emails, # list[str] of recipient addresses subject, # str body, # str (HTML) attachments=None, # list[Attachment] tracking_identifier=None, # str; enables open/click tracking + reply threading use_company_branding=True, # wrap body in the company-branded template at send time include_signature=True, # append the author's signature at send time company=None, # Company for branding message=None, # an existing chatter Message to reuse as the send job scheduled_at=None, # delay sending until this time (defaults to now))If you pass an existing message, that chatter row becomes the queued send (one row is both
the timeline entry and the send job). Otherwise a standalone email Message is created.
There is also a higher-level helper, OutgoingMailServer.send_email(...), which queues by
default and can send immediately with send_immediately=True:
await get_model("OutgoingMailServer").send_email( author=contact, # Contact instance of the sender contact_emails=["a@b.com"], subject="Hello", body="<p>Hi</p>", attachments=[], tracking_identifier="abc123", use_company_branding=True, include_signature=True, company=company, message=None, send_immediately=False, # True bypasses the queue and sends inline)What flushes the queue
Section titled “What flushes the queue”Queued messages are sent by a cron that calls Message.cron_flush():
async def cron_flush(cls): """Send queued messages that are due, and retry failed ones whose backoff elapsed."""It selects up to 100 outbound rows that are Queued (and due, per scheduled_at) or Failed
with an elapsed next_retry_at, oldest first, and calls send_now() on each. The cron entry
itself ships in core — see the Cron / Scheduled Jobs page:
- data_type: CronJob identifier: cron_email_queue_processor name: Process Outbound Messages model: Message method: cron_flush frequency: 1 frequency_unit: Minutes active: trueSend lifecycle and retry
Section titled “Send lifecycle and retry”send_now() flips the row through Sending → Sent (recording sent_at), dispatching the
actual delivery by channel (_deliver_email for email, which builds the final body —
signature, tracking pixel, link wrapping applied transiently — and hands off to SMTP). On
failure it calls _mark_failed, which:
- increments
retry_count, - sets
send_state="Failed", - schedules
next_retry_atwith a linear backoff of5 × retry_countminutes, untilmax_retries(default 3) is reached, after whichnext_retry_atis left null.
To re-queue a failed message manually, call await message.action_retry() (resets the retry
counter and sets it back to Queued); the cron picks it up on its next run.
Open / Click Tracking
Section titled “Open / Click Tracking”Tracking is keyed on the message’s tracking_identifier. When that is set and a BASE_URL
is configured, at send time the email body is rewritten to:
- include a 1×1 tracking pixel pointing at
/api/email/track/open/<tracking_id>, and - wrap every
hrefso it routes through/api/email/track/click/<tracking_id>?url=....
These public routes record open_count / opened_at and click_count / clicked_at on the
Message row (the click route then 302-redirects to the original URL). A bounce webhook
endpoint (/api/email/webhook/bounce) and a queue-stats endpoint
(/api/email/queue/stats) are also provided.
Inbound: Email Routes
Section titled “Inbound: Email Routes”An EmailRoute turns an incoming email sent to a given alias into a new record in a target
model, logging the email as that record’s first chatter message. It routes only new inbound
emails — replies are threaded onto existing records automatically by tracking id.
Fields
Section titled “Fields”| Field | Type | Meaning |
|---|---|---|
address | Char | Local-part of the inbound address, e.g. leads for leads@yourdomain. |
target_model | Char | Model a record is created in, e.g. CrmLead. Validated against the registry. |
target_name_field | Char | Field on the target that receives the email subject (default name). |
defaults | JSON | Default field values for created records. |
company | ManyToOne (Company) | Owning company. |
active | Boolean | Whether the route is live. |
EmailRoute.route_inbound(recipients, sender_email, subject, body) finds the first active
route whose address matches a recipient’s local-part, creates the target record (populating
common fields like email_from / email / description / company when the target model
has them), and logs the inbound email as a Message on the new record.
Mail Servers (IMAP / SMTP)
Section titled “Mail Servers (IMAP / SMTP)”Incoming and outgoing servers are configured as records, with SMTP falling back to global config.
Incoming (IMAP)
Section titled “Incoming (IMAP)”IncomingMailServer records hold server, port, use_ssl, username, password. A cron
calls fetch_emails_from_all_servers(), which fetches unread mail from each server. For each
message it:
- skips auto-responders / out-of-office / bounces (loop guard),
- threads replies onto the matching record when an
In-Reply-To/Referencestracking id matches an existingMessage, - otherwise routes the new mail through
EmailRouteto create a record, - leaves a mail unread for manual handling if nothing matched.
The fetch cron ships in core:
- data_type: CronJob identifier: cron_incoming_mail_fetch name: Fetch Incoming Email model: IncomingMailServer method: fetch_emails_from_all_servers frequency: 5 frequency_unit: Minutes active: trueOutgoing (SMTP)
Section titled “Outgoing (SMTP)”OutgoingMailServer records hold server, port, use_ssl, username, password, and an
optional user (for a per-user sending account). When sending, SMTP config is resolved with
this priority:
- a per-user
OutgoingMailServer(matching the acting user), then - a database-level
OutgoingMailServer(withuserunset), then - global
config.yamlSMTP settings.
The relevant global keys:
| Key | Default | Meaning |
|---|---|---|
SMTP_SERVER | — | SMTP host. If unset and no server record exists, sending raises a UserError. |
SMTP_PORT | 587 | SMTP port. |
SMTP_USERNAME | — | Login user. |
SMTP_PASSWORD | — | Login password. |
SMTP_USE_SSL | True | Use SMTPS. |
SMTP_FROM_EMAIL | — | Fallback From address. |
SMTP_FROM_NAME | Fullfinity | Fallback From name. |
BASE_URL | — | Public base URL; required for open/click tracking links. |
Both server models expose test_connection() to validate credentials before saving.