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) => ` ${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.responsibilityTitle)}
${escapeHtml(s.responsibilityBody)}
${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.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, '&') .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 }; } }