Shared Tilo email design, editable copy, previews and generated service adapters
  • JavaScript 54.7%
  • HTML 44%
  • Python 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Quinten 5f54da84bf
All checks were successful
Email design / validate (push) Successful in 32s
Email design / native (push) Successful in 55s
Merge pull request 'Use direct email copy and name workspace invitations' (#3) from fix/plain-email-copy into main
2026-10-26 05:04:10 +00:00
.forgejo/workflows fix: run native CI without a Node checkout dependency 2026-10-25 16:21:16 -07:00
content feat(email)!: clarify copy and name workspace invitations 2026-10-25 22:04:05 -07:00
design feat(email)!: clarify copy and name workspace invitations 2026-10-25 22:04:05 -07:00
generated feat(email)!: clarify copy and name workspace invitations 2026-10-25 22:04:05 -07:00
scripts feat: share Tilo email copy, layouts, previews and service adapters 2026-10-25 16:01:10 -07:00
src feat(email)!: clarify copy and name workspace invitations 2026-10-25 22:04:05 -07:00
tests feat(email)!: clarify copy and name workspace invitations 2026-10-25 22:04:05 -07:00
.gitignore feat: share Tilo email copy, layouts, previews and service adapters 2026-10-25 16:01:10 -07:00
bun.lock feat: share Tilo email copy, layouts, previews and service adapters 2026-10-25 16:01:10 -07:00
LICENSE feat: share Tilo email copy, layouts, previews and service adapters 2026-10-25 16:01:10 -07:00
package.json feat: share Tilo email copy, layouts, previews and service adapters 2026-10-25 16:01:10 -07:00
README.md feat(email)!: clarify copy and name workspace invitations 2026-10-25 22:04:05 -07:00

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. Run bun 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/ and src/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.