diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e1c1c64e..7851e29e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -88,7 +88,7 @@ jobs: TF_VAR_db_password="$DB_PASSWORD" \ TF_VAR_db_name="$DB_NAME" \ TF_VAR_security_key="$SECURITY_KEY" \ - TF_VAR_mailgun_apikey="$MAILGUN_APIKEY" \ + TF_VAR_resend_apikey="$RESEND_APIKEY" \ TF_VAR_r2_access_key="$AWS_ACCESS_KEY_ID" \ TF_VAR_r2_secret_key="$AWS_SECRET_ACCESS_KEY" \ TF_VAR_backend_image="$BACKEND_IMAGE" \ diff --git a/AGENTS.md b/AGENTS.md index 9776b954..3781382b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ - Backend follows controller -> service -> repository layering (`server/src/main/java/br/com/tasknoteapp/server/{controller,service,repository}`). - `HomeController` (`/rest/home`) exposes aggregated data such as `/rest/home/tasks/tags`; it is separate from `TaskController` and `NoteController`. - Global API error shape comes from `server/src/main/java/br/com/tasknoteapp/server/controller/RestExceptionController.java`; frontend expects `message` or `fields[].fieldMessage` (`client/src/api-service/api.ts`). -- Email and password-reset flows are Mailgun-backed (`server/src/main/java/br/com/tasknoteapp/server/service/MailgunEmailService.java`) and use templates under `server/src/main/java/br/com/tasknoteapp/server/templates/` + `mailgun-templates/`. +- Email and password-reset flows are Resend-backed (`server/src/main/java/br/com/tasknoteapp/server/service/ResendEmailService.java`) and render HTML bodies from templates under `server/src/main/resources/email-templates/` via `server/src/main/java/br/com/tasknoteapp/server/templates/EmailTemplate.java`. - Backend targets **Java 25** and **Spring Boot 4.x** (`server/pom.xml`). ## Developer workflows (use these first) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0eab01f..634a3889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Email sending migrated from Mailgun (named templates) to Resend (HTML bodies from local templates). The `MAILGUN_APIKEY` environment variable was replaced by `RESEND_APIKEY`. + ## 2026-09-28 ### Changed diff --git a/compose.dev.yaml b/compose.dev.yaml index 20000f94..b5712a02 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -36,7 +36,7 @@ services: POSTGRES_PASSWORD: default CORS_ALLOWED_ORIGINS: http://localhost:5000, http://tasknote-web:5000, https://flattop-depth-dropper.ngrok-free.dev SECURITY_KEY: this-is-a-very-long-security-key-for-dev - MAILGUN_APIKEY: invalid-api-key-only-placeholder + RESEND_APIKEY: invalid-api-key-only-placeholder ports: - "8585:8585" - "5005:5005" diff --git a/compose.prod-like.yaml b/compose.prod-like.yaml index 1b178f4f..1a713824 100644 --- a/compose.prod-like.yaml +++ b/compose.prod-like.yaml @@ -24,7 +24,7 @@ services: CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000, https://flattop-depth-dropper.ngrok-free.dev TARGET_ENV: production SECURITY_KEY: this-is-a-very-long-security-key-for-dev - MAILGUN_APIKEY: invalid-api-key-only-placeholder + RESEND_APIKEY: invalid-api-key-only-placeholder ports: ["8585:8585"] image: ghcr.io/thermcampos/tasknote/server:latest healthcheck: diff --git a/compose.schemaspy.yaml b/compose.schemaspy.yaml index 8d8dfd8d..cf09f22c 100644 --- a/compose.schemaspy.yaml +++ b/compose.schemaspy.yaml @@ -30,7 +30,7 @@ services: POSTGRES_PASSWORD: default CORS_ALLOWED_ORIGINS: http://localhost:5000, http://tasknote-web:5000, https://flattop-depth-dropper.ngrok-free.dev SECURITY_KEY: this-is-a-very-long-security-key-for-dev - MAILGUN_APIKEY: invalid-api-key-only-placeholder + RESEND_APIKEY: invalid-api-key-only-placeholder ports: - "8585:8585" - "5005:5005" diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java index 70ce23ed..642a03e6 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java @@ -67,7 +67,7 @@ public class AuthService { private final UserPwdLimitRepository userPwdLimitRepository; - private final MailgunEmailService mailgunEmailService; + private final ResendEmailService resendEmailService; private final Environment environment; @@ -80,7 +80,7 @@ public class AuthService { * @param authenticationManager AuthenticationManager instance. * @param authUtil AuthUtil instance. * @param userPwdLimitRepository UserPwdLimitRepository instance. - * @param mailgunEmailService MailgunEmailService instance. + * @param resendEmailService ResendEmailService instance. * @param environment Environment instance. */ public AuthService( @@ -90,7 +90,7 @@ public AuthService( AuthenticationManager authenticationManager, AuthUtil authUtil, UserPwdLimitRepository userPwdLimitRepository, - MailgunEmailService mailgunEmailService, + ResendEmailService resendEmailService, Environment environment) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; @@ -98,7 +98,7 @@ public AuthService( this.authenticationManager = authenticationManager; this.authUtil = authUtil; this.userPwdLimitRepository = userPwdLimitRepository; - this.mailgunEmailService = mailgunEmailService; + this.resendEmailService = resendEmailService; this.environment = environment; } @@ -149,8 +149,8 @@ public UserResponseWithToken signUpNewUser(LoginRequest newUser) { user.setLang(newUser.lang()); user = userRepository.save(user); - if (hasValidMailgunApiKey()) { - mailgunEmailService.sendNewUser(user); + if (hasValidResendApiKey()) { + resendEmailService.sendNewUser(user); } logger.info("User created! ID {}", user.getId()); @@ -389,10 +389,10 @@ public UserResponse patchUserInfo(UserPatchRequest patchRequest) { userRepository.save(currentUser); } - if (emailChanged && hasValidMailgunApiKey()) { + if (emailChanged && hasValidResendApiKey()) { logger.info( "Email changed from {} to {}", email, SecurityUtil.redactEmail(patchRequest.email())); - mailgunEmailService.sendEmailChangedNotification(currentUser, email); + resendEmailService.sendEmailChangedNotification(currentUser, email); } return UserResponse.fromEntity(currentUser, getGravatarImageUrl(email).orElse(null)); @@ -483,8 +483,8 @@ public void resendEmailConfirmation(String email) { User user = userOptional.get(); - if (hasValidMailgunApiKey()) { - mailgunEmailService.sendNewUser(user); + if (hasValidResendApiKey()) { + resendEmailService.sendNewUser(user); } logger.info("Confirmation email re-sent!"); @@ -520,8 +520,8 @@ public void resetPasswordForUser(String email) { LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS).plusHours(2L)); userRepository.save(user); - if (hasValidMailgunApiKey()) { - mailgunEmailService.sendResetPassword(user); + if (hasValidResendApiKey()) { + resendEmailService.sendResetPassword(user); } logger.info("Password reset request succeeded"); @@ -571,8 +571,8 @@ public void confirmResetPasswordForUser(PasswordResetRequest request) { user.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS)); userRepository.save(user); - if (hasValidMailgunApiKey()) { - mailgunEmailService.sendPasswordResetConfirmation(user); + if (hasValidResendApiKey()) { + resendEmailService.sendPasswordResetConfirmation(user); } logger.info("New password set for token {}", request.token()); @@ -622,8 +622,8 @@ private void checkLoginAttemptLimit(Long userId) { } } - private boolean hasValidMailgunApiKey() { - String apiKey = environment.getProperty("MAILGUN_APIKEY"); + private boolean hasValidResendApiKey() { + String apiKey = environment.getProperty("RESEND_APIKEY"); return Optional.ofNullable(apiKey).isPresent() && !"invalid-api-key-only-placeholder".equals(apiKey); } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/ResendEmailRequest.java b/server/src/main/java/br/com/tasknoteapp/server/service/ResendEmailRequest.java new file mode 100644 index 00000000..09b303cd --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/service/ResendEmailRequest.java @@ -0,0 +1,17 @@ +package br.com.tasknoteapp.server.service; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +/** + * This record represents the request body sent to the Resend API to send an email. + * + * @param from The sender email address. + * @param to The recipient email addresses. + * @param cc The carbon copy email addresses, if any. + * @param subject The email subject. + * @param html The email HTML body. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ResendEmailRequest( + String from, List to, List cc, String subject, String html) {} diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/MailgunEmailService.java b/server/src/main/java/br/com/tasknoteapp/server/service/ResendEmailService.java similarity index 61% rename from server/src/main/java/br/com/tasknoteapp/server/service/MailgunEmailService.java rename to server/src/main/java/br/com/tasknoteapp/server/service/ResendEmailService.java index 21f56480..66447178 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/MailgunEmailService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/ResendEmailService.java @@ -1,11 +1,10 @@ package br.com.tasknoteapp.server.service; import br.com.tasknoteapp.server.entity.User; -import br.com.tasknoteapp.server.templates.MailgunTemplate; -import br.com.tasknoteapp.server.templates.MailgunTemplateEmailChanged; -import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwd; -import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwdConfirm; -import br.com.tasknoteapp.server.templates.MailgunTemplateSignUp; +import br.com.tasknoteapp.server.templates.EmailTemplate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,16 +12,14 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; -/** This service handles email messages for Mailgun. */ +/** This service handles email messages for Resend. */ @Service -public class MailgunEmailService { +public class ResendEmailService { - private static final Logger logger = LoggerFactory.getLogger(MailgunEmailService.class.getName()); + private static final Logger logger = LoggerFactory.getLogger(ResendEmailService.class.getName()); private final RestClient restClient; private final String targetEnv; private final String domain; @@ -37,10 +34,10 @@ public class MailgunEmailService { * @param targetEnv The environment. * @param restClientBuilder The rest client builder. */ - public MailgunEmailService( - @Value("${mailgun.api-key}") String apiKey, - @Value("${mailgun.domain}") String domain, - @Value("${mailgun.sender-email}") String sender, + public ResendEmailService( + @Value("${resend.api-key}") String apiKey, + @Value("${resend.domain}") String domain, + @Value("${resend.sender-email}") String sender, @Value("${br.com.tasknote.server.target-env}") String targetEnv, RestClient.Builder restClientBuilder) { this.domain = domain; @@ -49,24 +46,24 @@ public MailgunEmailService( if (apiKey != null && apiKey.length() > 6) { logger.info( - "Mailgun API Key loaded: {}...{}", + "Resend API Key loaded: {}...{}", apiKey.substring(0, 3), apiKey.substring(apiKey.length() - 3)); } else { - logger.warn("Mailgun API Key is missing or too short!"); + logger.warn("Resend API Key is missing or too short!"); } this.restClient = restClientBuilder - .baseUrl("https://api.mailgun.net/v3/" + domain) + .baseUrl("https://api.resend.com") .defaultStatusHandler( HttpStatusCode::isError, (request, response) -> logger.error( - "Mailgun API Error: {} {}", + "Resend API Error: {} {}", response.getStatusCode(), response.getStatusText())) - .defaultHeaders(headers -> headers.setBasicAuth("api", apiKey)) + .defaultHeaders(headers -> headers.setBearerAuth(apiKey)) .build(); } @@ -84,10 +81,10 @@ public void sendNewUser(User user) { logger.info("New user link: {}", link); - MailgunTemplateSignUp signUpTemplate = new MailgunTemplateSignUp(); - signUpTemplate.setConfirmationLink(String.format(link, user.getEmailUuid().toString())); + Map variables = new HashMap<>(); + variables.put("CONFIRMATION_LINK", String.format(link, user.getEmailUuid().toString())); - sendEmail(to, subject, signUpTemplate); + sendEmail(to, subject, EmailTemplate.SIGN_UP, variables); } /** @@ -104,10 +101,10 @@ public void sendResetPassword(User user) { logger.info("Password reset link: {}", link); - MailgunTemplateResetPwd resetTemplate = new MailgunTemplateResetPwd(); - resetTemplate.setResetLink(String.format(link, user.getResetToken())); + Map variables = new HashMap<>(); + variables.put("RESET_LINK", String.format(link, user.getResetToken())); - sendEmail(to, subject, resetTemplate); + sendEmail(to, subject, EmailTemplate.PASSWORD_RESET, variables); } /** @@ -121,9 +118,7 @@ public void sendPasswordResetConfirmation(User user) { String to = user.getEmail(); String subject = "TaskNote App password confirmation"; - MailgunTemplateResetPwdConfirm resetTemplate = new MailgunTemplateResetPwdConfirm(); - - sendEmail(to, subject, resetTemplate); + sendEmail(to, subject, EmailTemplate.PASSWORD_RESET_CONFIRM, Map.of()); } /** @@ -135,14 +130,18 @@ public void sendPasswordResetConfirmation(User user) { public void sendEmailChangedNotification(User user, String oldEmail) { logger.info("Sending message with changed email notification"); - MailgunTemplateEmailChanged emailChanged = new MailgunTemplateEmailChanged(); - emailChanged.setEmailFrom(oldEmail); - emailChanged.setEmailTo(user.getEmail()); - emailChanged.setCarbonCopy(oldEmail); + Map variables = new HashMap<>(); + variables.put("EMAIL_FROM", oldEmail); + variables.put("EMAIL_TO", user.getEmail()); String subject = "TaskNote App email changed notification"; - sendEmail(user.getEmail(), subject, emailChanged); + sendEmail(user.getEmail(), subject, EmailTemplate.EMAIL_CHANGED, variables, oldEmail); + } + + private void sendEmail(String to, String subject, EmailTemplate template, + Map variables) { + sendEmail(to, subject, template, variables, null); } /** @@ -150,30 +149,29 @@ public void sendEmailChangedNotification(User user, String oldEmail) { * * @param to The target email address. * @param subject The message subject. - * @param template The Mailgun template. + * @param template The email template. + * @param variables The template variables. + * @param carbonCopy The carbon copy email address, if any. */ - private void sendEmail(String to, String subject, MailgunTemplate template) { + private void sendEmail(String to, String subject, EmailTemplate template, + Map variables, String carbonCopy) { String from = "TaskNote App <" + senderEmail + ">"; + String html = template.render(variables); - MultiValueMap mailData = new LinkedMultiValueMap<>(); - mailData.add("from", from); - mailData.add("to", to); - if (template.getCarbonCopy().isPresent()) { - mailData.add("cc", template.getCarbonCopy().get()); - } - mailData.add("subject", subject); - mailData.add("template", template.getName()); - if (!template.getVariables().isEmpty()) { - mailData.add("h:X-Mailgun-Variables", template.getVariableValuesJson()); - logger.info("JSON template variables: {}", template.getVariableValuesJson()); - } + ResendEmailRequest emailRequest = + new ResendEmailRequest( + from, + List.of(to), + carbonCopy != null ? List.of(carbonCopy) : null, + subject, + html); try { restClient .post() - .uri("/messages") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(mailData) + .uri("/emails") + .contentType(MediaType.APPLICATION_JSON) + .body(emailRequest) .retrieve() .toBodilessEntity(); diff --git a/server/src/main/java/br/com/tasknoteapp/server/templates/EmailTemplate.java b/server/src/main/java/br/com/tasknoteapp/server/templates/EmailTemplate.java new file mode 100644 index 00000000..c4b6831a --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/templates/EmailTemplate.java @@ -0,0 +1,50 @@ +package br.com.tasknoteapp.server.templates; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.core.io.ClassPathResource; + +/** This enum represents the available email templates backed by local HTML files. */ +public enum EmailTemplate { + SIGN_UP("sign_up_confirmation.html"), + PASSWORD_RESET("password_reset.html"), + PASSWORD_RESET_CONFIRM("password_change_confirmation.html"), + EMAIL_CHANGED("email_changed.html"); + + private static final String TEMPLATES_FOLDER = "email-templates/"; + private static final String PLACEHOLDER_REGEX = "\\{\\{\\s*%s\\s*\\}\\}"; + + private final String fileName; + + EmailTemplate(String fileName) { + this.fileName = fileName; + } + + /** + * Render the template HTML replacing the {@code {{ VARIABLE }}} placeholders. + * + * @param variables The values to replace the template placeholders with. + * @return The rendered HTML content. + */ + public String render(Map variables) { + String html = loadTemplate(); + for (Map.Entry entry : variables.entrySet()) { + String placeholder = String.format(PLACEHOLDER_REGEX, Pattern.quote(entry.getKey())); + html = html.replaceAll(placeholder, Matcher.quoteReplacement(entry.getValue())); + } + return html; + } + + private String loadTemplate() { + try { + return new ClassPathResource(TEMPLATES_FOLDER + fileName) + .getContentAsString(StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new UncheckedIOException("Unable to load email template " + fileName, ex); + } + } +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplate.java b/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplate.java deleted file mode 100644 index 0264a817..00000000 --- a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplate.java +++ /dev/null @@ -1,40 +0,0 @@ -package br.com.tasknoteapp.server.templates; - -import java.util.Map; -import java.util.Optional; - -/** This interface represents a Mailgun template structure. */ -public interface MailgunTemplate { - - String STRING_SCAPE = "\""; - String COLON = ":"; - String COMMA = ","; - - String getName(); - - Map getVariables(); - - default Optional getCarbonCopy() { - return Optional.empty(); - } - - /** - * Default method to get variables in JSON format. - * - * @return The JSON String representation. - */ - default String getVariableValuesJson() { - StringBuilder sb = new StringBuilder("{"); - for (Map.Entry entry : getVariables().entrySet()) { - if (sb.toString().length() > 1) { - sb.append(COMMA); - } - sb.append(STRING_SCAPE).append(entry.getKey()).append(STRING_SCAPE); - sb.append(COLON); - sb.append(STRING_SCAPE).append(entry.getValue().toString()).append(STRING_SCAPE); - } - sb.append("}"); - - return sb.toString(); - } -} diff --git a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateEmailChanged.java b/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateEmailChanged.java deleted file mode 100644 index b97c0537..00000000 --- a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateEmailChanged.java +++ /dev/null @@ -1,43 +0,0 @@ -package br.com.tasknoteapp.server.templates; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -/** This class represents a template for the email change workflow. */ -public class MailgunTemplateEmailChanged implements MailgunTemplate { - - private final Map props; - private String carbonCopy; - - public MailgunTemplateEmailChanged() { - this.props = new HashMap<>(); - } - - public void setEmailFrom(String emailFrom) { - props.put("EMAIL_FROM", emailFrom); - } - - public void setEmailTo(String emailTo) { - props.put("EMAIL_TO", emailTo); - } - - @Override - public String getName() { - return "email_changed"; - } - - @Override - public Map getVariables() { - return props; - } - - @Override - public Optional getCarbonCopy() { - return Optional.ofNullable(carbonCopy); - } - - public void setCarbonCopy(String carbonCopy) { - this.carbonCopy = carbonCopy; - } -} diff --git a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateResetPwd.java b/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateResetPwd.java deleted file mode 100644 index edad7a27..00000000 --- a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateResetPwd.java +++ /dev/null @@ -1,28 +0,0 @@ -package br.com.tasknoteapp.server.templates; - -import java.util.HashMap; -import java.util.Map; - -/** This class represents a template for the password reset workflow. */ -public class MailgunTemplateResetPwd implements MailgunTemplate { - - private final Map props; - - public MailgunTemplateResetPwd() { - this.props = new HashMap<>(); - } - - public void setResetLink(String resetLink) { - props.put("RESET_LINK", resetLink); - } - - @Override - public String getName() { - return "password_reset"; - } - - @Override - public Map getVariables() { - return props; - } -} diff --git a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateResetPwdConfirm.java b/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateResetPwdConfirm.java deleted file mode 100644 index dd518c8f..00000000 --- a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateResetPwdConfirm.java +++ /dev/null @@ -1,24 +0,0 @@ -package br.com.tasknoteapp.server.templates; - -import java.util.HashMap; -import java.util.Map; - -/** This class represents a template for the password reset confirmation workflow. */ -public class MailgunTemplateResetPwdConfirm implements MailgunTemplate { - - private final Map props; - - public MailgunTemplateResetPwdConfirm() { - this.props = new HashMap<>(); - } - - @Override - public String getName() { - return "password_change_confirmation"; - } - - @Override - public Map getVariables() { - return props; - } -} diff --git a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateSignUp.java b/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateSignUp.java deleted file mode 100644 index 10c47b89..00000000 --- a/server/src/main/java/br/com/tasknoteapp/server/templates/MailgunTemplateSignUp.java +++ /dev/null @@ -1,28 +0,0 @@ -package br.com.tasknoteapp.server.templates; - -import java.util.HashMap; -import java.util.Map; - -/** This class represents a template for the sign-up workflow. */ -public class MailgunTemplateSignUp implements MailgunTemplate { - - private final Map props; - - public MailgunTemplateSignUp() { - this.props = new HashMap<>(); - } - - public void setConfirmationLink(String confirmationLink) { - props.put("CONFIRMATION_LINK", confirmationLink); - } - - @Override - public String getName() { - return "sign_up_confirmation"; - } - - @Override - public Map getVariables() { - return props; - } -} diff --git a/server/src/main/resources/META-INF/native-image/reachability-metadata.json b/server/src/main/resources/META-INF/native-image/reachability-metadata.json index ac6c90e0..09802ea5 100644 --- a/server/src/main/resources/META-INF/native-image/reachability-metadata.json +++ b/server/src/main/resources/META-INF/native-image/reachability-metadata.json @@ -913,7 +913,7 @@ "org.springframework.security.authentication.AuthenticationManager", "br.com.tasknoteapp.server.util.AuthUtil", "br.com.tasknoteapp.server.repository.UserPwdLimitRepository", - "br.com.tasknoteapp.server.service.MailgunEmailService", + "br.com.tasknoteapp.server.service.ResendEmailService", "org.springframework.core.env.Environment" ] }, @@ -977,7 +977,7 @@ "type": "br.com.tasknoteapp.server.service.JwtService" }, { - "type": "br.com.tasknoteapp.server.service.MailgunEmailService", + "type": "br.com.tasknoteapp.server.service.ResendEmailService", "methods": [ { "name": "", @@ -7475,6 +7475,9 @@ { "glob": "META-INF/build-info.properties" }, + { + "glob": "email-templates/*.html" + }, { "glob": "META-INF/resources/index.html" }, @@ -7671,7 +7674,7 @@ "glob": "br/com/tasknoteapp/server/service/JwtService.class" }, { - "glob": "br/com/tasknoteapp/server/service/MailgunEmailService.class" + "glob": "br/com/tasknoteapp/server/service/ResendEmailService.class" }, { "glob": "br/com/tasknoteapp/server/service/NoteService.class" diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 18ef59f0..c0e9af6f 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -12,8 +12,8 @@ logging: root: ${ROOT_LOG_LEVEL:INFO} br.com.tasknoteapp: INFO -mailgun: - api-key: ${MAILGUN_APIKEY:abc123456} +resend: + api-key: ${RESEND_APIKEY:abc123456} domain: tasknote.cc sender-email: no-reply@tasknote.cc diff --git a/mailgun-templates/email_changed.html b/server/src/main/resources/email-templates/email_changed.html similarity index 100% rename from mailgun-templates/email_changed.html rename to server/src/main/resources/email-templates/email_changed.html diff --git a/mailgun-templates/password_change_confirmation.html b/server/src/main/resources/email-templates/password_change_confirmation.html similarity index 100% rename from mailgun-templates/password_change_confirmation.html rename to server/src/main/resources/email-templates/password_change_confirmation.html diff --git a/mailgun-templates/password_reset.html b/server/src/main/resources/email-templates/password_reset.html similarity index 100% rename from mailgun-templates/password_reset.html rename to server/src/main/resources/email-templates/password_reset.html diff --git a/mailgun-templates/sign_up_confirmation.html b/server/src/main/resources/email-templates/sign_up_confirmation.html similarity index 100% rename from mailgun-templates/sign_up_confirmation.html rename to server/src/main/resources/email-templates/sign_up_confirmation.html diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java index 36164905..5d0f887f 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java @@ -56,7 +56,7 @@ class AuthServiceTest { @Mock private UserPwdLimitRepository userPwdLimitRepository; - @Mock private MailgunEmailService mailgunEmailService; + @Mock private ResendEmailService resendEmailService; @Mock private Environment environment; @@ -72,7 +72,7 @@ void setup() { authenticationManager, authUtil, userPwdLimitRepository, - mailgunEmailService, + resendEmailService, environment); } @@ -549,17 +549,17 @@ void resendEmailConfirmation_happyPath_shouldSucceed() { existing.setId(919L); existing.setEmail(email); when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); - when(environment.getProperty("MAILGUN_APIKEY")).thenReturn("abc"); + when(environment.getProperty("RESEND_APIKEY")).thenReturn("abc"); - doNothing().when(mailgunEmailService).sendNewUser(existing); + doNothing().when(resendEmailService).sendNewUser(existing); Assertions.assertDoesNotThrow(() -> authService.resendEmailConfirmation(email)); - verify(mailgunEmailService, times(1)).sendNewUser(existing); + verify(resendEmailService, times(1)).sendNewUser(existing); } @Test - @DisplayName("Resend email confirmation no mailgun api key should succeed") - void resendEmailConfirmation_noMailgunApiKey_shouldSucceed() { + @DisplayName("Resend email confirmation no resend api key should succeed") + void resendEmailConfirmation_noResendApiKey_shouldSucceed() { String email = "user@domain.com"; User existing = new User(); @@ -568,7 +568,7 @@ void resendEmailConfirmation_noMailgunApiKey_shouldSucceed() { when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); Assertions.assertDoesNotThrow(() -> authService.resendEmailConfirmation(email)); - verify(mailgunEmailService, times(0)).sendNewUser(existing); + verify(resendEmailService, times(0)).sendNewUser(existing); } @Test @@ -580,7 +580,7 @@ void resendEmailConfirmation_nonExistentEmail_shouldFail() { Assertions.assertThrows( UserNotFoundException.class, () -> authService.resendEmailConfirmation(email)); - verify(mailgunEmailService, times(0)).sendNewUser(any()); + verify(resendEmailService, times(0)).sendNewUser(any()); } @Test @@ -593,13 +593,13 @@ void resetPasswordForUser_happyPath_shouldSucceed() { existing.setEmail(email); when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); - doNothing().when(mailgunEmailService).sendResetPassword(any()); + doNothing().when(resendEmailService).sendResetPassword(any()); when(userRepository.save(any())).thenReturn(existing); - when(environment.getProperty("MAILGUN_APIKEY")).thenReturn("abc"); + when(environment.getProperty("RESEND_APIKEY")).thenReturn("abc"); Assertions.assertDoesNotThrow(() -> authService.resetPasswordForUser(email)); verify(userRepository, times(1)).save(existing); - verify(mailgunEmailService, times(1)).sendResetPassword(existing); + verify(resendEmailService, times(1)).sendResetPassword(existing); } @Test @@ -611,7 +611,7 @@ void resetPasswordForUser_nonExistentEmail_shouldSucceed() { Assertions.assertDoesNotThrow(() -> authService.resetPasswordForUser(email)); verify(userRepository, times(0)).save(any()); - verify(mailgunEmailService, times(0)).sendResetPassword(any()); + verify(resendEmailService, times(0)).sendResetPassword(any()); } @Test @@ -630,22 +630,22 @@ void confirmResetPasswordForUser_happyPath_shouldSucceed() { when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user)); when(authUtil.validatePassword(newPassword)).thenReturn(Optional.empty()); - when(environment.getProperty("MAILGUN_APIKEY")).thenReturn("abc"); + when(environment.getProperty("RESEND_APIKEY")).thenReturn("abc"); PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword); Assertions.assertDoesNotThrow(() -> authService.confirmResetPasswordForUser(request)); verify(userRepository, times(1)).save(user); - verify(mailgunEmailService, times(1)).sendPasswordResetConfirmation(user); + verify(resendEmailService, times(1)).sendPasswordResetConfirmation(user); Assertions.assertNull(user.getResetToken()); Assertions.assertNull(user.getResetPasswordExpiration()); Assertions.assertNotNull(user.getPassword()); } @Test - @DisplayName("Confirm reset password no mailgun api token should succeed") - void confirmResetPasswordForUser_noMailgunApiToken_shouldSucceed() { + @DisplayName("Confirm reset password no resend api token should succeed") + void confirmResetPasswordForUser_noResendApiToken_shouldSucceed() { String token = "validToken"; User user = new User(); user.setResetToken(token); @@ -665,7 +665,7 @@ void confirmResetPasswordForUser_noMailgunApiToken_shouldSucceed() { Assertions.assertDoesNotThrow(() -> authService.confirmResetPasswordForUser(request)); verify(userRepository, times(1)).save(user); - verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(user); + verify(resendEmailService, times(0)).sendPasswordResetConfirmation(user); Assertions.assertNull(user.getResetToken()); Assertions.assertNull(user.getResetPasswordExpiration()); Assertions.assertNotNull(user.getPassword()); @@ -689,7 +689,7 @@ void confirmResetPasswordForUser_expiredToken_shouldFail() { ResetExpiredException.class, () -> authService.confirmResetPasswordForUser(request)); verify(userRepository, times(0)).save(any()); - verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any()); + verify(resendEmailService, times(0)).sendPasswordResetConfirmation(any()); } @Test @@ -705,7 +705,7 @@ void confirmResetPasswordForUser_invalidToken_shouldFail() { UserNotFoundException.class, () -> authService.confirmResetPasswordForUser(request)); verify(userRepository, times(0)).save(any()); - verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any()); + verify(resendEmailService, times(0)).sendPasswordResetConfirmation(any()); } @Test @@ -726,7 +726,7 @@ void confirmResetPasswordForUser_mismatchedPasswords_shouldFail() { BadPasswordException.class, () -> authService.confirmResetPasswordForUser(request)); verify(userRepository, times(0)).save(any()); - verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any()); + verify(resendEmailService, times(0)).sendPasswordResetConfirmation(any()); } @Test @@ -748,6 +748,6 @@ void confirmResetPasswordForUser_invalidPassword_shouldFail() { BadPasswordException.class, () -> authService.confirmResetPasswordForUser(request)); verify(userRepository, times(0)).save(any()); - verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any()); + verify(resendEmailService, times(0)).sendPasswordResetConfirmation(any()); } } diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/MailgunEmailServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/ResendEmailServiceTest.java similarity index 58% rename from server/src/test/java/br/com/tasknoteapp/server/service/MailgunEmailServiceTest.java rename to server/src/test/java/br/com/tasknoteapp/server/service/ResendEmailServiceTest.java index 0b841be7..e2af1f05 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/MailgunEmailServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/ResendEmailServiceTest.java @@ -1,5 +1,8 @@ package br.com.tasknoteapp.server.service; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.times; @@ -7,18 +10,20 @@ import static org.mockito.Mockito.when; import br.com.tasknoteapp.server.entity.User; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatusCode; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; -/** Test class for MailgunEmailService using RestClient. */ +/** Test class for ResendEmailService using RestClient. */ @ExtendWith(MockitoExtension.class) -class MailgunEmailServiceTest { +class ResendEmailServiceTest { @Mock private RestClient restClient; @Mock private RestClient.Builder restClientBuilder; @@ -26,7 +31,7 @@ class MailgunEmailServiceTest { @Mock private RestClient.RequestBodySpec requestBodySpec; @Mock private RestClient.ResponseSpec responseSpec; - private MailgunEmailService mailgunEmailService; + private ResendEmailService resendEmailService; @BeforeEach void setUp() { @@ -39,8 +44,7 @@ void setUp() { String domain = "domain.com"; String sender = "no-reply@domain.com"; String target = "development"; - mailgunEmailService = - new MailgunEmailService(apiKey, domain, sender, target, restClientBuilder); + resendEmailService = new ResendEmailService(apiKey, domain, sender, target, restClientBuilder); } private void setupMockChain() { @@ -51,6 +55,12 @@ private void setupMockChain() { when(requestBodySpec.retrieve()).thenReturn(responseSpec); } + private ResendEmailRequest captureEmailRequest() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ResendEmailRequest.class); + verify(requestBodySpec, times(1)).body(captor.capture()); + return captor.getValue(); + } + @Test void testSendResetPassword() { User user = new User(); @@ -59,10 +69,19 @@ void testSendResetPassword() { setupMockChain(); - mailgunEmailService.sendResetPassword(user); + resendEmailService.sendResetPassword(user); verify(restClient, times(1)).post(); + verify(requestBodyUriSpec, times(1)).uri("/emails"); verify(responseSpec, times(1)).toBodilessEntity(); + + ResendEmailRequest request = captureEmailRequest(); + assertEquals("TaskNote App ", request.from()); + assertEquals("test@example.com", request.to().getFirst()); + assertEquals("TaskNote App password reset", request.subject()); + assertNull(request.cc()); + assertTrue( + request.html().contains("http://localhost:5000/finish-reset-password?token=reset-token")); } @Test @@ -72,24 +91,39 @@ void testSendPasswordResetConfirmation() { setupMockChain(); - mailgunEmailService.sendPasswordResetConfirmation(user); + resendEmailService.sendPasswordResetConfirmation(user); verify(restClient, times(1)).post(); verify(responseSpec, times(1)).toBodilessEntity(); + + ResendEmailRequest request = captureEmailRequest(); + assertEquals("test@example.com", request.to().getFirst()); + assertEquals("TaskNote App password confirmation", request.subject()); + assertNull(request.cc()); + assertTrue(request.html().contains("> .env echo "CORS_ALLOWED_ORIGINS=http://localhost:5000" >> .env echo "SECURITY_KEY=this-is-a-very-long-security-key-for-dev" >> .env - echo "MAILGUN_APIKEY=invalid-api-key-only-placeholder" >> .env + echo "RESEND_APIKEY=invalid-api-key-only-placeholder" >> .env elif [ "$TARGET" == "front" ]; then if [ -f "client/.env" ]; then echo "Env file in place. Leaving..."