@nomideusz/svelte-notify

Transactional email templates as plain functions: pass the booking, get back { subject, html, text }. Localized in English, Polish and Ukrainian, with per-call copy overrides so the app keeps its own voice. QR codes come from @nomideusz/svelte-qr.

pnpm add @nomideusz/svelte-notify

Subject Booking confirmed: Sunrise Hatha class — Ref BK-4F92A1
text/plain alternative
Your booking is confirmed

Hi Anna Kowalska,

Your booking for Sunrise Hatha class is confirmed.

- Date: Monday, 14 September 2026 at 07:30
- Participants: 2
- Total paid: PLN 120.00
- Reference: BK-4F92A1

Show this QR code at the entrance
https://example.com/verify/BK-4F92A1

See you there!

--
thebest.travel

Usage

import { bookingConfirmationTemplate } from '@nomideusz/svelte-notify';

const { subject, html, text } = bookingConfirmationTemplate({
  guestName: 'Anna Kowalska',
  serviceName: 'Sunrise Hatha class',
  slotStartTime: new Date('2026-09-14T07:30:00Z'),
  participants: 2,
  totalAmount: 12000,        // cents
  currency: 'PLN',
  bookingReference: 'BK-4F92A1',
  language: 'pl',            // 'en' | 'pl' | 'uk'; unknown falls back to English
  brand: 'thebest.travel',
  verifyUrl: 'https://example.com/verify/BK-4F92A1',   // inlines a QR code
});

await sendMail({ to: guest.email, subject, html, text });

The templates are pure functions — no transport, no side effects. Every one returns a text/plain alternative alongside the HTML. Send the result with whatever mailer you already use, or with the SMTP transport below. Date and money formatting comes from @nomideusz/svelte-i18n (formatDateTime, formatMoney), so pages and emails share one implementation.

Bind once with createNotifier

import { createNotifier } from '@nomideusz/svelte-notify';

// Once, next to your mailer setup:
const notify = createNotifier({
  brand: 'szkolyjogi.pl',
  theme: { accent: '#3f6f4f', radius: '6px' },
  language: 'pl',                    // default when a message carries none
  timeZone: 'Europe/Warsaw',         // pin it — all times render in this zone
  overrides: (lang) => VOICE[lang],  // house voice, resolved per language
});

// Call sites pass only what varies per message:
notify.bookingConfirmation(data);    // also: providerNotification,
notify.reminder(data);               // cancellation, scheduleChanged,
notify.actionLink({ ... });          // and the action-link shell below

Per-call values win over the config, key by key — language stays per-recipient even when the app has a default.

One-off emails: the shared shell

import { actionLinkTemplate, escapeHtml } from '@nomideusz/svelte-notify';

// Magic link, password reset, invite — the copy is yours, the shell isn't:
const { subject, html, text } = actionLinkTemplate({
  subject: 'Sign in to szkolyjogi.pl',        // plain text, never HTML
  headingHtml: 'Sign in',
  bodyHtml: '<p>Hi ' + escapeHtml(user.name) + ', this link signs you in.</p>',
  cta: { label: 'Sign in', url: magicLink },  // label & url escaped for you
  footnotesHtml: ['The link expires in 15 minutes.'],
  brand: 'szkolyjogi.pl',
});

The escaping contract: templates are trusted, params are not. Fields suffixed Html carry deliberate markup and render as-is — run untrusted values (names typed into public forms) through escapeHtml before composing them in. Plain fields (brand, the cta) are escaped for you. renderLayout, renderText and htmlToText are exported too if you want the card without a subject.

Sending: SMTP transport (server-side)

// Server only — needs the optional nodemailer peer: pnpm add nodemailer
import { createMailer } from '@nomideusz/svelte-notify/transport';

const mailer = createMailer({
  host: env.SMTP_HOST,          // any of host/user/pass missing ⇒ send()
  user: env.SMTP_USER,          // throws (safe default); pass onUnconfigured
  pass: env.SMTP_PASS,          // to log-and-skip instead
  port: 465,                    // default; secure defaults to port === 465
  from: 'noreply@example.com',
  fromName: 'szkolyjogi.pl',    // sent as { name, address }
  replyTo: 'hello@example.com',
});

await mailer.send({ to: data.guestEmail, ...notify.bookingConfirmation(data) });

await mailer.canSend();         // transporter.verify(), cached for 60 s

Transient failures — 4xx SMTP responses and connection errors — are retried with 800 ms then 1600 ms backoff; permanent 5xx is not. Only the /transport subpath imports nodemailer, so the main entry stays pure and browser-safe.