diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index 0856b78..77884e3 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -62,7 +62,7 @@ jobs: docs: - '.readthedocs.yaml' - 'docs/**' - - '.github/workflows/docs.yaml' + - '.github/workflows/docs.yml' emails: - 'emails/**' - 'src/imio/emailkit/kit/**' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..6400dbb --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,83 @@ +name: "Docs" + +# Deliberately separate from main.yml. The Python suite must not gate a typo fix in +# the documentation, and a broken MDX page must not gate a release — they are +# different artifacts with different reviewers and very different run times. +on: + push: + paths: + - 'docs/site/**' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'docs/site/**' + - '.github/workflows/docs.yml' + workflow_dispatch: + +# GitHub Pages needs these on the deploy job. `contents: read` is the default for +# the rest; nothing here writes to the repository. +permissions: + contents: read + +# One deploy at a time, and never cancel one in flight: a cancelled deploy-pages run +# can leave the Pages site pointing at a half-uploaded artifact. +concurrency: + group: "pages" + cancel-in-progress: false + +defaults: + run: + working-directory: docs/site + +jobs: + + build: + name: "Docs: Build" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: docs/site/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build the static site + # The site is served from https://.github.io//, so every asset + # and link needs a / prefix. Reading it from the repository name rather + # than hardcoding it means a fork builds correctly with no edit. + env: + NEXT_BASE_PATH: /${{ github.event.repository.name }} + run: npm run build + + - name: Upload the Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/site/out + + deploy: + name: "Docs: Deploy to GitHub Pages" + # Only the default branch publishes. Every other push and every pull request + # still runs `build` above, so a page that fails to render is caught before merge. + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 3d3c62d..b447f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,21 +15,31 @@ dist/ docs/_build node_modules/ +# documentation site (docs/site) -- Next.js build output +docs/site/.next/ +docs/site/out/ + # venv / buildout related -.eggs/ -.installed.cfg -.mr.developer.cfg -.venv/ -bin/ -develop-eggs/ -eggs/ -etc/ -include/ -lib/ -lib64 -parts/ -pyvenv.cfg -var/ +# +# Anchored with a leading `/`: every one of these exists only at the repository +# root, and unanchored they match at any depth. `lib/` silently swallowed +# `docs/site/src/lib/`, so the documentation site committed without the four +# modules its components import and failed to build in CI while building fine +# locally. Keep new entries here anchored. +/.eggs/ +/.installed.cfg +/.mr.developer.cfg +/.venv/ +/bin/ +/develop-eggs/ +/eggs/ +/etc/ +/include/ +/lib/ +/lib64 +/parts/ +/pyvenv.cfg +/var/ # mxdev .installed.txt diff --git a/README.md b/README.md index 6f701e9..731f9c4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ [![PyPI - Plone Versions](https://img.shields.io/pypi/frameworkversions/plone/imio.emailkit)](https://pypi.org/project/imio.emailkit/) [![CI](https://github.com/IMIO/imio.emailkit/actions/workflows/main.yml/badge.svg)](https://github.com/IMIO/imio.emailkit/actions/workflows/main.yml) +[![Docs](https://github.com/IMIO/imio.emailkit/actions/workflows/docs.yml/badge.svg)](https://github.com/IMIO/imio.emailkit/actions/workflows/docs.yml) ![Code Style](https://img.shields.io/badge/Code%20Style-Ruff-000000) [![GitHub contributors](https://img.shields.io/github/contributors/IMIO/imio.emailkit)](https://github.com/IMIO/imio.emailkit) @@ -25,48 +26,30 @@ with a modern toolchain — [Maizzle 6](https://maizzle.com) (Vue SFC + Tailwind CSS 4) — and render them at runtime with Chameleon, so that **no Node.js ever runs in production**. -Installing it restyles Plone's stock password-reset and registration mails -immediately. That is the point: the mails a citizen actually receives from a -commune are the ones nobody ever gets round to designing. - -## Features - -- **Better defaults out of the box.** The `imio.emailkit:default` profile - restyles Plone's password-reset and user-registration mails, with no extra - package and no opt-in step. -- **A two-stage pipeline with a build-time seam.** Maizzle compiles `.vue` into - email-safe HTML (inlined CSS, Outlook fallbacks); the output is committed as - `.pt` and rendered by Chameleon at runtime. Node is a developer/CI tool only. - Dark mode currently ships as `color-scheme` hints only, not - `prefers-color-scheme` rules — see `docs/DECISIONS.md`. -- **A built-in design system** — one canonical layout plus components — shipped - *inside the egg*, so the buildout pin that governs the runtime governs the - design system too. No npm registry, no version skew. -- **`render(name, context, language)`** returning `(html, text)`: a pure - function of template, context and registry state, so previews, tests and real - sends all take the same path. -- **Locale-aware helpers** (`format_date`, `format_datetime`, `format_number`) - bound to the render language, so no template reinvents French date formatting. -- **Accessibility and i18n baked into the kit**, not left to authors: - `role="presentation"` on layout tables, an enforced `alt` on the logo, `lang` - on ``, and the hidden preheader line every inbox shows next to the - subject. -- **Three levels of override**, from a registry record to a full markup - replacement, plus a complete opt-out. +Installing it restyles Plone's stock password-reset, registration and +username-reminder mails immediately. That is the point: the mails a citizen +actually receives from a commune are the ones nobody ever gets round to +designing. -## Compatibility +## 📖 Documentation -Plone 6.0, 6.1 and 6.2 on Python 3.10 to 3.13. +**** -> [!IMPORTANT] -> **Classic UI only, and deliberately so.** These are emails: there is no Volto -> component and no REST endpoint to write. Rendering is isolated in -> `imio.emailkit.render`, which has no dependency on the request. +Everything is there: the quickstart, the architecture, the full API reference, +the template-authoring rules, how to ship templates from your own add-on, and +how to override what this package ships. -> [!NOTE] -> Building templates needs Node.js 22+. Installing, testing and *running* the -> add-on never does — that is the whole architecture. If you only consume the -> mails it ships, you will never install Node. +Some entry points worth naming: + +| | | +| --- | --- | +| [Quickstart](https://imio.github.io/imio.emailkit/quickstart/) | install, then send your first styled mail | +| [Architecture](https://imio.github.io/imio.emailkit/architecture/) | the build-time / runtime seam, and why | +| [`Email` builder](https://imio.github.io/imio.emailkit/api/email/) | recipients, attachments, per-language sending | +| [Authoring rules](https://imio.github.io/imio.emailkit/authoring/rules/) | eight ways a template breaks with a green build | +| [Shipping templates](https://imio.github.io/imio.emailkit/integration/shipping-templates/) | get your own add-on's templates discovered | +| [Migrating a mail](https://imio.github.io/imio.emailkit/integration/migrating/) | you already build HTML bodies | +| [Overrides & theming](https://imio.github.io/imio.emailkit/integration/overrides/) | three levels, plus a full opt-out | ## Installation @@ -75,335 +58,42 @@ pip install imio.emailkit ``` Then install the add-on in Site Setup, or apply the `imio.emailkit:default` -GenericSetup profile. - -### The two profiles - -| Profile | Installs | Use it when | -| --- | --- | --- | -| `imio.emailkit:default` | the runtime **and** the restyled Plone default mails | almost always | -| `imio.emailkit:base` | the runtime only; stock Plone mails are untouched | you want the API without the restyled defaults | - -`:default` extends `:base`, so installing it gives you both. - -> [!WARNING] -> On a `:base`-only site, Site Setup lists `imio.emailkit` as *available* rather -> than *installed*. Plone's quick-installer answers "has the `default` profile -> been applied?", not "is this add-on working?" — the add-on is installed and its -> API works. Check `imio.emailkit:base` in `portal_setup` if you need certainty. - -### Behind a reverse proxy: declare `trusted-proxy` - -Two of the restyled login-help mails (password reset, username reminder) tell the -recipient which IP address the request came from. That value comes from Zope's -`request.getClientAddr()`, which honours `X-Forwarded-For` **only** for proxies you -have declared: - -``` -# zope.conf -trusted-proxy 127.0.0.1 -``` - -Without it, `HTTPRequest.trusted_proxies` is empty and Zope reports the proxy's own -address — so the mail says `127.0.0.1` instead of the real client. That is Zope -behaving correctly, not a bug in this add-on. - -Reading `X-Forwarded-For` directly would need no configuration, and is deliberately -*not* what these templates do: the header is client-settable, so anyone triggering -a password reset could choose which IP address the mail names. - -## Sending a mail - -```python -from imio.emailkit import render - -html, text = render( - "imio.emailkit:notification", - context={"title": title, "intro": intro, "cta_url": url}, - language="fr", -) -``` - -`render()` injects the theme tokens, the render language as `lang`, and the -locale helpers. It returns an HTML part and a plaintext part; assembling them -into a message is yours until the `Email` builder lands. - -> [!NOTE] -> The two restyled Plone default mails are **not** available through `render()`. -> A stock Plone view renders them, so they speak that view's namespace, and -> asking for them by name raises `TemplateNotFound`. They are shipped, tested and -> overridable — just not discoverable. Use `imio.emailkit:notification` as the -> worked example of an ordinary template. - -## Sending styled mail from a content rule - -Installing the `default` profile adds a content-rule action, **Send styled email**, -alongside Plone's own. It offers every registered template — including templates -from other add-ons, because the vocabulary is built from the same discovery the -preview view uses — plus a list of recipients and a "send to the owner" box. - -The action is a thin caller of the `Email` builder, so everything from -[Sending a mail](#sending-a-mail) applies unchanged: recipients are resolved -through `IEmailRecipient`, one message is sent **per recipient language**, and -delivery is transaction-safe — if the transaction that fired the rule aborts, -nothing is sent. - -### What a rule can put in the template context - -A content rule cannot know what any given template wants, so it passes a fixed set -of names. Authoritative list and reasoning: the *"The render context, for template -authors"* section of `imio/emailkit/contentrules/mail.py`'s module docstring. - -| Name | What it is | -|---|---| -| `item` | the content object the rule fired on | -| `title` | its title | -| `intro` | a short lead line | -| `cta_label` | the call-to-action label | -| `cta_url` | the content's URL | - -A template that needs a name outside this set **fails loudly at render** rather -than quietly producing a mail with a gap in it. If yours needs more, send it from -your own code with `Email(...)` instead — the builder takes any context you like. +GenericSetup profile — which restyles Plone's own transactional mails. Use +`imio.emailkit:base` for the runtime only. > [!IMPORTANT] -> `cta_label` reaches the template as an **i18n msgid, not a translated string**. -> `.with_context()` runs once, *before* `.send()` groups recipients by language, so -> a string translated at that point would send one language's wording to every -> recipient. The same trap applies to anything you pass through -> `.with_context()` yourself: pass msgids and let the render translate them per -> language group. - -## Migrating a mail you already send - -If your add-on already builds an HTML body — a notification assembled by string -concatenation, say — you do not have to re-author it as a kit template to get the -styled shell. There are two routes, and **neither adds any API**. - -### Route 1 — you already own your sending code - -```python -from imio.emailkit import render_shell - -html, text = render_shell("Point 'Budget 2026' : etat modifie", legacy_body_html) -``` - -`render_shell` drops `legacy_body_html` into the kit shell's `body_html` slot and -returns the same `(html, text)` pair `render()` does. The shell contributes the -whole document: inlined CSS, accessibility defaults, `lang`, the header and footer, -the theme tokens and dark mode. Your body goes in **byte-for-byte** — nothing is -sanitised, reformatted or rewritten. - -`${...}` inside the body is emitted **literally**. It is not re-parsed as a -template, so a body assembled by string concatenation cannot accidentally (or -deliberately) read the render namespace. That is verified, not assumed — see -`docs/DECISIONS.md`. - -### Route 2 — you want the `Email` builder (usually better) +> **Behind a reverse proxy, declare `trusted-proxy` in `zope.conf`**, or the +> login-help mails will name the proxy's own IP address instead of the client's. +> Why, and why the header is not read directly: +> [Installation & profiles](https://imio.github.io/imio.emailkit/installation/#behind-a-reverse-proxy). ```python from imio.emailkit import Email Email("imio.emailkit:notification").to(member).with_context( - title=subject, body_html=legacy_body_html + title=title, intro=intro, cta_url=url ).send() ``` -The kit layout defines the `body_html` slot for **every** template, not just the -shell, so the builder can carry a legacy body too. Prefer this when you can: you -get per-language sending, recipient adapters, attachments and transaction-safe -delivery, and you keep the registration's subject, its preheader and its -hand-authored plaintext twin — none of which `render_shell` has. - -> [!NOTE] -> `Email("imio.emailkit:shell")` does **not** work. The shell is resolved by path -> and deliberately not registered for discovery, so it has no name to look up. Use -> one of the two routes above. - -### What you do not change - -Your existing markup, your existing data-gathering code, and your existing -recipient logic if you take route 1. The shell wraps; it does not redesign. - -### One thing to check in your legacy body - -> [!WARNING] -> If your body carries its own `