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;
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.',
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.',
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) => `
|
${escapeHtml(itemName(it))} × ${it.quantity}
|
${fmt(it.lineTotalOre)}
|
`,
)
.join('');
const pickupHtml =
slotLabel && slotTime
? `
${s.pickup}
${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}
`
: '';
const html = `
${escapeHtml(subject)}
|
${escapeHtml(s.bookingNumber)}
${escapeHtml(booking.bookingNumber)}
${escapeHtml(s.eventName)}
|
|
${escapeHtml(greeting)}
${escapeHtml(s.intro)}
${s.orderSummary}
${itemRowsHtml}
| ${s.subtotal} |
${fmt(booking.subtotalOre)} |
| ${s.ofWhichVat} |
${fmt(booking.vatOre)} |
| ${s.total} |
${fmt(booking.totalOre)} |
${pickupHtml}
${escapeHtml(s.invoiceInfo)}
${escapeHtml(s.questions)}
|
|
${escapeHtml(s.footer)}
|
|
`;
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.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, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 = `
${escapeHtml(s.subject)}
|
${escapeHtml(event)}
${escapeHtml(s.subject)}
|
|
${escapeHtml(s.greeting)}
${escapeHtml(s.intro)}
${escapeHtml(s.cta)}
${escapeHtml(s.expiry)}
${escapeHtml(s.fallback)}
${escapeHtml(link)}
${escapeHtml(s.ignore)}
|
|
${escapeHtml(s.footer)}
|
|
`;
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 = `
${escapeHtml(s.subject)}
|
${escapeHtml(s.bookingNumber)}
${escapeHtml(rr.booking.bookingNumber)}
${escapeHtml(event)}
|
|
${escapeHtml(greeting)}
${escapeHtml(s.intro)}
${escapeHtml(s.what)}
|
${escapeHtml(itemName)} × ${rr.quantity}
|
${escapeHtml(fmt(rr.lineTotalOre))}
|
| ${escapeHtml(s.total)} |
${escapeHtml(fmt(rr.lineTotalOre))}
${escapeHtml(s.inclVat)}
|
${escapeHtml(s.pickup)}
${
slotLabel && slotTime
? `${escapeHtml(slotLabel)} · ${escapeHtml(slotTime)}`
: `${escapeHtml(s.noPickup)}`
}
${
rr.notes
? `Notes${escapeHtml(rr.notes)} `
: ''
}
${escapeHtml(s.nextSteps)}
${escapeHtml(s.invoiceInfo)}
|
|
${escapeHtml(s.footer)}
|
|
`;
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 };
}
}