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). |
inherited_template | ManyToOne (EmailTemplate) | Optional. Set on an add-on that extends a base email (see below); empty for a base template. |
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).
Extending an email (add-ons)
Section titled “Extending an email (add-ons)”An email body is an extensible surface, exactly like a report template:
another module ships an EmailTemplate whose inherited_template points at the base and whose
content is patch directives ({% add/replace/attributes %}) targeting the base’s stable handles —
a [data-anchor]/#id the base declares, or a field:<chain> binding. Extensions are applied at
send time in module-dependency order. Base bodies must expose those handles (every <table>,
{% for %} wrapper, and top-level section carries an anchor); the check --only templates gate and
the email save-hook enforce both the extension targets and the base coverage. See
Making a report extensible for the anchor/field: contract —
it is identical for emails.
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 collaboration Message to reuse as the send job scheduled_at=None, # delay sending until this time (defaults to now) model=None, # the model this email is about, e.g. "SaleOrder" document=None, # that record's id contacts=None, # recipient Contact records/ids)If you pass an existing message, that collaboration row becomes the queued send (one row is both
the timeline entry and the send job). Otherwise a standalone email Message is created.
Anchor a programmatic send with model/document. A send raised from a form carries them
for free; one raised in code has no form behind it. Anchoring is what lets the delivery add a
portal link for the record (see Portal links below), and it
files the mail on the record’s timeline. contacts names who was written to, which the delivery
reads to translate the chrome it adds into the reader’s language.
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 model="SaleOrder", # the record this email is about document=order.id, contacts=[order.contact],)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 the install
knows its public URL (see The install’s public URL), 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 collaboration 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. Each server has an auth_type that selects how it authenticates:
Password(default) — Basic Auth withusername+password, over SMTPS/IMAPS or (for OAuth on non-SSL ports) STARTTLS. Use this for any standard relay: your own mail server, SendGrid, Mailgun, Amazon SES, Postmark, etc.Gmail/Microsoft 365— OAuth 2.0 (XOAUTH2). Added by theintegration_googleandintegration_microsoft365integrations. Google and Microsoft have both disabled Basic Auth for SMTP/IMAP, so mailboxes on those providers must authenticate with a short-lived OAuth token — see OAuth mailboxes below.
Incoming (IMAP)
Section titled “Incoming (IMAP)”IncomingMailServer records hold server, port, use_ssl, auth_type, username,
password (and OAuth token fields when connected). 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, auth_type, 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 | Implicit TLS on connect (port 465). False opens a plain connection with no STARTTLS upgrade on the password path — don’t put credentials on port 587 that way. |
SMTP_FROM_EMAIL | — | The address mail is sent as. See “Which address mail is sent as” below. |
SMTP_FROM_NAME | Fullfinity | Display name for mail with no author (password resets and similar). Authored mail uses the author’s name instead. |
BASE_URL | — | Fallback public base URL, for a process that has never served a request. Normally leave it unset — see The install’s public URL. |
Both server models expose test_connection() to validate credentials before saving.
Which address mail is sent as
Section titled “Which address mail is sent as”Mail goes out as the identity the server is authenticated as — SMTP_FROM_EMAIL, or the
connected mailbox of an OutgoingMailServer record — carrying the author’s name, with a
Reply-To back to the author:
From: Admin <notifications@example.com>Reply-To: admin@customer-domain.comIt is not sent as the author’s own address. A relay authenticated as notifications@example.com
is not authorized to send for the author’s domain, so SPF fails there and DMARC cannot align —
the relay rejects the message or the recipient’s provider files it as spam.
Two things opt out of the rewrite:
- Leave
SMTP_FROM_EMAILunset (or use a server record with no login) and no sending identity is declared, so mail sends as the author’s address unchanged. - Set
from_emailon theMessageand it is sent as exactly that, header and envelope both, untouched — this is how a campaign sends under its own address, whose SPF record the sending domain’s admin has set up. Pair it withfrom_namefor the display name.
Message.from_email therefore means an explicit sender override on outbound mail; it is not
set to the author’s address, which the author field already records. On inbound mail the
same field holds the real external sender, which is often the only record of them — such a
sender frequently has no contact record. Read from_display for a “From” column that resolves
correctly in all three cases.
How an email closes — don’t sign off in your template
Section titled “How an email closes — don’t sign off in your template”Every email gets exactly one sign-off, appended by the send:
- the sending user’s signature (their
signaturefield, wheninclude_signatureis on), else - the company name, when
use_company_brandingis on, else - nothing — an unbranded send carries its own footer.
So an email template must not sign itself off. A branded email already shows the company in
the wrapper’s header and again in its footer address block; a template that closed with
{{ company.name }} printed it a third time, and the sender’s signature underneath made four.
Write the body and let it end — the closing is added for you. Naming the company inside a
sentence (“your statement of account from {{ company.name }}”) is prose, not a sign-off, and is
fine.
Portal links on customer mail
Section titled “Portal links on customer mail”When the Portal app is installed and the message is anchored to a record whose model is published to the portal, the send appends a “View Online” button linking to that document — above the sign-off, in the recipient’s language. The link is a magic link: the customer opens the live document with no account and no login.
You get this for free. There is nothing to add to your email template and no per-model wiring:
- A send raised from a form (an action returning
{"type": "send_message", ...}) is anchored automatically, so quotes, invoices, orders, statements and tickets all carry the button. - A send raised in code needs
model/documentonqueue_email/send_email(above). - Which models qualify is decided by the
PortalActionrecords a module ships — the same data that puts a document type in the portal navigation. Publish a model to the portal and its mail gets links; don’t, and it doesn’t. See Portal.
Three deliberate exclusions:
- An install that doesn’t know its public URL emits no button — a relative href is useless in an inbox, so nothing is appended rather than a dead link. See The install’s public URL below; it is auto-detected, so this normally resolves on its own.
- A body that already contains a
/portal/link is left alone. If your template places the link itself, or the mail is a payment-link mail that carries its own portal URL, nothing is appended — the customer is never offered two destinations for one document. - A message with no customer to name gets no link (an unanchored password reset, an internal note, a record whose contact field is empty).
One token is issued per document and recipient, so resending a quote hands back the same URL
rather than minting a new one, and each resend pushes its expiry out. Revoking a token
(PortalAccess.revoke()) kills that customer’s link without touching anyone else’s.
The install’s public URL
Section titled “The install’s public URL”Every link that leaves the app — the “View Online” button, open/click tracking, a campaign’s unsubscribe link, a booking link a user copies out of a form — is absolute, so the install has to know the URL the outside world reaches it on. It resolves in this order:
- The
base_urlsetting, stored per database. It is auto-detected from the origin of each incoming request, so an ordinary install needs no setup at all. Override it under Settings → General when auto-detection would get it wrong (behind a proxy that rewrites the Host, or a public domain that differs from the one staff use) and tick Lock Base URL so a later request can’t overwrite your value. - The
BASE_URLconfig key, only as a fallback for a process that has never served a request — a worker that starts cold and flushes the mail queue before any request arrives. It lives in one file per server, so an install serving several databases cannot have one right answer there; prefer the setting.
Where neither is known, code that builds an outbound link emits nothing rather than a relative href that dies outside a browser tab already on the app.
In a template, {{ base_url }} is the same resolved value (see
Rendering) — use it rather than reading config yourself.
Immediate vs queued sends
Section titled “Immediate vs queued sends”send_immediately controls when delivery runs, never what is delivered. Both paths run the
same delivery, so an immediate send gets the sign-off, the open-tracking pixel, click-wrapped
links, and a send_state on the row exactly like a queued one — the only difference is that it
runs inline instead of on the cron, and a delivery failure is raised to the caller rather than
recorded on the row for retry.
OAuth mailboxes (Gmail & Microsoft 365)
Section titled “OAuth mailboxes (Gmail & Microsoft 365)”Google and Microsoft have disabled Basic Auth (username + password) for SMTP and IMAP, so a Gmail or Microsoft 365 mailbox authenticates with a short-lived OAuth access token over the SASL XOAUTH2 mechanism instead. Two integrations add this capability; install the one you need from the Apps screen, then set it up from Settings → Email:
| Integration | Provider | SMTP host / port | IMAP host / port |
|---|---|---|---|
integration_google | Gmail / Google Workspace | smtp.gmail.com : 465 (SSL) | imap.gmail.com : 993 (SSL) |
integration_microsoft365 | Microsoft 365 / Outlook | smtp.office365.com : 587 (STARTTLS) | outlook.office365.com : 993 (SSL) |
One-time setup (admin):
- Register an OAuth app with the provider — a Google Cloud OAuth 2.0 Client, or an Azure AD
app registration — authorizing the mail scopes (Gmail uses the restricted
https://mail.google.com/scope; Microsoft usesSMTP.Send+IMAP.AccessAsUser.All+offline_access). Set the redirect URI to<BASE_URL>/gmail_mail_author<BASE_URL>/microsoft_mail_authrespectively. - Go to Settings → Email and open Google or Microsoft 365, then paste the
Client ID / Client Secret (Microsoft also takes a Tenant —
common, or a specific directory ID). These are install-wide: one app serves every mailbox, because the app identifies this deployment to the provider, not the mailbox.
Connecting a mailbox: the quickest route is Connect a Mailbox on the provider screen —
it creates the outgoing and incoming servers with the provider’s SMTP/IMAP endpoints already
filled in, and one consent authorizes both (the mail scope covers SMTP and IMAP alike, so
there is no reason to ask twice). To connect an existing server instead, set its auth_type
to Gmail or Microsoft 365, save, then click Connect.
Either way the callback stores the refresh token on the record and fills in the connected
mailbox address; the framework refreshes the access token automatically on every send/fetch.
The envelope sender is forced to the connected mailbox (Gmail/Microsoft reject a mismatched
From).
Connect as many mailboxes as you need — each is its own pair of server records holding its own
tokens, all authorized against the same app. Which one a given message uses is resolved most
specific first: user + company → company → user → global.
Adding another OAuth provider. The transport is generic: core’s mail models declare an
auth_type Selection and an overridable async def _resolve_access_token(self) hook that
returns a valid access token. A provider module contributes its label via
_selection_add = {"auth_type": ["<Label>"]} on an __inherit__ of both mail-server models,
overrides _resolve_access_token to handle its own auth_type (calling super() for the
rest), and adds a connect action plus an OAuth callback route. integration_google is the
reference implementation.