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
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion compose.prod-like.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion compose.schemaspy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public class AuthService {

private final UserPwdLimitRepository userPwdLimitRepository;

private final MailgunEmailService mailgunEmailService;
private final ResendEmailService resendEmailService;

private final Environment environment;

Expand All @@ -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(
Expand All @@ -90,15 +90,15 @@ public AuthService(
AuthenticationManager authenticationManager,
AuthUtil authUtil,
UserPwdLimitRepository userPwdLimitRepository,
MailgunEmailService mailgunEmailService,
ResendEmailService resendEmailService,
Environment environment) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.authenticationManager = authenticationManager;
this.authUtil = authUtil;
this.userPwdLimitRepository = userPwdLimitRepository;
this.mailgunEmailService = mailgunEmailService;
this.resendEmailService = resendEmailService;
this.environment = environment;
}

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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!");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> to, List<String> cc, String subject, String html) {}
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
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;
import org.springframework.beans.factory.annotation.Value;
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;
Expand All @@ -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;
Expand All @@ -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();
}

Expand All @@ -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<String, String> variables = new HashMap<>();
variables.put("CONFIRMATION_LINK", String.format(link, user.getEmailUuid().toString()));

sendEmail(to, subject, signUpTemplate);
sendEmail(to, subject, EmailTemplate.SIGN_UP, variables);
}

/**
Expand All @@ -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<String, String> variables = new HashMap<>();
variables.put("RESET_LINK", String.format(link, user.getResetToken()));

sendEmail(to, subject, resetTemplate);
sendEmail(to, subject, EmailTemplate.PASSWORD_RESET, variables);
}

/**
Expand All @@ -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());
}

/**
Expand All @@ -135,45 +130,48 @@ 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<String, String> 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<String, String> variables) {
sendEmail(to, subject, template, variables, null);
}

/**
* Send an email message.
*
* @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<String, String> variables, String carbonCopy) {
String from = "TaskNote App <" + senderEmail + ">";
String html = template.render(variables);

MultiValueMap<String, String> 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();

Expand Down
Loading
Loading