Skip to content

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_at is set (T2 transition) OR a manual "resend receipt" is invoked. Per receipts.md § Email delivery.
  • Recipient: order.client_profile.person.email if non-null. If null, the email step is silently skipped (the print path remains). Per receipts.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. Per i18n.md § PDF receipts.
  • Transport: Resend SMTP via aiosmtplib; envelope-from + DKIM-d on wagen.io. Per integrations.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:

  1. 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."
  2. 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.
  3. 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.

  1. DKIM alignment. d=wagen.io is the only domain we sign. A From: stefan@example.ch that fails to align with d=wagen.io lands in spam at most major receivers. Per integrations.md, Atlas owns the wagen.io DKIM record; cross-domain DKIM (signing on behalf of every stringer's personal domain) is operationally infeasible.
  2. Reputation pooling. Resend's reputation lives on wagen.io. Routing every stringer's mail through that single domain pools the reputation; routing through From: stringer.email would 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: 480px collapses 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.io static 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:

email_locale = order.client_profile.person.default_locale or stringer.default_locale

Same locale across: subject, body, attachment filename, attached PDF.

Three guarantees this is meant to deliver:

  1. A client never gets a mixed-locale message (e.g. EN subject + DE body) — both render from one locale variable.
  2. 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.
  3. The recipient mailbox's Accept-Language is irrelevant — emails have no Accept-Language analog; 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:

  1. The order's state still progresses to Strung. The send is fail-soft; we do not roll back strung_at because an email didn't ship.
  2. The PDF is still rendered + still downloadable from the order detail page. The print path is unaffected.
  3. 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."
  4. 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.pdf reads aloud naturally. Avoid hash filenames like 7f3e8a.pdf.
  • DE umlauts are encoded as UTF-8 with the appropriate Content-Type: text/plain; charset=utf-8 header 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:

  1. Catalogue-driven — every label in the i18n string keys table. EN normative; Iris's DE pass closes the round.
  2. 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.
  3. Format — receipt number (#YYYY-NNNN, locale-neutral), total (Babel format_currency('CHF', locale) — both locales render CHF 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

Open questions for Stefan (with proposed defaults)

  1. 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 same receipt_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.
  2. Reply-To addressing. Current proposal: Reply-To is 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.
  3. 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.
  4. 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.
  5. Footer-block phone suppression. When Stringer.phone is 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.
  6. DE formality (Sie vs Du). Current proposal: formal Sie. Proposed default: keep, pending Iris's pass. DE-CH commerce defaults formal; an over-familiar Du reads cheap. Iris owns the final call.
  7. 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.
  8. "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.