manager-service/ManagerService/Services/ResendEmailService.cs
Thomas Fransolet af6b5b76ef Self-service onboarding (Stripe + Resend) + Postgres v3 schema (pgvector, media, visitor questions)
75 fichiers, ~1 mois de travail depuis f222861 (17/07). Contenu :

Onboarding self-service — jamais exécuté de bout en bout
  OnboardingController, StripeWebhookController, StripeService, ResendEmailService
  + IEmailService (10 templates), EmailTemplates/, TrialLifecycleService (Hangfire),
  PasswordTokenHelper, SlugHelper, 5 DTOs. Essai 14 j, Stripe customer/Checkout/Tax,
  mot de passe oublié + invitation user, plafond IA d'essai.

Schéma Postgres v3 — passe pendant que la base est vide
  ContentEmbedding + index HNSW (vector_cosine_ops), IEmbeddingService +
  GoogleEmbeddingService (gemini-embedding-001, 768 dims), Deployment/Dockerfile.postgres
  (postgis 3.4.3 + pgvector 0.8.6, épinglé par digest — un tag mobile rejouerait le
  warning de collation glibc). Colonnes Resource : StoragePath, FileName,
  IncludeInAiKnowledge, AiIndexStatus + nouveaux ResourceType ajoutés EN FIN d'enum.

