Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ follows [Semantic Versioning](https://semver.org/).

_Bounded-context implementation (iam, billing, workspace, discovery, gateway) in progress._

### Changed (Transactional email templates — `feature/improve-email-html-templates`)

- **Redesigned every transactional email** (verification, password reset, org invitation, project
invitation, project assignment) from bare unstyled `<p>`/`<a>` tags into a proper, brand-consistent
HTML layout: table-based structure (renders correctly in Outlook's Word engine, unlike
flexbox/grid), all styling inlined per-element (webmail clients like Gmail strip `<style>`
blocks/stylesheets), a fixed 600px card width, a hidden preheader driving the inbox preview
snippet, and an accessible CTA button (descriptive label, not "click here"; ~48px tall; a raw-link
fallback underneath for when the button doesn't render).
- **Plain-text fallback** — every email is now sent as a real `multipart/alternative` message (HTML +
plain text generated from the same structured content), so clients that can't or won't render HTML
still get a readable message, per standard transactional-email delivery practice.
- **HTML-escaping of user-controlled values** — display names, organization names, and role/project
names are now HTML-escaped before insertion (`EmailTemplateRenderer`/`HtmlUtils.htmlEscape`). The
previous string-concatenation approach interpolated these raw, which meant a display name like
`<script>...</script>` would have been injected verbatim into the rendered email.
- New `com.kntro.reqsai.iam.infrastructure.email.template` package: `EmailContent` (a single
structured source of truth — preheader, heading, paragraphs, optional CTA, optional footnote) and
`EmailTemplateRenderer`, which renders both the HTML and plain-text bodies from it so the two can
never drift out of sync.
- Verification and password-reset emails now state their token's actual expiry (24h / 1h,
matching `IAM_EMAIL_VERIFICATION_EXPIRATION` / `IAM_PASSWORD_RESET_EXPIRATION` defaults) and a
clear "if this wasn't you" security note.

### Added (Billing / Stripe subscriptions — `feature/billing-subscription-payments-quota`)

- **Paid subscription lifecycle** — the `Subscription` aggregate gains upgrade/cancel/reactivate/downgrade
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.kntro.reqsai.iam.infrastructure.email.strategy;

import com.kntro.reqsai.iam.application.port.EmailNotificationPort;
import com.kntro.reqsai.iam.infrastructure.email.template.EmailContent;
import com.kntro.reqsai.iam.infrastructure.email.template.EmailTemplateRenderer;
import com.kntro.reqsai.iam.infrastructure.exception.IamInfrastructureExceptions;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;

import java.util.List;

/**
* SMTP-based implementation of {@link EmailNotificationPort} using Spring's {@link JavaMailSender}.
* A single instance of this class is wired per active email provider (Mailpit, Mailtrap, Gmail)
Expand All @@ -16,6 +20,10 @@
* All three supported SMTP providers share the same JavaMail protocol. HTTP-based providers
* (e.g. Resend, SendGrid) require a separate adapter that uses {@code RestClient} with an API key
* instead of {@link JavaMailSender}.
* <p>
* Every message is sent as {@code multipart/alternative} — an HTML body rendered via
* {@link EmailTemplateRenderer} plus a plain-text fallback generated from the same
* {@link EmailContent}, so clients that can't (or won't) render HTML still get a readable message.
*/
@RequiredArgsConstructor
public class SmtpEmailAdapter implements EmailNotificationPort {
Expand All @@ -28,18 +36,34 @@ public class SmtpEmailAdapter implements EmailNotificationPort {
@Override
public void sendVerificationEmail(String toEmail, String firstName, String rawToken) {
String link = appUrl + "/auth/verify-email?token=" + rawToken;
send(toEmail, "Verifica tu correo — Reqs-AI",
"<p>Hola " + firstName + ",</p><p><a href=\"" + link + "\">Verificar correo</a></p>",
"verification email");
EmailContent content = new EmailContent(
"Verifica tu correo para activar tu cuenta en ReqsAI",
"Verifica tu correo",
List.of(
"Hola " + firstName + ",",
"Confirma tu dirección de correo para activar tu cuenta y empezar a usar ReqsAI."
),
"Verificar correo", link,
"Este enlace expira en 24 horas. Si no creaste una cuenta en ReqsAI, ignora este mensaje."
);
send(toEmail, "Verifica tu correo — ReqsAI", content, "verification email");
}

@Override
public void sendPasswordResetEmail(String toEmail, String firstName, String rawToken) {
String link = appUrl + "/auth/reset-password?token=" + rawToken;
send(toEmail, "Restablece tu contraseña — Reqs-AI",
"<p>Hola " + firstName + ",</p><p><a href=\"" + link + "\">Restablecer contraseña</a></p>" +
"<p>Este enlace expira en 1 hora. Si no solicitaste este cambio, ignora este mensaje.</p>",
"password reset email");
EmailContent content = new EmailContent(
"Restablece tu contraseña de ReqsAI",
"Restablece tu contraseña",
List.of(
"Hola " + firstName + ",",
"Recibimos una solicitud para restablecer tu contraseña de ReqsAI."
),
"Restablecer contraseña", link,
"Este enlace expira en 1 hora. Si no solicitaste este cambio, ignora este mensaje — tu " +
"contraseña actual sigue siendo válida."
);
send(toEmail, "Restablece tu contraseña — ReqsAI", content, "password reset email");
}

@Override
Expand All @@ -49,11 +73,17 @@ public void sendInvitationEmail(String toEmail, String displayName, String organ
String inviter = invitedByName != null && !invitedByName.isBlank()
? invitedByName + " te ha invitado"
: "Te han invitado";
send(toEmail, "Te invitaron a " + organizationName + " — Reqs-AI",
"<p>Hola " + displayName + ",</p>" +
"<p>" + inviter + " a unirte a <strong>" + organizationName + "</strong> como " + role + ".</p>" +
"<p><a href=\"" + link + "\">Aceptar invitación</a></p>",
"invitation email");
EmailContent content = new EmailContent(
inviter + " a unirte a " + organizationName + " en ReqsAI",
"Te invitaron a colaborar",
List.of(
"Hola " + displayName + ",",
inviter + " a unirte a **" + organizationName + "** como **" + role + "**."
),
"Aceptar invitación", link,
"Si no esperabas esta invitación, puedes ignorar este mensaje con seguridad."
);
send(toEmail, "Te invitaron a " + organizationName + " — ReqsAI", content, "invitation email");
}

@Override
Expand All @@ -64,35 +94,50 @@ public void sendProjectInvitationEmail(String toEmail, String displayName, Strin
String inviter = invitedByName != null && !invitedByName.isBlank()
? invitedByName + " te ha invitado"
: "Te han invitado";
send(toEmail, "Te invitaron a " + organizationName + " — Reqs-AI",
"<p>Hola " + displayName + ",</p>" +
"<p>" + inviter + " a unirte a <strong>" + organizationName + "</strong> como " + role + ".</p>" +
"<p>Al aceptar quedarás asignado al proyecto <strong>" + projectName + "</strong> con el rol "
+ projectRoleName + ".</p>" +
"<p><a href=\"" + link + "\">Aceptar invitación</a></p>",
"project invitation email");
EmailContent content = new EmailContent(
inviter + " a unirte a " + organizationName + " en ReqsAI",
"Te invitaron a colaborar",
List.of(
"Hola " + displayName + ",",
inviter + " a unirte a **" + organizationName + "** como **" + role + "**.",
"Al aceptar quedarás asignado al proyecto **" + projectName + "** con el rol **"
+ projectRoleName + "**."
),
"Aceptar invitación", link,
"Si no esperabas esta invitación, puedes ignorar este mensaje con seguridad."
);
send(toEmail, "Te invitaron a " + organizationName + " — ReqsAI", content, "project invitation email");
}

@Override
public void sendProjectAssignmentEmail(String toEmail, String displayName, String projectName,
String projectRoleName, String projectId) {
String link = appUrl + "/projects/" + projectId;
send(toEmail, "Te agregaron al proyecto " + projectName + " — Reqs-AI",
"<p>Hola " + displayName + ",</p>" +
"<p>Te agregaron al proyecto <strong>" + projectName + "</strong> con el rol "
+ projectRoleName + ".</p>" +
"<p><a href=\"" + link + "\">Ir al proyecto</a></p>",
"project assignment email");
EmailContent content = new EmailContent(
"Te agregaron al proyecto " + projectName + " en ReqsAI",
"Te agregaron a un proyecto",
List.of(
"Hola " + displayName + ",",
"Te agregaron al proyecto **" + projectName + "** con el rol **" + projectRoleName + "**."
),
"Ir al proyecto", link,
null
);
send(toEmail, "Te agregaron al proyecto " + projectName + " — ReqsAI", content, "project assignment email");
}

private void send(String toEmail, String subject, String htmlBody, String emailType) {
private void send(String toEmail, String subject, EmailContent content, String emailType) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
// multipart=true is required for setText(text, html) below to attach an alternative
// plain-text part; Spring nests it as mixed > alternative > {text/plain, text/html}
// since there are no attachments/inline resources, which every mail client unwraps
// transparently to render the HTML part.
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(fromEmail);
helper.setTo(toEmail);
helper.setSubject(subject);
helper.setText(htmlBody, true);
helper.setText(EmailTemplateRenderer.plainText(content), EmailTemplateRenderer.html(content));
mailSender.send(message);
} catch (Exception e) {
throw IamInfrastructureExceptions.emailDeliveryFailed(providerName, emailType, e);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.kntro.reqsai.iam.infrastructure.email.template;

import org.jspecify.annotations.Nullable;

import java.util.List;

/**
* Structured content for a transactional email, rendered into both an HTML and a plain-text body
* by {@link EmailTemplateRenderer} — a single source of truth so the two versions never drift out
* of sync (required for a proper {@code multipart/alternative} message).
*
* @param preheader short inbox-preview snippet (40-90 chars); hidden in the rendered body, shown
* by the mail client next to the subject line
* @param heading the card's visible title
* @param paragraphs body copy, one entry per paragraph, in reading order
* @param ctaText the button/link label, or {@code null} for a notice-only email with no action
* @param ctaUrl the button/link target, required when {@code ctaText} is set
* @param footnote small print under the button (e.g. an expiry or "if this wasn't you" notice),
* or {@code null}
*/
public record EmailContent(
String preheader,
String heading,
List<String> paragraphs,
@Nullable String ctaText,
@Nullable String ctaUrl,
@Nullable String footnote
) {}
Loading
Loading