224 lines
8.1 KiB
TypeScript
224 lines
8.1 KiB
TypeScript
/**
|
|
* Service email — Resend si `RESEND_API_KEY` est configuré, sinon log console.
|
|
*
|
|
* Le code consommateur ne doit jamais bloquer ni jeter d'erreur sur un échec
|
|
* d'envoi (best-effort, le booking est l'action principale).
|
|
*/
|
|
|
|
import "server-only";
|
|
|
|
let resendClient: import("resend").Resend | null | undefined;
|
|
|
|
async function getResend(): Promise<import("resend").Resend | null> {
|
|
if (resendClient !== undefined) return resendClient;
|
|
const key = process.env.RESEND_API_KEY?.trim();
|
|
if (!key) {
|
|
resendClient = null;
|
|
return null;
|
|
}
|
|
try {
|
|
const { Resend } = await import("resend");
|
|
resendClient = new Resend(key);
|
|
return resendClient;
|
|
} catch (e) {
|
|
console.error("[email] resend init failed:", e instanceof Error ? e.message : e);
|
|
resendClient = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type EmailOpts = {
|
|
to: string | string[];
|
|
subject: string;
|
|
html: string;
|
|
text?: string;
|
|
replyTo?: string;
|
|
};
|
|
|
|
const DEFAULT_FROM = process.env.RESEND_FROM ?? "Karbé <no-reply@karbe.cosmolan.fr>";
|
|
|
|
export async function sendEmail(opts: EmailOpts): Promise<{ ok: boolean; id?: string; reason?: string }> {
|
|
const client = await getResend();
|
|
if (!client) {
|
|
console.log(
|
|
"[email] dry-run (no RESEND_API_KEY):",
|
|
JSON.stringify({ to: opts.to, subject: opts.subject }),
|
|
);
|
|
return { ok: true, reason: "dry-run" };
|
|
}
|
|
try {
|
|
const { data, error } = await client.emails.send({
|
|
from: DEFAULT_FROM,
|
|
to: Array.isArray(opts.to) ? opts.to : [opts.to],
|
|
subject: opts.subject,
|
|
html: opts.html,
|
|
text: opts.text,
|
|
replyTo: opts.replyTo,
|
|
});
|
|
if (error) {
|
|
console.error("[email] resend error:", error);
|
|
return { ok: false, reason: error.message };
|
|
}
|
|
return { ok: true, id: data?.id };
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error("[email] send failed:", msg);
|
|
return { ok: false, reason: msg };
|
|
}
|
|
}
|
|
|
|
// ---------- Templates ----------
|
|
|
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
|
|
|
|
const baseStyle = `
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
color: #18181b;
|
|
max-width: 580px;
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
line-height: 1.5;
|
|
`;
|
|
|
|
function wrap(title: string, content: string): string {
|
|
return `<!doctype html><html><body style="background:#fafafa;margin:0;padding:24px 12px;">
|
|
<div style="${baseStyle}background:white;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
|
|
<h1 style="margin:0 0 16px;font-size:22px;font-weight:600;color:#18181b;">${title}</h1>
|
|
${content}
|
|
<hr style="margin:24px 0;border:0;border-top:1px solid #e4e4e7;" />
|
|
<p style="font-size:11px;color:#71717a;margin:0;">
|
|
Karbé · <a href="${SITE_URL}" style="color:#71717a;">${SITE_URL}</a><br/>
|
|
Cet email a été envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le.
|
|
</p>
|
|
</div>
|
|
</body></html>`;
|
|
}
|
|
|
|
export async function sendSignupWelcome(to: string, firstName: string): Promise<void> {
|
|
await sendEmail({
|
|
to,
|
|
subject: "Bienvenue sur Karbé",
|
|
html: wrap(
|
|
`Bienvenue ${firstName} !`,
|
|
`<p>Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.</p>
|
|
<p><a href="${SITE_URL}/carbets" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Découvrir les carbets</a></p>`,
|
|
),
|
|
text: `Bienvenue ${firstName} ! Votre compte Karbé est créé. ${SITE_URL}/carbets`,
|
|
});
|
|
}
|
|
|
|
export async function sendBookingRequestToTenant(
|
|
to: string,
|
|
firstName: string,
|
|
bookingId: string,
|
|
carbetTitle: string,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
amount: string,
|
|
currency: string,
|
|
): Promise<void> {
|
|
const fmt = (d: Date) =>
|
|
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
|
|
await sendEmail({
|
|
to,
|
|
subject: `Demande de réservation enregistrée — ${carbetTitle}`,
|
|
html: wrap(
|
|
"Demande de réservation envoyée",
|
|
`<p>Bonjour ${firstName},</p>
|
|
<p>Votre demande de réservation pour <strong>${carbetTitle}</strong> a bien été enregistrée :</p>
|
|
<ul>
|
|
<li>Arrivée : ${fmt(startDate)}</li>
|
|
<li>Départ : ${fmt(endDate)}</li>
|
|
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
|
|
</ul>
|
|
<p>Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.</p>
|
|
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
|
|
),
|
|
});
|
|
}
|
|
|
|
export async function sendBookingRequestToOwner(
|
|
to: string,
|
|
ownerFirstName: string,
|
|
bookingId: string,
|
|
carbetTitle: string,
|
|
tenantName: string,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
): Promise<void> {
|
|
const fmt = (d: Date) =>
|
|
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
|
|
await sendEmail({
|
|
to,
|
|
subject: `Nouvelle demande de réservation — ${carbetTitle}`,
|
|
html: wrap(
|
|
"Nouvelle demande à confirmer",
|
|
`<p>Bonjour ${ownerFirstName},</p>
|
|
<p><strong>${tenantName}</strong> souhaite réserver <strong>${carbetTitle}</strong> :</p>
|
|
<ul>
|
|
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
|
|
</ul>
|
|
<p>Connectez-vous à votre espace hôte pour confirmer ou refuser.</p>
|
|
<p><a href="${SITE_URL}/espace-hote" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mon espace hôte</a></p>`,
|
|
),
|
|
});
|
|
}
|
|
|
|
export async function sendBookingConfirmed(
|
|
to: string,
|
|
firstName: string,
|
|
bookingId: string,
|
|
carbetTitle: string,
|
|
startDate: Date,
|
|
endDate: Date,
|
|
): Promise<void> {
|
|
const fmt = (d: Date) =>
|
|
new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d);
|
|
await sendEmail({
|
|
to,
|
|
subject: `Réservation confirmée — ${carbetTitle}`,
|
|
html: wrap(
|
|
"Votre séjour est confirmé",
|
|
`<p>Bonjour ${firstName},</p>
|
|
<p>Votre réservation pour <strong>${carbetTitle}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.</p>
|
|
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
|
|
),
|
|
});
|
|
}
|
|
|
|
export async function sendPasswordReset(
|
|
to: string,
|
|
resetUrl: string,
|
|
): Promise<void> {
|
|
await sendEmail({
|
|
to,
|
|
subject: "Réinitialisation de votre mot de passe Karbé",
|
|
html: wrap(
|
|
"Réinitialiser votre mot de passe",
|
|
`<p>Vous avez demandé à réinitialiser votre mot de passe Karbé. Cliquez sur le lien ci-dessous pour choisir un nouveau mot de passe (valable 1 heure) :</p>
|
|
<p><a href="${resetUrl}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Réinitialiser mon mot de passe</a></p>
|
|
<p style="font-size:12px;color:#71717a;">Si vous n'avez pas fait cette demande, ignorez simplement cet email — votre mot de passe ne change pas.</p>`,
|
|
),
|
|
text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`,
|
|
});
|
|
}
|
|
|
|
export async function sendBookingRefunded(
|
|
to: string,
|
|
firstName: string,
|
|
bookingId: string,
|
|
carbetTitle: string,
|
|
amount: string,
|
|
currency: string,
|
|
): Promise<void> {
|
|
await sendEmail({
|
|
to,
|
|
subject: `Remboursement traité — ${carbetTitle}`,
|
|
html: wrap(
|
|
"Remboursement en cours",
|
|
`<p>Bonjour ${firstName},</p>
|
|
<p>Votre réservation pour <strong>${carbetTitle}</strong> a été annulée et le remboursement de <strong>${Number(amount).toFixed(2)} ${currency}</strong> est en cours de traitement par Stripe (3 à 5 jours ouvrés).</p>
|
|
<p><a href="${SITE_URL}/reservations/${bookingId}" style="color:#18181b;">Détails de la réservation</a></p>`,
|
|
),
|
|
});
|
|
}
|