rubis/apps/api/commands/send_test_email.ts
ordinarthur 919ebfe755
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 36s
Build & Deploy API / build-and-deploy (push) Successful in 1m36s
Build & Deploy Landing / build-and-deploy (push) Successful in 1m18s
feat(release): v1.11.0 — marque blanche pour le plan Business (backend)
Première moitié de la feature marque blanche : la machinerie complète qui
permet à un compte Business d'envoyer ses emails de relance avec son
propre logo, ses propres couleurs et son nom comme expéditeur, à la place
du branding Rubis.

Architecture :

- Nouvelle colonne JSONB `organizations.brand_settings` (12 tokens
  customisables : logo, senderName, et 10 couleurs — primary, banner,
  body bg, card bg, text, text muted, border, link, button text).
  Null = palette Rubis intacte. Validation hex stricte (#RRGGBB).

- Service `#services/brand` avec `resolveBrandTokens(org)` qui merge
  defaults + overrides en respectant le plan (couleurs/logo = Business
  only ; senderName = cascade pour tous les plans). Mergeurs avec
  sémantique "null = reset au default sur ce champ précis" pour les
  patches partiels.

- Service mutualisé `#services/media_storage` qui remplace l'ancien
  `blog_uploads.ts`. Scopes `blog` (4 MB, jpg/png/webp) et `brand-logo`
  (1 MB, + svg accepté). Cleanup automatique du logo précédent lors
  d'un remplacement (pas de versioning — la conv produit est "on écrase").

- Controller `BrandController` (5 endpoints) + middleware
  `AssertBusinessPlanMiddleware` qui throw 403 `business_plan_required`
  (code matché par le SPA pour l'upsell card).

- Refactor des 3 templates mail (relance, payment thanks, checkin) +
  layout commun pour accepter `tokens: BrandTokens` en prop. Le
  dispatcher résout les tokens per-org pour relance + remerciement
  (= user → client, branded), et passe `DEFAULT_BRAND` au checkin
  (= Rubis → user, toujours Rubis-branded).

- Routes publiques pour le logo : `/api/v1/uploads/brand-logos/:filename`
  (sans auth, cache immutable, X-Content-Type-Options: nosniff pour les SVG).

UI self-service arrive dans la prochaine version (v1.12.0). En attendant,
un compte Business peut être configuré via Bruno / API directe.

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

126 lines
4.5 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import mail from '@adonisjs/mail/services/main'
import { render } from '@react-email/components'
import { DateTime } from 'luxon'
import env from '#start/env'
import { CheckinEmail } from '#mails/checkin_email'
import { DEFAULT_BRAND } from '#services/brand'
import { RelanceEmail } from '#mails/relance_email'
/**
* Envoie un email de test via le mailer courant (typiquement Resend)
* pour valider la conf SPF/DKIM/clé API ET le rendu visuel des templates
* HTML React Email — sans passer par toute la chaîne facture → BullMQ.
*
* Usage :
* node ace send:test-email arthur@example.com # checkin
* node ace send:test-email arthur@example.com --template=checkin
* node ace send:test-email arthur@example.com --template=relance
* node ace send:test-email arthur@example.com --template=plain # ancien
* node ace send:test-email arthur@example.com --reply-to=patron@tpe.fr
*
* Default `checkin` car c'est le template avec les boutons CTA — le plus
* intéressant à valider visuellement au premier coup d'œil.
*/
export default class SendTestEmail extends BaseCommand {
static commandName = 'send:test-email'
static description = 'Envoie un email de test (HTML React Email) pour valider rendu + driver'
static options: CommandOptions = {
startApp: true,
}
@args.string({ description: 'Adresse destinataire' })
declare to: string
@flags.string({
description: 'Template à envoyer : checkin (default) | relance | plain',
default: 'checkin',
})
declare template: string
@flags.string({ description: 'Adresse de reply-to (optionnelle)' })
declare replyTo?: string
async run() {
const driver = env.get('MAIL_DRIVER', 'smtp')
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
this.logger.info(`Driver: ${driver}`)
this.logger.info(`From: ${fromName} <${fromAddress}>`)
this.logger.info(`To: ${this.to}`)
this.logger.info(`Template: ${this.template}`)
if (this.replyTo) this.logger.info(`ReplyTo: ${this.replyTo}`)
// Données factices pour le rendu — évite d'avoir à créer une vraie
// facture en DB juste pour tester l'email.
const fakeInvoice = {
numero: 'F2026-TEST',
amountFormatted: '1 234,56 €',
dueDateFormatted: DateTime.utc().toFormat('dd/LL/yyyy'),
}
let subject: string
let text: string
let html: string | undefined
if (this.template === 'plain') {
subject = "[Rubis] Test d'envoi (plain text)"
text =
`Bonjour,\n\n` +
`Ceci est un email de test envoyé depuis Rubis sur l'ongle.\n` +
`Driver : ${driver}\n` +
`Date : ${new Date().toISOString()}\n\n` +
`— L'équipe Rubis`
html = undefined
} else if (this.template === 'relance') {
subject = `Facture ${fakeInvoice.numero} échue (test)`
text =
`Bonjour,\n\nPetit rappel pour la facture ${fakeInvoice.numero}.\n\nCordialement,\nArthur Barré`
html = await render(
RelanceEmail({
tokens: { ...DEFAULT_BRAND, senderName: 'Arthur Barré (test)' },
invoice: { ...fakeInvoice, daysLate: 3 },
bodyText: text,
landingUrl,
})
)
} else {
// checkin par défaut
subject = `[Rubis] Test — Facture ${fakeInvoice.numero}, payée ?`
text =
`Test d'envoi du template check-in.\n\n` +
`Lien "payée" : https://example.com/paid\n` +
`Lien "pending" : https://example.com/pending\n\n` +
`— L'équipe Rubis`
html = await render(
CheckinEmail({
tokens: DEFAULT_BRAND,
invoice: fakeInvoice,
client: { name: 'Boulangerie Test' },
user: { fullName: 'Arthur Barré' },
paidUrl: 'https://example.com/paid',
pendingUrl: 'https://example.com/pending',
landingUrl,
})
)
}
const mailer = mail.use(driver)
const response = await mailer.send((m) => {
m.from(fromAddress, fromName).to(this.to).subject(subject).text(text)
if (html) m.html(html)
if (this.replyTo) m.replyTo(this.replyTo)
})
this.logger.success('Email envoyé')
if (response?.messageId) {
this.logger.info(`messageId: ${response.messageId}`)
}
}
}