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>
126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
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}`)
|
||
}
|
||
}
|
||
}
|