Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,114 @@ When in doubt, apply these in order:

---

## 15. Tailwind v4 integration

The marketing site (`site/`) uses **Tailwind v4** wired through `@tailwindcss/vite`. There is no `tailwind.config.{js,ts}` — Tailwind v4 is **CSS-first**: tokens declared in `@theme {}` inside [`site/src/styles/tokens.css`](site/src/styles/tokens.css) automatically become utilities (`bg-brand-primary`, `text-ink-700`, `rounded-lg`, …).

### 15.1 Wiring

- `astro.config.mjs` → `vite: { plugins: [tailwindcss()] }`.
- `site/src/styles/tokens.css` is the single source of truth:
1. **Tailwind imports are split, NOT `@import "tailwindcss"`** :
```css
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities);
```
Preflight is **intentionally omitted**. The full `@import "tailwindcss"` pulls a global CSS reset that resets `h1`/`p`/`ul` margins and list markers — that mangles Starlight's docs typography (heading sizes collapse, lists lose bullets, links default-color). Starlight ships its own scoped reset; ours adds nothing. If a marketing component needs a real reset, scope it manually with `.reset { all: revert; }` etc.
2. `@variant dark (&:where([data-theme='dark'], [data-theme='dark'] *))` rebinds Tailwind's built-in `dark:` variant to our `[data-theme]` attribute (instead of `prefers-color-scheme`), so it stays in sync with `ThemeToggle.astro` + Starlight's theme provider.
3. `@theme { … }` declares **every** token Tailwind should expose as utilities.
4. `:root[data-theme='dark']` overrides the same `--color-*` vars for dark mode — utilities and raw `var(--color-…)` references both flip automatically.
5. Legacy aliases (`--brand-primary`, `--space-4`, …) are kept as `var(--color-brand-primary)` etc. so older components written before Tailwind landed keep working without a rewrite.

### 15.2 Token → utility mapping

Tailwind v4 generates utilities from the prefix of each `--*-name` declared in `@theme`. The naming convention is **enforced by Tailwind**, so it's worth memorizing.

| `@theme` prefix | Utilities generated | Example |
|----------------|---------------------|---------|
| `--color-*` | `bg-*`, `text-*`, `border-*`, `ring-*`, `from-*`, `to-*`, `via-*`, `outline-*`, `decoration-*`, `accent-*`, `caret-*`, `divide-*`, `placeholder-*` | `bg-brand-primary`, `text-ink-700` |
| `--font-*` | `font-*` | `font-display`, `font-mono` |
| `--text-*` | `text-*` (typography scale) | `text-md`, `text-3xl` |
| `--spacing` (single) | `p-N`, `m-N`, `gap-N`, `w-N`, `h-N`, etc. — `N × spacing` | `p-4` = `4 × 4px` = `16px` |
| `--radius-*` | `rounded-*` | `rounded-md`, `rounded-full` |
| `--shadow-*` | `shadow-*` | `shadow-md`, `shadow-lg` |
| `--container-*` | `max-w-*`, `mx-auto` containers | `max-w-prose`, `max-w-main` |
| `--breakpoint-*` | responsive variants | `sm:`, `md:`, `lg:`, `xl:` |

### 15.3 When to use utility vs CSS var

