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).
inherited_templateManyToOne (EmailTemplate)Optional. Set on an add-on that extends a base email (see below); empty for a base template.
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).

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.

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

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 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 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 collaboration 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. Each server has an auth_type that selects how it authenticates:

  • Password (default) — Basic Auth with username + 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 the integration_google and integration_microsoft365 integrations. 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.

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 / 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, auth_type, 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_SSLTrueImplicit 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_EMAILThe address mail is sent as. See “Which address mail is sent as” below.
SMTP_FROM_NAMEFullfinityDisplay name for mail with no author (password resets and similar). Authored mail uses the author’s name instead.
BASE_URLFallback 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.

Mail goes out as the identity the server is authenticated asSMTP_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.com

It 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_EMAIL unset (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_email on the Message and 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 with from_name for 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:

  1. the sending user’s signature (their signature field, when include_signature is on), else
  2. the company name, when use_company_branding is on, else
  3. 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.

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/document on queue_email / send_email (above).
  • Which models qualify is decided by the PortalAction records 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.

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:

  1. The base_url setting, 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.
  2. The BASE_URL config 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.

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.

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:

IntegrationProviderSMTP host / portIMAP host / port
integration_googleGmail / Google Workspacesmtp.gmail.com : 465 (SSL)imap.gmail.com : 993 (SSL)
integration_microsoft365Microsoft 365 / Outlooksmtp.office365.com : 587 (STARTTLS)outlook.office365.com : 993 (SSL)

One-time setup (admin):

  1. 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 uses SMTP.Send + IMAP.AccessAsUser.All + offline_access). Set the redirect URI to <BASE_URL>/gmail_mail_auth or <BASE_URL>/microsoft_mail_auth respectively.
  2. Go to Settings → Email and open Google or Microsoft 365, then paste the Client ID / Client Secret (Microsoft also takes a Tenantcommon, 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.