Skip to content

Email

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.

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.

modules/invoicing/data/email_templates.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: true
FieldTypeMeaning
identifierCharStable handle used to look the template up in code.
nameCharHuman-readable label.
subjectCharJinja string rendered against the record.
contentTextJinja body. In YAML you point at a .jinja file (loaded as the field value).
modelManyToOne (ModelRegistry)The model the template renders against.
reportManyToOne (ReportAction)Optional. A report to generate as a PDF and attach to the email.
attachmentsManyToMany (Attachment)Optional static attachments.
from_email / to_email / cc_email / reply_emailCharOptional default addressing.

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 date filter that respects the recipient’s locale date format,
  • a money filter ({{ 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).

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.

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
)

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: true

send_now() flips the row through SendingSent (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_at with a linear backoff of 5 × retry_count minutes, until max_retries (default 3) is reached, after which next_retry_at is 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.

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 href so 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.

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.

FieldTypeMeaning
addressCharLocal-part of the inbound address, e.g. leads for leads@yourdomain.
target_modelCharModel a record is created in, e.g. CrmLead. Validated against the registry.
target_name_fieldCharField on the target that receives the email subject (default name).
defaultsJSONDefault field values for created records.
companyManyToOne (Company)Owning company.
activeBooleanWhether 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.

Incoming and outgoing servers are configured as records, with SMTP falling back to global config.

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 / References tracking id matches an existing Message,
  • otherwise routes the new mail through EmailRoute to 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: true

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:

  1. a per-user OutgoingMailServer (matching the acting user), then
  2. a database-level OutgoingMailServer (with user unset), then
  3. global config.yaml SMTP settings.

The relevant global keys:

KeyDefaultMeaning
SMTP_SERVERSMTP host. If unset and no server record exists, sending raises a UserError.
SMTP_PORT587SMTP port.
SMTP_USERNAMELogin user.
SMTP_PASSWORDLogin password.
SMTP_USE_SSLTrueUse SMTPS.
SMTP_FROM_EMAILFallback From address.
SMTP_FROM_NAMEFullfinityFallback From name.
BASE_URLPublic base URL; required for open/click tracking links.

Both server models expose test_connection() to validate credentials before saving.