Files
boka-gasol247/src/lib/mailjet.ts
2026-05-22 22:05:16 +02:00

621 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Mailjet from 'node-mailjet';
import { formatOre } from './money';
import type {
Booking,
BookingItem,
PickupSlot,
ReplacementRequest,
} from '@prisma/client';
let client: Mailjet | null = null;
function getClient(): Mailjet | null {
if (!process.env.MAILJET_API_KEY || !process.env.MAILJET_API_SECRET) {
return null;
}
if (!client) {
client = new Mailjet({
apiKey: process.env.MAILJET_API_KEY,
apiSecret: process.env.MAILJET_API_SECRET,
});
}
return client;
}
type BookingWithRels = Booking & {
items: BookingItem[];
pickupSlot: PickupSlot | null;
};
type EmailStrings = {
subject: string;
greeting: string;
intro: string;
bookingNumber: string;
orderSummary: string;
pickup: string;
invoiceInfo: string;
responsibilityTitle: string;
responsibilityBody: string;
questions: string;
footer: string;
subtotal: string;
ofWhichVat: string;
total: string;
eventName: string;
};
function getStrings(locale: 'sv' | 'en', event: string): EmailStrings {
if (locale === 'sv') {
return {
subject: `Bokningsbekräftelse {number} — ${event}`,
greeting: 'Hej {name},',
intro: `Tack för din beställning av gasoltuber till ${event}. Här är din bokningsbekräftelse.`,
bookingNumber: 'Bokningsnummer',
orderSummary: 'Beställning',
pickup: 'Upphämtning',
invoiceInfo: 'Faktura skickas till organisationen efter eventet.',
responsibilityTitle: 'Beställarens ansvar',
responsibilityBody:
'Som beställare ansvarar du för att din by/kontingent har tillstånd för den mängd gasol som beställs. Riktlinje: 60 liter per 40 deltagare.',
questions: 'Har du frågor? Svara på detta mejl så hjälper vi dig.',
footer: 'Detta är en automatiserad bekräftelse från Gasol247.',
subtotal: 'Delsumma',
ofWhichVat: 'varav moms',
total: 'Totalt',
eventName: event,
};
}
return {
subject: `Booking confirmation {number} — ${event}`,
greeting: 'Hi {name},',
intro: `Thank you for ordering LPG cylinders for ${event}. Here is your booking confirmation.`,
bookingNumber: 'Booking number',
orderSummary: 'Order',
pickup: 'Pickup',
invoiceInfo: 'An invoice will be sent to your organization after the event.',
responsibilityTitle: 'Your responsibility',
responsibilityBody:
'As the orderer you are responsible for ensuring your village/contingent has permission for the quantity of LPG ordered. Guideline: 60 litres per 40 participants.',
questions: 'Questions? Reply to this email and we will help you.',
footer: 'This is an automated confirmation from Gasol247.',
subtotal: 'Subtotal',
ofWhichVat: 'of which VAT',
total: 'Total',
eventName: event,
};
}
export function renderBookingEmail(
booking: BookingWithRels,
locale: 'sv' | 'en' = 'sv',
): { subject: string; html: string; text: string } {
const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
const s = getStrings(locale, event);
const fmt = (ore: number) => formatOre(ore, locale);
const itemName = (it: BookingItem) =>
locale === 'sv' ? it.nameSv : it.nameEn;
const slotLabel = booking.pickupSlot
? locale === 'sv'
? booking.pickupSlot.labelSv
: booking.pickupSlot.labelEn
: null;
const slotTime = booking.pickupSlot
? booking.pickupSlot.startsAt.toLocaleString(
locale === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'medium', timeStyle: 'short' },
)
: null;
const subject = s.subject.replace('{number}', booking.bookingNumber);
const greeting = s.greeting.replace('{name}', booking.contactName);
const itemRowsHtml = booking.items
.map(
(it) => `
<tr>
<td style="padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:14px;color:#0f172a;">
${escapeHtml(itemName(it))} <span style="color:#94a3b8;">× ${it.quantity}</span>
</td>
<td style="padding:8px 0;border-bottom:1px solid #e2e8f0;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">
${fmt(it.lineTotalOre)}
</td>
</tr>`,
)
.join('');
const pickupHtml =
slotLabel && slotTime
? `
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">${s.pickup}</h2>
<p style="font-size:14px;color:#0f172a;margin:0;">${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}</p>`
: '';
const html = `<!doctype html>
<html lang="${locale}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(subject)}</title>
</head>
<body style="margin:0;background:#f8fafc;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
<tr>
<td style="background:#ea580c;color:#ffffff;padding:24px;">
<div style="font-size:12px;opacity:0.9;text-transform:uppercase;letter-spacing:0.05em;">${escapeHtml(s.bookingNumber)}</div>
<div style="font-size:24px;font-weight:600;margin-top:4px;">${escapeHtml(booking.bookingNumber)}</div>
<div style="font-size:12px;opacity:0.9;margin-top:8px;">${escapeHtml(s.eventName)}</div>
</td>
</tr>
<tr>
<td style="padding:24px;">
<p style="font-size:15px;margin:0 0 8px;">${escapeHtml(greeting)}</p>
<p style="font-size:14px;color:#475569;margin:0 0 16px;">${escapeHtml(s.intro)}</p>
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:16px 0 8px;">${s.orderSummary}</h2>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
${itemRowsHtml}
<tr>
<td style="padding-top:12px;font-size:14px;color:#475569;">${s.subtotal}</td>
<td style="padding-top:12px;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">${fmt(booking.subtotalOre)}</td>
</tr>
<tr>
<td style="font-size:13px;color:#64748b;">${s.ofWhichVat}</td>
<td style="font-size:13px;color:#64748b;text-align:right;white-space:nowrap;">${fmt(booking.vatOre)}</td>
</tr>
<tr>
<td style="padding-top:8px;font-size:15px;color:#0f172a;font-weight:600;border-top:1px solid #e2e8f0;">${s.total}</td>
<td style="padding-top:8px;font-size:15px;color:#0f172a;font-weight:600;text-align:right;white-space:nowrap;border-top:1px solid #e2e8f0;">${fmt(booking.totalOre)}</td>
</tr>
</table>
${pickupHtml}
<div style="margin-top:24px;padding:16px;background:#fef3c7;border:1px solid #fcd34d;border-radius:8px;font-size:13px;color:#78350f;">
<div style="font-weight:600;color:#7c2d12;margin-bottom:4px;">${escapeHtml(s.responsibilityTitle)}</div>
${escapeHtml(s.responsibilityBody)}
</div>
<div style="margin-top:16px;padding:16px;background:#f1f5f9;border-radius:8px;font-size:13px;color:#475569;">
${escapeHtml(s.invoiceInfo)}
</div>
<p style="font-size:13px;color:#64748b;margin:24px 0 0;">${escapeHtml(s.questions)}</p>
</td>
</tr>
<tr>
<td style="padding:16px 24px;border-top:1px solid #e2e8f0;font-size:12px;color:#94a3b8;text-align:center;">
${escapeHtml(s.footer)}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const text = [
greeting,
'',
s.intro,
'',
`${s.bookingNumber}: ${booking.bookingNumber}`,
'',
`${s.orderSummary}:`,
...booking.items.map(
(it) => ` ${itemName(it)} × ${it.quantity} ${fmt(it.lineTotalOre)}`,
),
'',
`${s.subtotal}: ${fmt(booking.subtotalOre)}`,
`${s.ofWhichVat}: ${fmt(booking.vatOre)}`,
`${s.total}: ${fmt(booking.totalOre)}`,
...(slotLabel ? ['', `${s.pickup}: ${slotLabel} ${slotTime}`] : []),
'',
`${s.responsibilityTitle}: ${s.responsibilityBody}`,
'',
s.invoiceInfo,
'',
s.questions,
'',
'— ' + s.footer,
].join('\n');
return { subject, html, text };
}
export async function sendBookingConfirmation(
booking: BookingWithRels,
): Promise<{ ok: boolean; error?: string }> {
const c = getClient();
if (!c) {
console.warn('[mailjet] not configured — skipping email send');
return { ok: false, error: 'mailjet-not-configured' };
}
const locale = (booking.locale as 'sv' | 'en') ?? 'sv';
const { subject, html, text } = renderBookingEmail(booking, locale);
const fromEmail = process.env.MAIL_FROM_EMAIL ?? 'no-reply@example.com';
const fromName = process.env.MAIL_FROM_NAME ?? 'Gasol247 Bokning';
try {
await c.post('send', { version: 'v3.1' }).request({
Messages: [
{
From: { Email: fromEmail, Name: fromName },
To: [{ Email: booking.email, Name: booking.contactName }],
Subject: subject,
HTMLPart: html,
TextPart: text,
CustomID: booking.bookingNumber,
},
],
});
return { ok: true };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[mailjet] send failed', msg);
return { ok: false, error: msg };
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
type MagicLinkStrings = {
subject: string;
greeting: string;
intro: string;
cta: string;
expiry: string;
fallback: string;
ignore: string;
footer: string;
};
function magicLinkStrings(locale: 'sv' | 'en', event: string): MagicLinkStrings {
if (locale === 'sv') {
return {
subject: `Inloggningslänk — ${event}`,
greeting: 'Hej,',
intro: 'Klicka på knappen nedan för att se dina bokningar och beställa byte av tomma gasolflaskor.',
cta: 'Öppna min sida',
expiry: 'Länken gäller i 1 timme och kan användas en gång.',
fallback: 'Fungerar inte knappen? Klistra in denna länk i webbläsaren:',
ignore: 'Om du inte begärt detta mejl kan du ignorera det.',
footer: 'Detta är ett automatiskt mejl från Gasol247.',
};
}
return {
subject: `Sign-in link — ${event}`,
greeting: 'Hi,',
intro: 'Click the button below to view your bookings and request empty cylinder swaps.',
cta: 'Open my page',
expiry: 'This link is valid for 1 hour and can be used once.',
fallback: 'Button not working? Paste this link into your browser:',
ignore: "If you didn't request this email you can ignore it.",
footer: 'This is an automated email from Gasol247.',
};
}
export function renderMagicLinkEmail(
link: string,
locale: 'sv' | 'en' = 'sv',
): { subject: string; html: string; text: string } {
const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
const s = magicLinkStrings(locale, event);
const html = `<!doctype html>
<html lang="${locale}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(s.subject)}</title>
</head>
<body style="margin:0;background:#f8fafc;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
<tr>
<td style="background:#0f172a;color:#ffffff;padding:24px;">
<div style="font-size:12px;opacity:0.8;text-transform:uppercase;letter-spacing:0.05em;">${escapeHtml(event)}</div>
<div style="font-size:22px;font-weight:600;margin-top:4px;">${escapeHtml(s.subject)}</div>
</td>
</tr>
<tr>
<td style="padding:24px;">
<p style="font-size:15px;margin:0 0 8px;">${escapeHtml(s.greeting)}</p>
<p style="font-size:14px;color:#475569;margin:0 0 24px;">${escapeHtml(s.intro)}</p>
<p style="margin:0 0 16px;">
<a href="${escapeHtml(link)}" style="display:inline-block;background:#ea580c;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:8px;font-weight:600;font-size:15px;">${escapeHtml(s.cta)}</a>
</p>
<p style="font-size:13px;color:#64748b;margin:16px 0 0;">${escapeHtml(s.expiry)}</p>
<p style="font-size:12px;color:#64748b;margin:24px 0 0;">${escapeHtml(s.fallback)}</p>
<p style="font-size:12px;color:#0f172a;word-break:break-all;margin:4px 0 0;"><a href="${escapeHtml(link)}" style="color:#0f172a;">${escapeHtml(link)}</a></p>
<p style="font-size:12px;color:#94a3b8;margin:24px 0 0;">${escapeHtml(s.ignore)}</p>
</td>
</tr>
<tr>
<td style="padding:16px 24px;border-top:1px solid #e2e8f0;font-size:12px;color:#94a3b8;text-align:center;">
${escapeHtml(s.footer)}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const text = [
s.greeting,
'',
s.intro,
'',
link,
'',
s.expiry,
'',
s.ignore,
'',
'— ' + s.footer,
].join('\n');
return { subject: s.subject, html, text };
}
export async function sendMagicLinkEmail(
email: string,
link: string,
locale: 'sv' | 'en' = 'sv',
): Promise<{ ok: boolean; error?: string }> {
const c = getClient();
if (!c) {
// Dev fallback: print the link so the flow is testable without Mailjet.
console.warn(
`\n[magic-link] Mailjet not configured. Login link for ${email}:\n ${link}\n`,
);
return { ok: false, error: 'mailjet-not-configured' };
}
const { subject, html, text } = renderMagicLinkEmail(link, locale);
const fromEmail = process.env.MAIL_FROM_EMAIL ?? 'no-reply@example.com';
const fromName = process.env.MAIL_FROM_NAME ?? 'Gasol247 Bokning';
try {
await c.post('send', { version: 'v3.1' }).request({
Messages: [
{
From: { Email: fromEmail, Name: fromName },
To: [{ Email: email }],
Subject: subject,
HTMLPart: html,
TextPart: text,
},
],
});
return { ok: true };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[mailjet] magic-link send failed', msg);
return { ok: false, error: msg };
}
}
type ReplacementWithRels = ReplacementRequest & {
booking: Booking;
bookingItem: BookingItem;
pickupSlot: PickupSlot | null;
};
type ReplacementStrings = {
subject: string;
greeting: string;
intro: string;
what: string;
total: string;
inclVat: string;
invoiceInfo: string;
bookingNumber: string;
pickup: string;
noPickup: string;
status: string;
nextSteps: string;
footer: string;
};
function replacementStrings(
locale: 'sv' | 'en',
event: string,
): ReplacementStrings {
if (locale === 'sv') {
return {
subject: `Bytesärende mottaget — ${event}`,
greeting: 'Hej {name},',
intro: 'Vi har tagit emot din begäran om byte av tomma gasolflaskor.',
what: 'Begärt byte',
total: 'Belopp',
inclVat: 'inkl. moms',
invoiceInfo:
'Bytet faktureras tillsammans med er övriga bokning efter eventet.',
bookingNumber: 'Bokningsnummer',
pickup: 'Önskad upphämtningstid',
noPickup: 'Ingen tid vald — vi återkommer med förslag.',
status: 'Status',
nextSteps: 'Vi schemalägger bytet och återkommer med bekräftelse.',
footer: 'Detta är en automatiserad bekräftelse från Gasol247.',
};
}
return {
subject: `Replacement request received — ${event}`,
greeting: 'Hi {name},',
intro: 'We have received your request to swap empty gas cylinders.',
what: 'Requested swap',
total: 'Amount',
inclVat: 'incl. VAT',
invoiceInfo:
'The swap will be invoiced together with your other booking after the event.',
bookingNumber: 'Booking number',
pickup: 'Preferred pickup time',
noPickup: 'No time selected — we will get back to you.',
status: 'Status',
nextSteps: 'We will schedule the swap and get back to you.',
footer: 'This is an automated confirmation from Gasol247.',
};
}
export function renderReplacementEmail(
rr: ReplacementWithRels,
locale: 'sv' | 'en' = 'sv',
): { subject: string; html: string; text: string } {
const event = process.env.NEXT_PUBLIC_EVENT_NAME ?? 'Jamboree';
const s = replacementStrings(locale, event);
const fmt = (ore: number) => formatOre(ore, locale);
const greeting = s.greeting.replace('{name}', rr.booking.contactName);
const itemName = locale === 'sv' ? rr.nameSv : rr.nameEn;
const slotLabel = rr.pickupSlot
? locale === 'sv'
? rr.pickupSlot.labelSv
: rr.pickupSlot.labelEn
: null;
const slotTime = rr.pickupSlot
? rr.pickupSlot.startsAt.toLocaleString(
locale === 'sv' ? 'sv-SE' : 'en-SE',
{ dateStyle: 'medium', timeStyle: 'short' },
)
: null;
const html = `<!doctype html>
<html lang="${locale}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(s.subject)}</title>
</head>
<body style="margin:0;background:#f8fafc;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f8fafc;padding:24px 0;">
<tr><td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
<tr>
<td style="background:#ea580c;color:#ffffff;padding:24px;">
<div style="font-size:12px;opacity:0.9;text-transform:uppercase;letter-spacing:0.05em;">${escapeHtml(s.bookingNumber)}</div>
<div style="font-size:22px;font-weight:600;margin-top:4px;">${escapeHtml(rr.booking.bookingNumber)}</div>
<div style="font-size:12px;opacity:0.9;margin-top:8px;">${escapeHtml(event)}</div>
</td>
</tr>
<tr>
<td style="padding:24px;">
<p style="font-size:15px;margin:0 0 8px;">${escapeHtml(greeting)}</p>
<p style="font-size:14px;color:#475569;margin:0 0 16px;">${escapeHtml(s.intro)}</p>
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:16px 0 8px;">${escapeHtml(s.what)}</h2>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td style="padding:4px 0;font-size:14px;color:#0f172a;">
${escapeHtml(itemName)} <span style="color:#94a3b8;">× ${rr.quantity}</span>
</td>
<td style="padding:4px 0;font-size:14px;color:#0f172a;text-align:right;white-space:nowrap;">
${escapeHtml(fmt(rr.lineTotalOre))}
</td>
</tr>
<tr>
<td style="padding-top:6px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">${escapeHtml(s.total)}</td>
<td style="padding-top:6px;font-size:14px;color:#0f172a;font-weight:600;text-align:right;white-space:nowrap;border-top:1px solid #e2e8f0;">
${escapeHtml(fmt(rr.lineTotalOre))}
<span style="font-size:12px;font-weight:400;color:#64748b;">${escapeHtml(s.inclVat)}</span>
</td>
</tr>
</table>
<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">${escapeHtml(s.pickup)}</h2>
<p style="font-size:14px;margin:0;">${
slotLabel && slotTime
? `${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}`
: `<span style="color:#64748b;">${escapeHtml(s.noPickup)}</span>`
}</p>
${
rr.notes
? `<h2 style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;margin:24px 0 8px;">Notes</h2><p style="font-size:14px;margin:0;white-space:pre-wrap;color:#475569;">${escapeHtml(rr.notes)}</p>`
: ''
}
<div style="margin-top:24px;padding:16px;background:#f1f5f9;border-radius:8px;font-size:13px;color:#475569;">
${escapeHtml(s.nextSteps)}<br>
${escapeHtml(s.invoiceInfo)}
</div>
</td>
</tr>
<tr>
<td style="padding:16px 24px;border-top:1px solid #e2e8f0;font-size:12px;color:#94a3b8;text-align:center;">
${escapeHtml(s.footer)}
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
const text = [
greeting,
'',
s.intro,
'',
`${s.bookingNumber}: ${rr.booking.bookingNumber}`,
`${s.what}: ${itemName} × ${rr.quantity}`,
`${s.total}: ${fmt(rr.lineTotalOre)} (${s.inclVat})`,
`${s.pickup}: ${slotLabel ? `${slotLabel} ${slotTime}` : s.noPickup}`,
...(rr.notes ? ['', `Notes: ${rr.notes}`] : []),
'',
s.nextSteps,
s.invoiceInfo,
'',
'— ' + s.footer,
].join('\n');
return { subject: s.subject, html, text };
}
export async function sendReplacementConfirmation(
rr: ReplacementWithRels,
): Promise<{ ok: boolean; error?: string }> {
const c = getClient();
if (!c) {
console.warn('[mailjet] not configured — skipping replacement send');
return { ok: false, error: 'mailjet-not-configured' };
}
const locale = (rr.booking.locale as 'sv' | 'en') ?? 'sv';
const { subject, html, text } = renderReplacementEmail(rr, locale);
const fromEmail = process.env.MAIL_FROM_EMAIL ?? 'no-reply@example.com';
const fromName = process.env.MAIL_FROM_NAME ?? 'Gasol247 Bokning';
try {
await c.post('send', { version: 'v3.1' }).request({
Messages: [
{
From: { Email: fromEmail, Name: fromName },
To: [{ Email: rr.booking.email, Name: rr.booking.contactName }],
Subject: subject,
HTMLPart: html,
TextPart: text,
CustomID: `replacement-${rr.id}`,
},
],
});
return { ok: true };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[mailjet] replacement send failed', msg);
return { ok: false, error: msg };
}
}