Receipt Email — Body Design¶
The email that wraps Pax-A's M14 receipt PDF and delivers it to the client when an order is marked Strung. This page covers the email envelope + body; the attached PDF is specified separately in receipt-pdf. Owned by Mira. Cross-cuts: receipt-pdf (the attachment), architecture/receipts.md (delivery pipeline), architecture/integrations.md (SMTP wrapper + Resend constraints), architecture/i18n.md (locale rule), order-lifecycle (T2 trigger + re-emit semantics), stringer-dashboard (host of the failure indicator), design-tokens.
Source requirement¶
- Trigger: Order's
strung_atis set (T2 transition) OR a manual "resend receipt" is invoked. Perreceipts.md§ Email delivery. - Recipient:
order.client_profile.person.emailif non-null. If null, the email step is silently skipped (the print path remains). Perreceipts.md+ M14 acceptance criteria (#67). - Attachment: the PDF rendered per receipt-pdf, in the same locale as the email body.
- Locale:
order.client_profile.person.default_locale ?? Stringer.default_locale. Same rule as the PDF; subject + body + attachment filename all share one locale per emit. Peri18n.md§ PDF receipts. - Transport: Resend SMTP via
aiosmtplib; envelope-from + DKIM-d onwagen.io. Perintegrations.md. - Failure semantics: silent + logged; the order's state machine still advances. Per #44 + M14 acceptance criteria.
- Re-emit: receipts can be re-sent on edit (auto) or on a manual button click. Per order-lifecycle § Receipt re-emit triggers. Body shape is identical for first-emit, auto-re-emit, manual resend; nothing in the email tells the client which one this is. (Re-emit hygiene: a re-emitted receipt has the same
receipt_number; the client sees a corrected version of the same document, not a "v2 receipt" framing.)
Goal¶
Three commitments, in tension-resolution order:
- The email is a courier, not a marketing surface. The PDF is the artifact. The email body's job is: confirm what the attachment is, who it's from, name the total + receipt number, get out of the way. No banners, no calls-to-action, no "here are some other rackets you might like."
- Plain-text-first, single-part body for V2. Lower delivery risk (Resend's free tier is well within budget but spam-filter heuristics still penalise HTML-only or HTML-heavy senders without an established sending reputation), zero brand-design tax, and screen-readers / cli mail clients render perfectly. HTML companion is flagged as Round-3 + (see Open questions #4); not blocking M14.
- The locale is invisible to the user — the right one just shows up. EN client gets EN subject + body + attachment filename; DE client gets DE everything. No locale chooser in the email; the resolution rule fires server-side.
Where the email is rendered¶
Email is rendered by mail clients we do not control — Gmail web, iOS Mail, Outlook desktop, Thunderbird, the recipient's company SMTP gateway's plain-text-only stripping, etc. Two design budgets:
| Surface | Constraint |
|---|---|
| Plain-text body | Wrap at 72 characters for compatibility with old Outlook + cli clients that hard-wrap at 78. No tables; no ASCII art beyond a single horizontal rule. Single-byte ASCII + UTF-8 (umlauts in DE pass through cleanly via Content-Type: text/plain; charset=utf-8). |
| HTML body (V3) | If we ever ship an HTML companion: max 600 px width (the de-facto standard since Outlook 2007), single-column, inline CSS only, no web fonts (mail clients strip @font-face). Indigo-700 (#3949ab) primary per design-tokens. Defer to Round-3 + . |
The V2 spec below assumes plain-text-only (single-part text/plain). The HTML body is documented as a future companion — Pax-A can ship V2 without it.
Subject line¶
| Locale | Subject string | Rendered example |
|---|---|---|
| EN | Your stringing receipt #{receipt_number} |
Your stringing receipt #2026-0042 |
| DE | Ihre Saitenrechnung #{receipt_number} |
Ihre Saitenrechnung #2026-0042 |
Token: {receipt_number} is Order.receipt_number rendered with the # prefix to match the PDF's TR-1 styling and so a client can search their inbox for #2026-0042 and find both the email and any future correspondence about that receipt.
Token reservation for re-send disambiguation (NOT in V2). A future "v2 of receipt #2026-0042" framing (e.g. when the stringer corrects a price after Strung and the auto-re-emit fires) might want a marker like (corrected) / (korrigiert) in the subject. Per design commitment #1 above, the V2 default is silent re-emit — the subject does not change between first-emit and auto-re-emit. Flagged as Open question #1.
No emoji, no ALL-CAPS, no exclamation marks. All three are spam-filter penalties on a young sending domain; none earn their cost.
Length budget. Both EN + DE subjects are well under the 70-char visible-in-list cutoff at common widths (Gmail mobile ≈ 35 chars; Gmail desktop ≈ 70). The {receipt_number} token tail is the most informative substring; if any client truncates, "Your stringing receipt #2026-0042" still shows the prefix that identifies the message.
From address + display name¶
| Field | Value | Rationale |
|---|---|---|
From header |
"{stringer.display_name} (via racket-book) <{SMTP_FROM}>" — SMTP_FROM is noreply@wagen.io per integrations.md. |
The display name surfaces the stringer (the client's actual relationship); the envelope-from is a single platform-controlled address with DKIM aligned (d=wagen.io). The "(via racket-book)" suffix is honest about the platform's role and matches established UX patterns (Gmail's "via gmail.com" pattern when a sender uses an alias). |
Reply-To header |
"{stringer.display_name} <{stringer.email}>" |
A client hitting "Reply" lands in the stringer's inbox, not the platform's. This is the high-value detail: the email is functionally a message from the stringer; the platform is the courier. |
Sender header |
Omitted (the From envelope-from already carries the platform identity). |
Adding Sender: triggers Gmail's "on behalf of" rendering, which reads as more bureaucratic than helpful. |
Why not send From: stringer.email? Two reasons.
- DKIM alignment.
d=wagen.iois the only domain we sign. AFrom: stefan@example.chthat fails to align withd=wagen.iolands in spam at most major receivers. Perintegrations.md, Atlas owns thewagen.ioDKIM record; cross-domain DKIM (signing on behalf of every stringer's personal domain) is operationally infeasible. - Reputation pooling. Resend's reputation lives on
wagen.io. Routing every stringer's mail through that single domain pools the reputation; routing throughFrom: stringer.emailwould mean per-stringer reputation, which a low-volume stringer can never accrue.
Effect for the client. They see "Stefan Wagen (via racket-book)" in their inbox list, recognise it as Stefan, and reply to Stefan directly. The wagen.io envelope-from is visible only if they expand headers — a non-event for the typical recipient.
Plain-text body¶
EN draft¶
Hi {first_name},
Your racket is strung and ready. The receipt is attached as a PDF.
Receipt #{receipt_number}
Racket {racket_make} {racket_model}
Total CHF {total}
If you have any questions, just reply to this email.
Thank you,
{stringer_display_name}
--
{stringer_business_name}
{stringer_email}
{stringer_phone}
This receipt was sent via racket-book on behalf of {stringer_display_name}.
Not VAT-registered.
Line-by-line annotations:
| Slot | Source | Notes |
|---|---|---|
Hi {first_name} |
order.client_profile.person.display_first_name |
Salutation. Falls back to Hi there, if the Person has no first name (rare; Person is created with at least a display name). DE uses Hallo. |
| Confirmation sentence | Static i18n string | Past-perfect framing ("strung and ready"); the action is complete, no client action required. |
| Three-line summary block | Order.receipt_number, racket fields, Order.total |
Indented two spaces, aligned column for scanability. Tabular layout is what plain-text + monospace mail clients render cleanest; proportional clients see a slight column wobble that's still readable. The total uses the same CHF X.XX format as the PDF (Babel format_currency('CHF', locale)); both locales render CHF X.XX per the PDF spec. |
| "If you have any questions, just reply to this email." | Static i18n string | Sets the expectation that Reply-To works. Important: the client's Reply lands in the stringer's inbox per the From / Reply-To rationale above. |
Thank you, + signature |
Static + Stringer.display_name |
Closing. The signature is the stringer, not "the racket-book team" — reinforces that this is a message from the stringer. |
Footer block (after -- separator) |
Stringer.business_name, email, phone |
Standard email-signature pattern: -- + \n is the de-facto delimiter mail clients use to fold the signature into a "show signature" affordance (Outlook, some webmails). Business name + email + phone, one per line. Phone is suppressed when NULL; business name is suppressed when NULL (same suppression rules as the PDF footer — see receipt-pdf § Footer band). |
| Disclosure line | Static i18n string + Stringer.display_name |
"Sent via racket-book on behalf of X" — duplicates the From-header context for clients whose mail UI doesn't render the display name fully. The "Not VAT-registered." final line mirrors the PDF's F-1 disclosure for clients who only read the email and not the attachment. |
DE draft (Mira's first pass — Iris's review later)¶
Hallo {first_name},
Ihr Schläger ist bespannt und einsatzbereit. Die Rechnung ist als PDF
beigefügt.
Rechnung #{receipt_number}
Schläger {racket_make} {racket_model}
Gesamt CHF {total}
Bei Fragen antworten Sie einfach auf diese E-Mail.
Vielen Dank,
{stringer_display_name}
--
{stringer_business_name}
{stringer_email}
{stringer_phone}
Diese Rechnung wurde über racket-book im Auftrag von
{stringer_display_name} versendet.
Nicht mehrwertsteuerpflichtig.
Width budget. The 72-char wrap is preserved; the disclosure sentence wraps mid-line in DE (the {stringer_display_name} token can be long). The plain-text wrap at 72 lets the receiving client re-wrap to its window without ragged-right damage.
Sie vs Du. DE uses formal Sie (the relationship is stringer→client; many clients are not personally close to the stringer, and DE-CH defaults formal in commerce). Iris will sanity-check on her DE pass.
i18n string keys¶
| Key | EN | DE |
|---|---|---|
email.receipt.subject |
Your stringing receipt #{receipt_number} |
Ihre Saitenrechnung #{receipt_number} |
email.receipt.greeting |
Hi {first_name}, |
Hallo {first_name}, |
email.receipt.greeting_no_name |
Hi there, |
Guten Tag, |
email.receipt.confirmation |
Your racket is strung and ready. The receipt is attached as a PDF. |
Ihr Schläger ist bespannt und einsatzbereit. Die Rechnung ist als PDF beigefügt. |
email.receipt.summary_label_receipt |
Receipt |
Rechnung |
email.receipt.summary_label_racket |
Racket |
Schläger |
email.receipt.summary_label_total |
Total |
Gesamt |
email.receipt.reply_invite |
If you have any questions, just reply to this email. |
Bei Fragen antworten Sie einfach auf diese E-Mail. |
email.receipt.closing |
Thank you, |
Vielen Dank, |
email.receipt.platform_disclosure |
This receipt was sent via racket-book on behalf of {stringer_display_name}. |
Diese Rechnung wurde über racket-book im Auftrag von {stringer_display_name} versendet. |
email.receipt.vat_disclosure |
Not VAT-registered. |
Nicht mehrwertsteuerpflichtig. |
The email.receipt.vat_disclosure value is intentionally identical to receipt.vat_disclosure in receipt-pdf — same string, same regulatory commitment. Pax-A can either share the key or duplicate it; either is fine.
HTML body (deferred — V3+ companion)¶
V2 ships plain-text-only. This section documents the HTML companion as a future round so the V2 plain-text design is not architecturally blocked from later upgrade.
If/when an HTML body is added, it ships as a multipart/alternative message (RFC 2046): the same text body as above, plus an HTML body that adds light visual hierarchy. Mail clients pick HTML when they can render it; text-only clients (cli, screen-readers, spam-filter pre-render) get the same content.
HTML constraints (informational, for the future round):
- Single-column, max 600 px wide. Inline CSS only (no
<style>, no external CSS — most webmail strips both). - Brand color: indigo-700 (
#3949ab) for the receipt-number row + the "Reply to ask a question" affordance. Body text: slate-900 (#0f172a). Per design-tokens § Brand. - No web fonts. System font stack:
-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif. - Mobile: media-query
max-width: 480pxcollapses the summary block to single-column rows (the three lines stack rather than table-align). - No images other than (optional) the stringer's logo at top, max 120 × 36 px, served from the
wagen.iostatic origin (Gmail and others block remote images by default; design must read fine without the logo). - Same content, same i18n keys, same locale rule. The HTML is a presentation layer over the plain-text content, not a different message.
Why deferred: the V2 plain-text body is sufficient (the PDF carries the visual brand); HTML adds a delivery-risk surface (HTML emails from new sending domains land in spam more often than plain-text) and a maintenance surface (mail-client CSS quirks are notoriously thankless). Re-evaluate once wagen.io has > 6 months of established sending reputation.
PDF attachment¶
| Aspect | Value |
|---|---|
| Filename (EN) | receipt-{receipt_number}.pdf — e.g. receipt-2026-0042.pdf |
| Filename (DE) | rechnung-{receipt_number}.pdf — e.g. rechnung-2026-0042.pdf |
| MIME type | application/pdf |
| Encoding | base64 (Content-Transfer-Encoding: base64) — aiosmtplib handles this transparently when the attachment is added via aiosmtplib.email.message.EmailMessage.add_attachment(pdf_bytes, maintype='application', subtype='pdf', filename=...) |
Content-Disposition |
attachment; filename="..." — explicit attachment (not inline) so mail clients treat it as a download, not an inline preview that some clients render badly |
Filename localisation rationale. A DE client receiving a file called receipt-2026-0042.pdf reads it as foreign-language; localising the filename matches the rest of the locale stack (subject, body, PDF content). The receipt number itself is not localised — it's a stable identifier that should match the PDF's TR-1 rendering.
Filename collision. Re-emits use the same receipt_number, so the client sees the same filename. Their mail client either de-duplicates (Gmail) or appends (1) / (2) (Outlook); either is acceptable — the user knows the file by receipt number, not by mail-client filename. We do not append a version suffix to the filename for re-emits per the silent-re-emit commitment.
No inline preview / no Content-ID. The attachment is not referenced from the body (we are plain-text); no cid: linking is possible or needed.
Locale resolution¶
The email is rendered in the receipt's locale, resolved per the PDF rule:
Same locale across: subject, body, attachment filename, attached PDF.
Three guarantees this is meant to deliver:
- A client never gets a mixed-locale message (e.g. EN subject + DE body) — both render from one locale variable.
- A re-emit honours the latest Person.default_locale — if a client updated their preference between emits (V3 affordance), the next emit picks up the new preference. V2 has no surface for the client to change this; the seeded value (per
i18n.md§ Receipt language) holds. - The recipient mailbox's
Accept-Languageis irrelevant — emails have noAccept-Languageanalog; we use the saved Person preference, same rule as the in-app UI.
No-email case ("print-only" mode)¶
When order.client_profile.person.email IS NULL, the email path is silently skipped. Per receipts.md § Email delivery + M14 acceptance criteria.
No UI surface for V2. The order detail page does not surface a "no email available" warning; the print path remains and is the documented fallback. The hidden requirement from order-lifecycle (#199 self-job receipt UI affordance) covers self-jobs; the unclaimed-Person case is treated symmetrically.
Why silent. The stringer either knows the client doesn't have email on file (typical: a walk-in, a one-time customer) or doesn't (a long-tail data-entry omission). Surfacing a banner per-order would be noise for the former and only marginally useful for the latter. The fix path — "edit the Person, add an email, click Resend" — exists already through manual paths.
Logging. Pax-A logs the skip on the Order row (receipt_emailed_at stays NULL; receipt_email_skipped_reason = 'no_email'). Stefan can later run a report on emails-not-sent if he wants to clean up; out of scope for V2 design.
V3 hook. When the V3 client portal lands, a Person can self-claim and add their email; a future "you have N pending receipts that couldn't be sent — claim and we'll send them" affordance is plausible. Flagged for the V3 client portal screens round; not in V2.
Failure UX¶
When SMTP send fails (Resend down, network blip, recipient mailbox full, DKIM-rejected by some receivers, etc.), per #44 and the M14 acceptance criteria:
- The order's state still progresses to Strung. The send is fail-soft; we do not roll back
strung_atbecause an email didn't ship. - The PDF is still rendered + still downloadable from the order detail page. The print path is unaffected.
- The failure is logged to
admin_audit_log(per #45) with the order id, recipient address, and the SMTP error. Stefan can grep the log if he hears "I never got my receipt." - Surface a small "receipt didn't send" indicator on the order detail page. Designed below; queued for Juno's later work.
Indicator design (queued for Juno)¶
The order detail page (a future Mira round; see index § Future rounds) carries a "Receipt" status block. The block has three states:
SENT (green-700 dot · text-small) Receipt sent to lukas@example.ch · 2026-04-30 14:22
SKIPPED (slate-600 dot · text-small) No email on file — print path only
FAILED (amber-700 dot · text-small) Receipt didn't send · [Resend] · [View error]
| State | Trigger | Affordance |
|---|---|---|
| Sent | receipt_emailed_at IS NOT NULL AND no failure log entry since the latest emit. |
Passive; no action. Click → expands to show emit history (timestamps + recipient). |
| Skipped | receipt_emailed_at IS NULL AND Person.email IS NULL. |
Passive; no action. Subtle by design (silent is the V2 contract). |
| Failed | receipt_emailed_at IS NULL AND Person.email IS NOT NULL AND a failure log entry exists since the latest emit. |
Active. "Resend" button re-runs the send path (same as the manual resend trigger). "View error" expands to show the SMTP error message + log timestamp — for self-debugging without forcing a log-grep. |
Color choice. Amber-700 (warning) not red-700 (danger): the order is fine, the customer can be re-emailed manually, the world is not on fire. Per design-tokens § State.
Hit-target. The "Resend" and "View error" controls are 44 × 44 px touch targets per design-tokens § Spacing. Mobile-first: a stringer noticing a failed send while still at the stringing table can re-trigger from the phone.
Auto-retry policy. None in V2. The "Resend" button is the user-driven retry; auto-retry adds complexity (idempotency, exponential backoff, capped attempts) for a low-volume failure mode. Flagged as a follow-up if Stefan ever sees enough failures to feel the manual-retry tax.
Notifications inbox link (V3+). The future notifications inbox (index § Future rounds) will carry a feed of failed sends across all orders so Stefan can triage in one place. V2's surface is per-order only.
Accessibility¶
- Plain-text body is the most accessible email format — no per-mail-client a11y quirks; screen-readers read it directly. The summary block uses spaces (not tabs) for column alignment so screen-reader linearisation reads "Receipt 2026-0042 Racket Babolat Pure Aero 98 Total CHF 48.00" cleanly enough to follow.
- Subject line is short + descriptive (announce-on-first-read pattern); a screen-reader user listening to a digest hears the receipt number first.
- Attachment filename is human-readable —
receipt-2026-0042.pdfreads aloud naturally. Avoid hash filenames like7f3e8a.pdf. - DE umlauts are encoded as UTF-8 with the appropriate
Content-Type: text/plain; charset=utf-8header so screen-readers pronounce them correctly (legacy Latin-1 would re-render as?on some clients). - PDF a11y is handled in receipt-pdf § Accessibility — heading hierarchy, table structure, alt text on the logo. The email body briefly recapitulates the receipt content (number, racket, total) so a screen-reader user can confirm the gist without opening the attachment.
i18n affordance¶
Three categories:
- Catalogue-driven — every label in the i18n string keys table. EN normative; Iris's DE pass closes the round.
- Data — recipient first name, stringer display name, business name, email, phone, racket make/model. Stored as-is, displayed as-is. Per i18n § Catalogue / data localisation.
- Format — receipt number (
#YYYY-NNNN, locale-neutral), total (Babelformat_currency('CHF', locale)— both locales renderCHF X.XX), filename slug (EN:receipt-; DE:rechnung-).
Same i18n catalogue as the PDF. Pax-A's M14 implementation should share one Babel domain across email + PDF; locale resolution is computed once per emit and threaded through both renders.
Cross-references¶
- PDF (the attachment): receipt-pdf.
- Pipeline:
architecture/receipts.md(render + send sequence). - SMTP wrapper + Resend:
architecture/integrations.md. - Locale rule:
architecture/i18n.md. - Re-emit triggers: order-lifecycle § Receipt re-emit triggers.
- Failure semantics: #44 (silent + log) + #45 (
admin_audit_log). - M14 backend issue: #67.
- This spec's issue: racket-book#114.
Open questions for Stefan (with proposed defaults)¶
- Re-emit subject marker. When an auto-re-emit fires after a price correction, should the subject indicate "this is a corrected version" (e.g.
(corrected)/(korrigiert)suffix)? Proposed default: no marker — subject is identical between first-emit and re-emit. The PDF carries the samereceipt_number; the client sees a corrected version of "the same document" rather than a sequence of competing emails. If Stefan ever sees a client confused by two same-numbered receipts arriving days apart, easy to add a marker. - Reply-To addressing. Current proposal:
Reply-Tois the stringer's email, so client replies bypass the platform. Proposed default: keep — the platform is a courier, not a CRM. Alternative: route replies through a platform inbox so Stefan has an audit trail of client communication. The alternative is a real product surface (a help-desk feature), not in V2 scope. - From display name format. Current proposal:
"{stringer.display_name} (via racket-book)". Proposed default: keep — honest about the platform's role, matches Gmail's "via" pattern. Alternatives:"{stringer.display_name}"(no platform suffix; cleaner but slightly misleading) or"racket-book on behalf of {stringer.display_name}"(more bureaucratic). The "via" suffix is the lowest-friction honest framing. - HTML body in V2 or defer to V3+ ? Proposed default: defer. Plain-text-only ships safer (delivery reputation), simpler (no per-client CSS quirks), and is sufficient given the PDF carries the visual brand. Re-evaluate after 6 months of established sending reputation. If Stefan strongly wants HTML in V2, an MR adds it in a separate round.
- Footer-block phone suppression. When
Stringer.phoneis NULL, the line is suppressed entirely (rather than a placeholder like "Phone: not provided"). Proposed default: suppress. Same rule as the PDF's F-2..F-4 line. Alternative would be noise. - DE formality (Sie vs Du). Current proposal: formal
Sie. Proposed default: keep, pending Iris's pass. DE-CH commerce defaults formal; an over-familiarDureads cheap. Iris owns the final call. - Subject character (
#prefix on the receipt number). Current proposal:#2026-0042. Proposed default: keep — locale-neutral, recognisable as an identifier, searchable in inboxes. Alternative: spell out (Receipt 2026-0042/Rechnung 2026-0042) — already in the body, redundant in the subject. - "Receipt didn't send" indicator placement. Currently spec'd inline on the order detail page with a Resend button. Proposed default: inline with Resend, no separate notifications inbox in V2. Alternative would be a dedicated failed-sends queue in admin tooling — overkill for V2 volume (≤ 50 receipts/year/stringer). Per-order is sufficient.