rubis/apps/api/app/services/mail_dispatcher.ts
ordinarthur 68ed8f2ec6
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 29s
Build & Deploy API / build-and-deploy (push) Successful in 1m7s
feat(api): logs fichier en dev + traces du flow relance/mail
config/logger.ts: en dev, on duplique les logs vers
apps/api/storage/logs/app.log via un `multistream` pino (pretty stdout
+ JSON file en parallèle). Plus fiable que `transport.targets` qui
tourne dans un worker thread et fail silencieusement quand le path
n'est pas accessible.

Logs ciblés sur le pipeline relance pour debug rapide :
  - relance_scheduler : tâche créée + delaySec + queueJobId
  - send_relance_job  : pick-up / skip / envoi / OK / KO
  - mail_dispatcher   : driver actif (smtp/resend) + send OK / err

.gitignore : storage/uploads + storage/logs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 12:37:20 +02:00

231 lines
7.2 KiB
TypeScript

import mail from '@adonisjs/mail/services/main'
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import { DateTime } from 'luxon'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
import * as clock from '#services/clock'
import { captureEmailIfDemo } from '#services/demo/capture'
import type Invoice from '#models/invoice'
import type Client from '#models/client'
import type PlanStep from '#models/plan_step'
import type User from '#models/user'
import type Organization from '#models/organization'
type RelancePayload = {
invoice: Invoice
client: Client
step: PlanStep
user: User | null
organization?: Organization | null
}
/**
* Construit l'objet `vars` interpolé dans subject/body. Exposé pour
* permettre la preview côté contrôleur (wizard de création de plan)
* avec les mêmes variables que ce qui sera réellement envoyé.
*
* Variables disponibles :
* - {{client.name}}, {{client.email}}
* - {{client.contactFirstName}}, {{client.contactLastName}} (peuvent être vides)
* - {{numero}}, {{amount}}, {{dueDate}}, {{issueDate}}
* - {{daysLate}} (jours de retard depuis dueDate, négatif = avant échéance)
* - {{user.fullName}}, {{user.companyName}}
* - {{signature}}
*/
export function buildRelanceVars({
invoice,
client,
user,
organization,
now = DateTime.utc(),
}: {
invoice: Pick<Invoice, 'numero' | 'amountTtcCents' | 'dueDate' | 'issueDate'>
client: Pick<Client, 'name' | 'email' | 'contactFirstName' | 'contactLastName'>
user: Pick<User, 'fullName' | 'signature' | 'email'> | null
organization?: Pick<Organization, 'name'> | null
/** `now` injecté pour respecter virtualNow en mode démo. */
now?: DateTime
}) {
const dueDate = invoice.dueDate.toJSDate()
// Jours de retard arrondis à l'entier — démo-aware via `now` injecté.
const daysLate = Math.floor(
now.startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
)
return {
client: {
name: client.name,
email: client.email,
contactFirstName: client.contactFirstName ?? '',
contactLastName: client.contactLastName ?? '',
},
user: {
fullName: user?.fullName ?? '',
companyName: organization?.name ?? '',
},
numero: invoice.numero,
amount: formatAmountFr(invoice.amountTtcCents),
dueDate: formatDateFr(dueDate),
issueDate: formatDateFr(invoice.issueDate.toJSDate()),
daysLate: String(daysLate),
signature: user?.signature ?? user?.fullName ?? '',
}
}
/**
* Envoie un email de relance à un client à partir d'un step.
* Le subject/body du step contiennent des placeholders Mustache-like
* qu'on interpole avant l'envoi (cf. `buildRelanceVars`).
*
* Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev,
* `resend` en prod).
*/
export async function sendRelanceEmail({
invoice,
client,
step,
user,
organization,
}: RelancePayload) {
const vars = buildRelanceVars({
invoice,
client,
user,
organization,
now: await clock.now(invoice.organizationId),
})
const subject = renderTemplate(step.subject, vars)
const body = renderTemplate(step.body, vars)
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
// FORK DÉMO — unique point où l'app dévie de la prod. Si l'org est
// en mode démo, on capture l'email dans demo_captured_emails au lieu
// de l'envoyer via Resend. Tout le reste du pipeline (idempotence,
// status update, rubis bump) tourne identique.
const captured = await captureEmailIfDemo({
organizationId: invoice.organizationId,
kind: 'relance',
to: { email: client.email, name: client.name },
from: { email: fromAddress, name: fromName },
replyTo: user?.email ?? null,
subject,
body,
meta: { invoiceId: invoice.id, clientId: client.id, stepOrder: step.order },
})
if (captured) {
logger.info(
{ invoiceId: invoice.id, numero: invoice.numero, to: client.email },
'sendRelanceEmail: capturé en mode démo (pas d\'envoi réel)'
)
return // demo : ne pas envoyer pour de vrai
}
const driver = env.get('MAIL_DRIVER', 'smtp')
logger.info(
{
invoiceId: invoice.id,
numero: invoice.numero,
to: client.email,
from: fromAddress,
driver,
subjectPreview: subject.slice(0, 80),
},
'sendRelanceEmail: envoi via driver'
)
try {
const mailer = mail.use(driver)
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(client.email, client.name)
.subject(subject)
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
// décidé d'un look graphique pour les relances.
.text(body)
// Reply-To pointe sur l'utilisateur Rubis : si le client final répond
// à la relance, sa réponse arrive chez le patron de la TPE, pas dans
// notre boîte transactionnelle.
if (user?.email) {
m.replyTo(user.email, user.fullName ?? user.email)
}
})
logger.info(
{ invoiceId: invoice.id, numero: invoice.numero, driver },
'sendRelanceEmail: send OK'
)
} catch (err) {
logger.error(
{ err, invoiceId: invoice.id, numero: invoice.numero, driver },
'sendRelanceEmail: échec envoi'
)
throw err
}
}
type CheckinPayload = {
invoice: Invoice
client: Client
user: User
paidUrl: string
pendingUrl: string
}
/**
* Envoie le check-in à l'**utilisateur** (pas au client). Lui demande
* si la facture a été payée, avec 2 liens publics qui modifient l'état
* côté API et redirigent ensuite vers le SPA.
*
* Texte brut V1. Un template HTML viendra quand on aura figé le look
* graphique (cf. ADR-021).
*/
export async function sendCheckinEmail({
invoice,
client,
user,
paidUrl,
pendingUrl,
}: CheckinPayload) {
const subject = `Facture ${invoice.numero} — payée par ${client.name} ?`
const body = `Bonjour ${user.fullName ?? ''},
La facture ${invoice.numero} (${formatAmountFr(invoice.amountTtcCents)}) émise pour ${client.name}
arrive à échéance aujourd'hui (${formatDateFr(invoice.dueDate.toJSDate())}).
Avant que Rubis n'envoie la première relance, dites-nous où vous en êtes :
✓ J'ai été payé(e), pas besoin de relancer :
${paidUrl}
→ Toujours en attente, lance la relance comme prévu :
${pendingUrl}
Ces liens expirent dans 24h.
Merci,
L'équipe Rubis`
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
// FORK DÉMO — capture si demoMode (cf. sendRelanceEmail).
const captured = await captureEmailIfDemo({
organizationId: invoice.organizationId,
kind: 'checkin',
to: { email: user.email, name: user.fullName ?? user.email },
from: { email: fromAddress, name: fromName },
replyTo: null,
subject,
body,
meta: { invoiceId: invoice.id, clientId: client.id, paidUrl, pendingUrl },
})
if (captured) return
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.text(body)
})
}