- JavaScript 54.7%
- HTML 44%
- Python 1.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| content | ||
| design | ||
| generated | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| bun.lock | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
Tilo emails
One home for Tilo's email design and copy. The generated templates are consumed by Authentik and the coffee workspace service; they do not contain credentials or subscriber records.
Edit text and look at the result
bun install --frozen-lockfile --ignore-scripts
bun run dev
Open http://127.0.0.1:4173/. Choose a template, switch between desktop/mobile
and light/dark preview chrome, edit its JSON file and refresh. The gallery shows
its subject, HTML, plaintext and editable fixture variables. Preview links use
example.invalid; the server binds only to loopback and never sends mail.
- Copy:
content/*.json— subject, preheader, heading, paragraphs, CTA, fine print and footer links. Each gallery card names its source. - Other native Authentik messages:
content/native/authentik.json— OTP, notifications, invitations and the administrative test message. Runbun run preview:native, then choose Native Authentik examples in the gallery. These six snapshots use real pinned Django and nonfunctional fixture data; regenerate them after edits. Their CID logo is a preview stand-in. - Brand:
design/brand.json— shared palette, type, spacing and senders. - Layout:
src/partials/andsrc/templates/_body.mjml. - Campaign configuration:
design/delivery.json— the opted-in updates segment, not a subscriber export.
Coffee's active messages are coffee-confirm-{en,de},
coffee-invite-{en,de}, and coffee-removed-{en,de}. English and German are
preserved. The other product templates are reusable building blocks; an available
welcome template does not itself enroll or email anyone.
Build and verify
bun run build
bun test
bun run check
bun run test:native
bun run screenshots
build updates the checked-in generated/ artifacts. check fails on stale
artifacts. Commit source and generated changes together. Do not edit generated
files directly.
The explicit native check uses the pinned prebuilt Authentik image with no
network, services, database, SMTP or credentials. It fails, never skips, if
its runtime is unavailable. Pull the image shown in
scripts/review-native.mjs once if needed. CI also runs the same Python checks
directly inside that image, including the real vendor children and hostile
fixture values.
The source HTML is responsive table-based MJML with Outlook fallbacks. There are no remote fonts or tracking pixels. Emails intentionally use the warm light paper palette; dark preview chrome is not a claim that every mail client's forced inversion behaves identically. Screenshots are browser previews, not an Outlook/Gmail certification.
Production adapters
Authentik
generated/authentik/configmap.yaml supplies the public sender and the two
account EmailStage subjects. Credentials remain in the deployment's encrypted
secrets. The account/reset HTML and plaintext derive from the same site artifacts;
their native links and expiry values are preserved. The native-compatible
email/base.html and four additional pairs brand OTP, event notifications,
invitations and setup/test messages, retaining native optional fields and
escaping. tilo_base.html is a legacy compatibility artifact, not a required
production dependency.
Coffee
generated/coffee/ retains the room service's existing file/variable contract:
layout.html, English/German confirmation, invitation and removal pairs,
and a public sender ConfigMap. The service loads these from a read-only
directory; changing copy does not require recompiling Rust. Consent, token
validation, invitation permissions, rate limits and durable delivery remain the
service's responsibility, not the template renderer's.
The invitation preview now requires the inviter’s display name as well as the
workspace name. Coffee export schema 2 records the required slots and emits
{{inviter_name}}. The current service adapter does not supply that field yet:
these revised invitation artifacts must not be deployed until the sender passes
and escapes the real inviter’s name. Do not substitute “someone” or a sample name.
The preview fixture uses Ada inviting someone to Lounge; it is not live data.
The two senders are Tilo Login login@no-reply.tilo.so and
Tilo hello@tilo.coffee. Both sending domains were verified in Resend during
integration; this does not assert an inbound mailbox at hello.
Resend open/click tracking is disabled on both domains. Coffee has its own
sending-only credential restricted to its domain. No credentials belong here.
Deployment
Infra pins this public repository at apps/authentik/emails; Authentik and
coffee both consume that one pin. After publishing a reviewed source/generated
commit here, update that submodule pin in PodestaAI/infra, validate the affected
overlays and deploy through its normal GitOps review. Only that pin changes for
copy or brand updates. The first room-service runtime adapter needs its tested
server image before deployment; later copy changes need no server image build.
Announcements: draft first, never surprise subscribers
bun run campaign:export
RESEND_API_KEY_FILE=/private/path/key bun run campaign:draft --consent-confirmed
The first command writes HTML, plaintext and a Resend payload under
artifacts/campaigns/. The second creates an unsent draft using the
configured updates segment. The private credential file must have mode 0600
(or stricter), stay outside this repository and have the provider permissions
needed for drafts. Use SOPS/your secret manager to provision it.
Both commands preserve Resend's managed unsubscribe variable; bulk templates
cannot build without a visible unsubscribe action. The publisher hard-codes
send: false and has no send/schedule option. Review the draft and explicitly
send or schedule it in Resend. Resend manages contact opt-outs, delivery reports,
bounce/complaint suppressions and one-click unsubscribe for broadcasts.
A workspace waitlist request is consent for that workspace's confirmation and invitation, not consent for product marketing. There is no automatic export of waitlist members into the updates segment and no existing subscribers are mailed by installation.
JavaScript consumers
import { renderEmail } from '@tilo/emails';
const message = renderEmail('account-confirmation', {
url: verificationUrl,
expiresIn: '30 minutes',
});
// message.subject, message.from, message.html, message.text
The runtime reads no files, environment, credentials or network. It escapes dynamic HTML, preserves plaintext query strings, rejects unknown/missing variables, unsafe URLs and header injection. URLs are HTTPS; loopback HTTP requires an explicit preview option. Native certificate-chain invitation URLs up to 20,000 characters remain supported.
Do not pass arbitrary HTML as copy or variables. Native token handling and MFA stay in Authentik/the room service; the generator never invents authentication.