Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0cd6789
feat(iam): redesign transactional email templates with 2026 best prac…
jhosepmyr Jul 10, 2026
8ec84a1
Merge pull request #73 from Kntro-Soft/feature/improve-email-html-tem…
jhosepmyr Jul 10, 2026
bd2cddb
fix(iam): distinguish project-invitation email subject from plain org…
jhosepmyr Jul 10, 2026
e9f5ea6
Merge pull request #74 from Kntro-Soft/feature/improve-email-html-tem…
jhosepmyr Jul 10, 2026
0557ef7
feat(workspace): add member base permission floor to the organization
jhosepmyr Jul 10, 2026
a60e777
feat(workspace): apply the member base permission floor in authorization
jhosepmyr Jul 10, 2026
9530db6
feat(workspace): expose organization base-permission and authorizatio…
jhosepmyr Jul 10, 2026
6a4c5b0
feat(workspace): expose the caller's effective project permissions en…
jhosepmyr Jul 10, 2026
4662f42
test(workspace): cover the member base permission floor and effective…
jhosepmyr Jul 10, 2026
88171d3
refactor(workspace): gate me/permissions on tenant membership, not pr…
jhosepmyr Jul 10, 2026
ab3c582
test(workspace): integration-cover the base permission and effective …
jhosepmyr Jul 10, 2026
39c0f09
docs(changelog): note the member base permission floor and endpoints
jhosepmyr Jul 10, 2026
1ae3939
test(discovery): isolate project-role gating from the new base permis…
jhosepmyr Jul 10, 2026
381fd6a
fix(workspace): honor the member base permission floor in project access
jhosepmyr Jul 10, 2026
a3cf11c
feat(workspace): embed the role name in the project members list
jhosepmyr Jul 10, 2026
9778779
feat(workspace): let member-managers read the project roles list
jhosepmyr Jul 10, 2026
33a01d7
docs(changelog): note role-name embed, base-floor access and roles-li…
jhosepmyr Jul 10, 2026
d42be38
Merge pull request #75 from Kntro-Soft/feature/rbac-base-permission
jhosepmyr Jul 10, 2026
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
59 changes: 59 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 Expand Up @@ -45,6 +69,41 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in
`.env.example` documents every billing/Stripe variable (provider flag, API key, per-plan Price ids,
webhook secret, return-URL overrides).

### Added (Member base permission — `feature/rbac-base-permission`)

- **GitHub-style member base permission floor** — every organization now has a `memberBasePermission`
applied to **all** project members on top of their explicit project role (roles are additive on the
floor); owners/admins bypass it entirely. Values: `NONE` (members get only their project role) or
`READ` (a read-only baseline). Default `READ`. The `READ` floor grants exactly the workspace
`*_READ` permissions members need — `MEMBER_READ`, `ROLE_READ`, `DOCUMENT_READ`, `GLOSSARY_READ`,
`CONSTRAINT_READ`, `SESSION_READ`, `STORY_READ` (integration read is excluded; integrations are
org-admin configuration). Wired into `ProjectPermissionService.hasPermission`, so it flows through
`@authz.projectPermission` and `WorkspaceModuleApi.callerHasProjectPermission` — every gated
workspace/discovery endpoint honors the floor.
- **New endpoints** (header `Api-Version: 1`):
- `GET /organizations/{orgId}/base-permission` → `{ "basePermission": "NONE"|"READ" }` (org
owner/admin).
- `PUT /organizations/{orgId}/base-permission` `{ "basePermission": "NONE"|"READ" }` → `200`
`{ "basePermission": … }` (org owner/admin).
- `GET /organizations/{orgId}/me/authorization` →
`{ "orgRole": "OWNER"|"ADMIN"|"MEMBER", "memberBasePermission": "NONE"|"READ" }` (any org member).
- `GET /projects/{projectId}/me/permissions` → `{ "permissions": ["STORY_READ", …] }` — the caller's
effective project permissions (the full catalog for owners/admins, else the base floor unioned with
their project role). Gated on active tenant membership so any member reads their own set.
- Migration `V20260710090000__organization_member_base_permission.sql` (public schema): adds
`organizations.member_base_permission VARCHAR(16) NOT NULL DEFAULT 'READ'`.
- **Project access honors the base floor** — `canAccessProject`/`accessibleProjectIds` now grant every
active member access to all projects when the floor is non-`NONE`, falling back to explicit assignments
only under `NONE`. Previously they always required an explicit assignment, so a `READ`-floor member
could load a project's stories yet be 403'd on the project itself.
- **Members list embeds the role name** — `GET .../projects/{projectId}/members` now returns each
assignment's `roleName`, so a caller with only `MEMBER_READ` sees each member's role without also
needing `ROLE_READ` to resolve it.
- **Roles list readable by member-managers** — listing project roles now accepts `ROLE_READ` **or**
`MEMBER_UPDATE_ROLE`/`MEMBER_INVITE` (new `@authz.projectAnyPermission`, which resolves the org once
and holds when any listed permission is granted), so the member-role editor's options load without a
separate `ROLE_READ` grant.

### Added (Live session presence — `feature/discovery-presence`)

- **Real-time presence for live discovery sessions** — the users currently viewing a live session are
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,51 @@ 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 + " y al proyecto " + projectName + " en ReqsAI",
"Te invitaron a un proyecto",
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 + " y al proyecto " + projectName + " — 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