- **Tailwind utility** — for layout, spacing, typography, color in JSX-style markup. Reads cleanly inline. Default choice for new components.
- **Raw `var(--color-…)`** — for SVG fills/strokes, CSS-in-JS dynamic values, gradients with `color-mix()`, anywhere a utility doesn't exist or you need the raw value. The two systems share the same `--color-*` vars, so they stay in sync.
- **Scoped `<style>`** — for complex, single-use styles (the hero's orb glows, the architecture diagram). Compose with Tailwind, don't replace it.

### 15.4 Component pattern (recommended)

```astro
---
// Hero.astro — utility-first with raw vars only where utilities can't reach.
---
<section class="bg-paper-raised dark:bg-paper">
<div class="mx-auto grid max-w-wide gap-12 px-4 py-24 lg:grid-cols-[1.1fr_minmax(22rem,30rem)]">
<div>
<p class="mb-6 inline-flex rounded-full bg-brand-primary-muted px-3 py-1 text-xs font-semibold uppercase tracking-wide text-brand-primary">
Self-hosted · Theme-aware · Rust-fast
</p>
<h1 class="text-3xl font-semibold leading-tight tracking-tighter text-ink-900 lg:text-4xl">
The mail server that wears your brand.
</h1>
<p class="mt-6 max-w-xl text-md text-ink-500">
Self-hosted transactional mail with your colors, your templates, and your SMTP provider.
</p>
</div>

{/* SVG strokes use raw vars so they flip with [data-theme] */}
<svg viewBox="0 0 64 64" class="size-32" aria-hidden="true">
<path d="M..." stroke="var(--color-brand-primary)" stroke-width="4" fill="none"/>
</svg>
</div>
</section>
```

### 15.5 Dark mode

Already wired. Use `dark:` variant on any utility:

```astro
<button class="bg-brand-primary text-paper-raised dark:bg-brand-primary dark:text-ink-900">
CTA
</button>
```

The `--color-*` vars are redefined under `[data-theme='dark']` in `tokens.css`, so `bg-brand-primary` already flips. Use `dark:` only when you need a *different token* in dark mode (rare with our system — most of the time the auto-flip is enough).

### 15.6 What NOT to do

- ❌ Don't add a `tailwind.config.js`. Tailwind v4 is CSS-first; that file is legacy.
- ❌ Don't switch to `@import "tailwindcss"` — it pulls preflight, which destroys Starlight's docs typography (h1/p/ul margins, list markers, link colors all reset). Keep the split imports.
- ❌ Don't redeclare tokens elsewhere (in component `<style>` blocks, in other CSS files). One source of truth — `tokens.css`.
- ❌ Don't use `class:list={...}` to compose colors at runtime — Tailwind needs to see literal class names at build time. Use full conditional ternaries with literal classes instead.
- ❌ Don't bypass `@variant dark (...)` and use Tailwind's default `prefers-color-scheme` dark mode — it would desync from Starlight + the ThemeToggle.

### 15.7 Migrating existing components

Existing components in `site/src/components/*.astro` use scoped `<style>` blocks with raw CSS vars. They keep working because of the legacy aliases in §15.1.5. Migrate incrementally — pick a component, replace its `<style>` block with Tailwind utilities, delete the legacy `<style>`.

Suggested migration order (simplest → most painful):
1. `404.astro`, `pricing.astro` — small, mostly text.
2. `FeatureGrid.astro`, `CodeTabs.astro` — clear card/tab patterns.
3. `SiteFooter.astro`, `SiteHeader.astro` — chrome.
4. `Hero.astro`, `ThemeShowcase.astro`, `ArchitectureDiagram.astro` — keep their bespoke `<style>` for the SVG/animation parts; convert layout chrome only.

---

## 14. Versioning this document

- Bump `## N. …` section number only when adding new sections (never renumber).
- Changes to tokens (colors, sizes) = note in `CHANGELOG.md` under `### Design`.
- Major palette/typography changes = bump to a new major doc version in a `DESIGN-v2.md` and link back, so historical context is preserved.

**Current version:** `v1` — 2026-04-23 — initial system.
**Current version:** `v2` — 2026-04-26 — Tailwind v4 integration via `@theme` + `@variant dark`.

### Changelog
- `v2` (2026-04-26) — Added §15: Tailwind v4 wired through `@tailwindcss/vite`. Tokens exposed via `@theme {}` in `tokens.css`. Dark variant rebound to `[data-theme]` attribute. Legacy `--brand-*` / `--space-*` aliases preserved.
- `v1` (2026-04-23) — Initial system.
13 changes: 9 additions & 4 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ sidebar:
order: 1
---

# Installation

Mailify ships four install paths. Pick the one that matches your target.

## 1. Docker (recommended)
Expand Down Expand Up @@ -71,15 +69,21 @@ mailify

## 3. `cargo install` (Rust toolchain)

> **Status:** planned. Publishing to crates.io is gated on the lib crates being deemed API-stable. Track progress in [`TODO.md §4.1`](https://github.com/donilite/mailify/blob/master/TODO.md).
:::caution[Planned, not yet released]
Publishing to crates.io is gated on the lib crates being deemed API-stable. Track progress in [`TODO.md §4.1`](https://github.com/donilite/mailify/blob/master/TODO.md).
:::

When ready:

```bash
cargo install mailify-api --locked
```

This installs the `mailify` binary into `$CARGO_HOME/bin`. Note that `cargo install` does **not** ship the compiled template bundle — you will need to either build them from source (see [Template contract](../reference/template-contract.md)) or point `MAILIFY_TEMPLATES__PATH` at a pre-built bundle extracted from a GitHub Release archive.
This installs the `mailify` binary into `$CARGO_HOME/bin`.

:::note
`cargo install` does **not** ship the compiled template bundle — you'll either build them from source (see [Template contract](../reference/template-contract.md)) or point `MAILIFY_TEMPLATES__PATH` at a pre-built bundle extracted from a GitHub Release archive.
:::

## 4. Build from source

Expand All @@ -102,3 +106,4 @@ cargo build --release --bin mailify
- [Send your first email →](./quickstart.md)
- [Understand the moving parts →](./concepts.md)
- [Configure SMTP for your provider →](../guides/configure-smtp.md)
- [Brand your mail →](../guides/configure-theme.md)
24 changes: 12 additions & 12 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ sidebar:
order: 0
---

# Mailify

**The mail server that wears your brand.**

Mailify is a self-hosted transactional email server written in Rust. You give it a theme, a template id, and a recipient — it sends a branded email through your own SMTP provider. No SaaS, no vendor lock-in, no external service holding your template library hostage.
Expand All @@ -15,29 +13,31 @@ Mailify is a self-hosted transactional email server written in Rust. You give it

- **Branded out of the box** — palette, fonts, logo, footer text injected into every template via a single `Theme` config object.
- **One Docker image** — `donighost/mailify:<tag>` ships binary + compiled templates + built-in migrations. `docker run`, done.
- **Templates as code** — React Email (`.tsx`) compiles to pre-rendered HTML with minijinja placeholders preserved for runtime variables. Edit in your editor, ship in CI.
- **Templates as code** — React Email (`.tsx`) compiles to pre-rendered HTML with minijinja placeholders preserved for runtime variables.
- **Durable queue** — jobs persist in Postgres via [apalis](https://github.com/geofmureithi/apalis). A worker crash does not eat your outbound.
- **Per-job SMTP override** — one install can fan out to many tenants, each using their own SMTP provider, with credentials accepted in-memory only.
- **Argon2 + JWT auth** — API keys are argon2-hashed at rest; clients exchange them for short-lived JWTs per session.
- **Rust-fast, distroless-small** — final image ≈20 MB, non-root, nothing but `cc` libs and the binary.

## Who it's for

- **Indie / solo backend devs** who don't want to write a `MJML` template every time.
- **Indie / solo backend devs** who don't want to write an MJML template every time.
- **Small SaaS teams** needing Postmark-like ergonomics without paying per email.
- **Agencies** running multi-tenant stacks where each client needs its own visual identity.

If you need drag-and-drop campaign editors, audience segmentation, or bounce-analytics dashboards — Mailify is not for you. This is a *sending* server, not a marketing suite.
:::note[Not a fit?]
If you need drag-and-drop campaign editors, audience segmentation, or bounce-analytics dashboards — Mailify is not for you. This is a **sending** server, not a marketing suite.
:::

## Quick links

- [Install →](./getting-started/installation.md)
- [Send your first mail →](./getting-started/quickstart.md)
- [Concepts & vocabulary →](./getting-started/concepts.md)
- [Full config reference →](./reference/config.md)
- [HTTP API →](./reference/http-api.md)
- [Troubleshooting →](./troubleshooting/common-errors.md)
- [Contribute →](./contributing/overview.md)
- [Install →](./getting-started/installation.md) — Docker, install script, cargo install, or build from source
- [Quickstart →](./getting-started/quickstart.md) — send your first email in 5 minutes against a local Mailpit
- [Concepts →](./getting-started/concepts.md) — templates, themes, jobs, queue, overrides, bootstrap
- [Configuration →](./reference/config.md) — every TOML key, every env var, every default
- [HTTP API →](./reference/http-api.md) — auth, send, jobs, templates, health
- [Troubleshooting →](./troubleshooting/common-errors.md) — symptoms, causes, fixes by stage
- [Contribute →](./contributing/overview.md) — three ways to help: code, reach, sponsorship

## License

Expand Down
10 changes: 8 additions & 2 deletions docs/troubleshooting/common-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ sidebar:
order: 1
---

# Common errors

Errors are grouped by stage. If yours isn't here, check [Debugging](./debugging.md) and [FAQ](./faq.md).

:::tip[Speed up debugging]
Set `RUST_LOG=mailify=debug,mailify_api=debug,mailify_queue=debug` before reproducing. Most errors below are easier to identify with the structured logs surfaced by these targets.
:::

## Startup

### `error: figment error: missing field smtp.host`
Expand Down Expand Up @@ -98,6 +100,10 @@ psql "$MAILIFY_DATABASE__URL" -c 'SELECT 1'
- If stuck past `retry_backoff_secs`, restart the server — apalis will requeue on next poll.
- Persistent hangs often mean an external dependency (SMTP provider) is timing out silently; raise `MAILIFY_SMTP__TIMEOUT_SECS` briefly to confirm, then fix the upstream.

:::danger[SMTP error 550 = upstream rejection]
A `5xx` SMTP code from your provider means the provider refused the message — Mailify just relays the rejection. Don't bury this; investigate domain auth (SPF/DKIM/DMARC) before retrying.
:::

### `relay access denied` / `550 5.7.1`

**Cause** — SMTP provider refused the `From:` domain. Most transactional providers (SES, Mailgun, Postmark) require you to prove domain ownership via DNS records (SPF, DKIM, DMARC) before accepting mail from `@yourdomain.com`.
Expand Down
5 changes: 5 additions & 0 deletions site/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ node_modules
.env
.env.*
!.env.example

# Generated at prebuild — source of truth lives elsewhere
public/og-default.png
public/install.sh
public/install.ps1
47 changes: 43 additions & 4 deletions site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,43 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
site: 'https://mailify.donilite.me',
trailingSlash: 'never',

vite: {
plugins: [tailwindcss()],
},

integrations: [
starlight({
title: 'Mailify',
description: 'Self-hosted, theme-aware transactional mail server in Rust.',
disable404Route: true,
expressiveCode: {
themes: ['github-dark-default', 'github-light-default'],
themeCssRoot: ':root',
themeCssSelector: (theme) =>
`[data-theme='${theme.type === 'dark' ? 'dark' : 'light'}']`,
styleOverrides: {
borderRadius: 'var(--radius-md)',
borderColor: 'var(--color-border)',
codeFontFamily: 'var(--font-mono)',
codeFontSize: '0.9rem',
codeLineHeight: '1.6',
frames: {
shadowColor: 'transparent',
editorActiveTabBorderColor: 'var(--color-brand-primary)',
editorActiveTabIndicatorBottomColor: 'var(--color-brand-primary)',
terminalBackground: 'var(--color-paper-raised)',
terminalTitlebarBackground: 'var(--color-paper)',
},
},
},
logo: {
light: './public/logo.png',
dark: './public/logo-white.png',
src: './src/assets/logo.svg',
alt: 'Mailify',
replacesTitle: false,
},
Expand All @@ -31,7 +56,7 @@ export default defineConfig({
head: [
{
tag: 'meta',
attrs: { property: 'og:image', content: 'https://mailify.donilite.me/og-default.svg' },
attrs: { property: 'og:image', content: 'https://mailify.donilite.me/og-default.png' },
},
{
tag: 'meta',
Expand Down Expand Up @@ -99,6 +124,20 @@ export default defineConfig({
},
],
}),
sitemap(),
sitemap({
changefreq: 'weekly',
priority: 0.8,
lastmod: new Date(),
filter: (page) => !page.includes('/404'),
serialize(item) {
if (item.url === 'https://mailify.donilite.me/') {
return { ...item, priority: 1.0 };
}
if (item.url.includes('/docs/')) {
return { ...item, priority: 0.9 };
}
return item;
},
}),
],
});
Loading
Loading