Guide IA
  Champs Guide* sur Instance + InstanceDTO, AssistantService lit la configuration client
  dans les 4 blocs de prompt (ton codé en dur retiré, règle hors-sujet ajoutée aux deux
  variantes qui n'en avaient pas), IHttpClientFactory à la place des new HttpClient().
  Table VisitorQuestion + ConversationId sur AiChatRequest.

Stats
  Rétention unifiée à 13 mois (instances ET plans), VisitEventPurgeService.

Nettoyage
  IsStepLocked / IsHiddenInitially / FactContent supprimés de GuidedStep — IsStepLocked
  rendait une étape définitivement infranchissable même après réussite.
  SectionMap allégé (-57 lignes).

Tests
  SectionParcoursControllerTests, FakeConfiguration, FakeEmailService.
  ContentEmbedding a cassé 116 tests sur 124 (EF InMemory ne connaît pas Vector) :
  l'entité est exclue quand le provider n'est pas Npgsql. Conséquence assumée —
  le vector store n'est couvert par aucun test. dotnet test 124/124.

10 migrations EF. Base locale à jour, dotnet build 0 erreur.
Rien n'est en prod : la bascule Mongo → Postgres est décrite dans DOCS/STATUS.md §1quinquies.

⚠️ appsettings.json contient les clés Stripe (test) et Resend (prod) en clair — à rotationner.
2026-08-09 22:11:48 +02:00

137 lines
6.8 KiB
C#

using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using ManagerService.EmailTemplates;
using ManagerService.Helpers;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ManagerService.Services
{
/// <summary>
/// Sends transactional emails via the Resend API (https://resend.com), using the shared
/// <see cref="EmailLayout"/> branded HTML shell for every template.
/// </summary>
public class ResendEmailService : IEmailService
{
private readonly ResendSettings _settings;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ResendEmailService> _logger;
public ResendEmailService(IOptions<ResendSettings> settings, IHttpClientFactory httpClientFactory, ILogger<ResendEmailService> logger)
{
_settings = settings.Value;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
private async Task SendAsync(string toEmail, string subject, string html)
{
try
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _settings.ApiKey);
var payload = JsonSerializer.Serialize(new
{
from = _settings.FromEmail,
to = new[] { toEmail },
subject,
html,
});
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await client.PostAsync("https://api.resend.com/emails", content);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Resend API error {StatusCode} sending to {ToEmail}: {Body}", response.StatusCode, toEmail, body);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {ToEmail} via Resend", toEmail);
}
}
public Task SendWelcomeEmailAsync(string toEmail, string firstName, string loginUrl) => SendAsync(
toEmail,
"Bienvenue sur MyInfoMate !",
EmailLayout.Render(
$"Bienvenue{(string.IsNullOrEmpty(firstName) ? "" : $", {firstName}")} !",
"Votre espace MyInfoMate est prêt. Vous pouvez dès maintenant vous connecter et configurer votre lieu culturel.",
"Accéder à mon espace", loginUrl));
public Task SendTrialStartedEmailAsync(string toEmail, string firstName, DateTime trialEndsAt) => SendAsync(
toEmail,
"Votre essai gratuit a démarré",
EmailLayout.Render(
"Votre essai gratuit de 14 jours a démarré",
$"Vous avez accès à toutes les fonctionnalités du plan Essentiel jusqu'au {trialEndsAt:dd/MM/yyyy}, sans carte bancaire. " +
"Vos pages visiteur afficheront un léger filigrane « Aperçu » tant que l'essai n'est pas converti en abonnement."));
public Task SendTrialCheckInEmailAsync(string toEmail, string firstName) => SendAsync(
toEmail,
"Comment se passe votre essai MyInfoMate ?",
EmailLayout.Render(
"Comment ça se passe ?",
"Cela fait une semaine que vous testez MyInfoMate. Une question, besoin d'aide pour configurer votre contenu ? Répondez simplement à cet e-mail."));
public Task SendTrialEndingReminderEmailAsync(string toEmail, string firstName, DateTime trialEndsAt, string checkoutUrl) => SendAsync(
toEmail,
"Votre essai se termine bientôt",
EmailLayout.Render(
"Votre essai se termine dans quelques jours",
$"Votre essai gratuit se termine le {trialEndsAt:dd/MM/yyyy}. Passez à l'abonnement Essentiel (39€/mois HTVA) pour garder votre espace actif et retirer le filigrane « Aperçu ».",
"Activer mon abonnement", checkoutUrl));
public Task SendTrialLastDayEmailAsync(string toEmail, string firstName, string checkoutUrl) => SendAsync(
toEmail,
"Dernier jour de votre essai gratuit",
EmailLayout.Render(
"C'est le dernier jour de votre essai",
"Sans abonnement actif, votre espace sera désactivé demain. Activez votre abonnement dès maintenant pour ne pas perdre votre configuration.",
"Activer mon abonnement", checkoutUrl));
public Task SendPaymentConfirmedEmailAsync(string toEmail, string firstName) => SendAsync(
toEmail,
"Paiement confirmé — abonnement actif",
EmailLayout.Render(
"Votre abonnement est actif !",
"Merci ! Votre paiement a été confirmé et votre abonnement Essentiel est maintenant actif. Le filigrane « Aperçu » a été retiré de vos pages visiteur. Votre facture vous sera envoyée sous peu."));
public Task SendPaymentFailedEmailAsync(string toEmail, string firstName, string customerPortalUrl) => SendAsync(
toEmail,
"Échec du paiement de votre abonnement",
EmailLayout.Render(
"Le paiement de votre abonnement a échoué",
"Nous n'avons pas pu traiter le paiement de votre abonnement MyInfoMate. Merci de mettre à jour votre moyen de paiement pour éviter une interruption de service.",
"Mettre à jour mon moyen de paiement", customerPortalUrl));
public Task SendUserInvitationEmailAsync(string toEmail, string setPasswordUrl) => SendAsync(
toEmail,
"Vous êtes invité(e) sur MyInfoMate",
EmailLayout.Render(
"Vous avez été invité(e) sur MyInfoMate",
"Un administrateur vous a donné accès à son espace MyInfoMate. Définissez votre mot de passe pour commencer.",
"Définir mon mot de passe", setPasswordUrl));
public Task SendPasswordResetEmailAsync(string toEmail, string firstName, string resetUrl) => SendAsync(
toEmail,
"Réinitialisation de votre mot de passe",
EmailLayout.Render(
"Réinitialisation de mot de passe",
"Vous avez demandé à réinitialiser votre mot de passe MyInfoMate. Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.",
"Définir un nouveau mot de passe", resetUrl));
public Task NotifyAdminAsync(string subject, string message) => SendAsync(
_settings.AdminEmail,
$"[MyInfoMate] {subject}",
EmailLayout.Render(subject, message.Replace("\n", "<br>")));
}
}