Compare commits

..

12 Commits

Author SHA1 Message Date
a42a8dd3a5 chore: optimize HikariCP pool and JVM memory for Raspberry Pi 2025-09-20 10:07:11 +02:00
ab0e2ffa68 refactor(bootstrap): replace PostConstruct validator with ApplicationContextInitializer 2025-09-19 17:20:02 +02:00
e0692f7913 feat(config): add AppPropertiesValidator to validate required environment variables at startup 2025-09-19 17:09:28 +02:00
bcb97312cd fix(cors): allow origins from environment variable 2025-09-19 17:09:09 +02:00
8fa2845220 fix(db): limit Hikari connection pool to prevent "too many clients already" 2025-09-19 16:16:54 +02:00
fea733f990 test: add unit and integration tests 2025-09-15 21:06:20 +02:00
ee88068d99 refactor(config): clean YAML configuration 2025-09-15 08:28:36 +02:00
f04fb14192 refactor(use-case): persist status and error if present 2025-09-15 08:26:02 +02:00
fb0ddf391f docs(api): add API documentation 2025-09-15 08:25:26 +02:00
d417a46a06 feat(security): add support for message encryption with a key 2025-09-15 08:25:06 +02:00
c541119cf0 refactor(model): persist date, status, and error description 2025-09-15 08:24:53 +02:00
54798b7554 feat(i18n): add English, Spanish and Galician translations for error messages 2025-09-15 08:24:28 +02:00
36 changed files with 936 additions and 61 deletions

View File

@@ -1,5 +1,8 @@
SPRING_PROFILES_ACTIVE=dev
APP_ENCRYPTION_SECRET=123456789
APP_ALLOWED_ORIGINS=http://localhost:8080
DB_NAME=EXAMPLE_DB
DB_USER=EXAMPLE
DB_PASSWORD=SECRET

View File

@@ -10,4 +10,4 @@ COPY --from=build /app/bootstrap/target/*.jar app.jar
EXPOSE 8080
EXPOSE 5005
ENTRYPOINT ["java", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "app.jar"]
ENTRYPOINT ["java", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-Xmx512m", "-Xms256m", "-jar", "app.jar"]

View File

@@ -23,5 +23,25 @@
<version>2.0.13</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -39,19 +39,19 @@ public class SendEmailUseCase {
public void handle(EmailDTO emailDTO) {
String to = emailConfigurationPort.getDefaultRecipient();
Email email = Email.builder()
.from(emailDTO.from())
.to(to)
.subject(emailDTO.subject())
.body(emailDTO.body())
.build();
Email email = Email.create(emailDTO.from(), to, emailDTO.subject(), emailDTO.body());
emailValidatorService.validate(email);
log.info("Sending email from {} to {}", emailDTO.from(), to);
email = emailService.sendEmail(email);
try {
email = emailService.sendEmail(email);
email.markAsSent();
} catch (Exception e) {
log.error("Error sending email", e);
email.markAsFailed(e.getMessage());
}
emailRepository.save(email);
log.info("Email successfully sent and persisted to repository for recipient {}", to);
}

View File

@@ -0,0 +1,129 @@
package com.pablotj.restemailbridge.application.usecase;
import com.pablotj.restemailbridge.application.dto.EmailDTO;
import com.pablotj.restemailbridge.application.port.in.EmailDefaultConfigPort;
import com.pablotj.restemailbridge.application.port.out.EmailPort;
import com.pablotj.restemailbridge.domain.model.Email;
import com.pablotj.restemailbridge.domain.model.EmailStatus;
import com.pablotj.restemailbridge.domain.repository.EmailRepository;
import com.pablotj.restemailbridge.domain.service.EmailValidatorService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SendEmailUseCaseTest {
private EmailValidatorService emailValidatorService;
private EmailPort emailPort;
private EmailRepository emailRepository;
private SendEmailUseCase useCase;
@BeforeEach
void setUp() {
EmailDefaultConfigPort emailDefaultConfigPort = mock(EmailDefaultConfigPort.class);
emailValidatorService = mock(EmailValidatorService.class);
emailPort = mock(EmailPort.class);
emailRepository = mock(EmailRepository.class);
useCase = new SendEmailUseCase(
emailValidatorService,
emailDefaultConfigPort,
emailPort,
emailRepository
);
when(emailDefaultConfigPort.getDefaultRecipient()).thenReturn("default@example.com");
}
@Test
void shouldSendEmailSuccessfully() {
// given
EmailDTO dto = new EmailDTO("sender@example.com", "Subject", "Body");
when(emailPort.sendEmail(any(Email.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// when
useCase.handle(dto);
// then
verify(emailValidatorService).validate(any(Email.class));
verify(emailPort).sendEmail(any(Email.class));
verify(emailRepository).save(argThat(email ->
email.getStatus() == EmailStatus.SENT
));
}
@Test
void shouldMarkEmailAsFailedWhenSendThrowsException() {
// given
EmailDTO dto = new EmailDTO("sender@example.com", "Subject", "Body");
when(emailPort.sendEmail(any(Email.class))).thenThrow(new RuntimeException("SMTP error"));
// when
useCase.handle(dto);
// then
verify(emailValidatorService).validate(any(Email.class));
verify(emailRepository).save(argThat(email ->
email.getStatus() == EmailStatus.FAILED &&
email.getErrorDescription().contains("SMTP error")
));
}
@Test
void shouldUseDefaultRecipientFromConfigPort() {
// given
EmailDTO dto = new EmailDTO("sender@example.com", "Subject", "Body");
when(emailPort.sendEmail(any(Email.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// when
useCase.handle(dto);
// then
verify(emailRepository).save(argThat(email ->
email.getTo().equals("default@example.com")
));
}
@Test
void shouldPropagateExceptionWhenValidatorFails() {
// given
EmailDTO dto = new EmailDTO("sender@example.com", "Subject", "Body");
doThrow(new RuntimeException("Invalid email")).when(emailValidatorService).validate(any());
// when & then
RuntimeException ex = assertThrows(RuntimeException.class, () -> useCase.handle(dto));
assertThat(ex.getMessage()).isEqualTo("Invalid email");
// El repositorio no debería guardar el email, porque la validación falló antes
verify(emailRepository, never()).save(any());
}
@Test
void shouldFailWhenEmailDTOHasNullFields() {
// given
EmailDTO dto = new EmailDTO(null, null, null);
doThrow(new IllegalArgumentException("Invalid fields")).when(emailValidatorService).validate(any());
// when & then
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> useCase.handle(dto));
assertThat(ex.getMessage()).isEqualTo("Invalid fields");
verify(emailRepository, never()).save(any());
}
}

View File

@@ -26,13 +26,6 @@
<scope>runtime</scope>
</dependency>
<!-- Swagger/OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>

View File

@@ -0,0 +1,29 @@
package com.pablotj.restemailbridge.bootstrap;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
public class EnvironmentValidatorInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext ctx) {
String[] requiredProperties = {
"APP_ENCRYPTION_SECRET",
"APP_ALLOWED_ORIGINS",
"DB_NAME",
"DB_USER",
"DB_PASSWORD",
"DB_HOST",
"DB_PORT",
"GMAIL_OAUTH_CLIENT_ID",
"GMAIL_OAUTH_CLIENT_SECRET"
};
for (String prop : requiredProperties) {
String value = ctx.getEnvironment().getProperty(prop);
if (value == null || value.isEmpty()) {
throw new IllegalStateException("ERROR: Property '" + prop + "' is not defined or empty");
}
}
}
}

View File

@@ -11,6 +11,8 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
public class RestEmailBridgeApplication {
public static void main(String[] args) {
SpringApplication.run(RestEmailBridgeApplication.class, args);
SpringApplication app = new SpringApplication(RestEmailBridgeApplication.class);
app.addInitializers(new EnvironmentValidatorInitializer());
app.run(args);
}
}

View File

@@ -1,6 +1,19 @@
info:
app:
version: @project.version@
app:
encryption:
secret: ${APP_ENCRYPTION_SECRET}
cors:
allowed-origins: ${APP_ALLOWED_ORIGINS:http://localhost:8080}
server:
port: 8080
servlet:
context-path: /api
forward-headers-strategy: framework
spring:
application:
name: restemailbridge
@@ -8,6 +21,18 @@ spring:
resources:
add-mappings: false
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:default_db}
username: ${DB_USER:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 3
minimum-idle: 1
idle-timeout: 30000
connection-timeout: 10000
leak-detection-threshold: 10000
jpa:
hibernate:
ddl-auto: validate
@@ -15,6 +40,7 @@ spring:
hibernate.transaction.jta.platform: org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
show-sql: true
jackson:
@@ -26,32 +52,10 @@ springdoc:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui
server:
port: 8080
servlet:
context-path: /api
forward-headers-strategy: framework
show-actuator: true
gmail:
oauth2:
clientId: ${GMAIL_OAUTH_CLIENT_ID}
clientSecret: ${GMAIL_OAUTH_CLIENT_SECRET}
redirectUri: http://localhost:8888/Callback
---
spring:
config:
activate:
on-profile: default
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:default_db}
username: ${DB_USER:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
jpa:
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
redirectUri: http://localhost:8888/Callback

View File

@@ -13,6 +13,8 @@ services:
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
APP_ENCRYPTION_SECRET: ${APP_ENCRYPTION_SECRET}
APP_ALLOWED_ORIGINS: ${APP_ALLOWED_ORIGINS}
GMAIL_OAUTH_CLIENT_ID: ${GMAIL_OAUTH_CLIENT_ID}
GMAIL_OAUTH_CLIENT_SECRET: ${GMAIL_OAUTH_CLIENT_SECRET}
networks:

View File

@@ -21,5 +21,33 @@
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>maven_central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2/</url>
</repository>
</repositories>
</project>

View File

@@ -1,5 +1,6 @@
package com.pablotj.restemailbridge.domain.model;
import java.time.Instant;
import lombok.Builder;
import lombok.Getter;
@@ -7,9 +8,30 @@ import lombok.Getter;
@Builder
public class Email {
private String from;
private String to;
private String subject;
private String body;
private final String from;
private final String to;
private final String subject;
private final String body;
private EmailStatus status;
private final Instant createdAt;
private String errorDescription;
}
public void markAsSent() {
this.status = EmailStatus.SENT;
}
public void markAsFailed(String errorDescription) {
this.status = EmailStatus.FAILED;
this.errorDescription = errorDescription;
}
public static Email create(String from, String to, String subject, String body) {
return Email.builder()
.from(from)
.to(to)
.subject(subject)
.body(body)
.status(EmailStatus.PENDING)
.build();
}
}

View File

@@ -0,0 +1,5 @@
package com.pablotj.restemailbridge.domain.model;
public enum EmailStatus {
SENT, FAILED, PENDING
}

View File

@@ -0,0 +1,62 @@
package com.pablotj.restemailbridge.domain.service;
import com.pablotj.restemailbridge.domain.model.Email;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class EmailValidatorServiceTest {
private final EmailValidatorService validator = new EmailValidatorService();
@Test
void shouldThrowIfEmailIsNull() {
assertThatThrownBy(() -> validator.validate(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Email cannot be null");
}
@Test
void shouldThrowIfRecipientInvalid() {
Email email = Email.create("sender@example.com", "not-an-email", "Subject", "Body");
assertThatThrownBy(() -> validator.validate(email))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Recipient email is invalid");
}
@Test
void shouldThrowIfSenderIsBlank() {
Email email = Email.create("", "recipient@example.com", "Subject", "Body");
assertThatThrownBy(() -> validator.validate(email))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Sender email is required");
}
@Test
void shouldThrowIfSubjectIsBlank() {
Email email = Email.create("sender@example.com", "recipient@example.com", "", "Body");
assertThatThrownBy(() -> validator.validate(email))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Subject is required");
}
@Test
void shouldThrowIfBodyIsBlank() {
Email email = Email.create("sender@example.com", "recipient@example.com", "Subject", "");
assertThatThrownBy(() -> validator.validate(email))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Body is required");
}
@Test
void shouldPassForValidEmail() {
Email email = Email.create("sender@example.com", "recipient@example.com", "Subject", "Body");
// no debe lanzar excepción
validator.validate(email);
}
}

View File

@@ -49,6 +49,11 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.reflections</groupId>
<artifactId>reflections</artifactId>
@@ -74,6 +79,14 @@
<version>1.39.0</version>
</dependency>
<!-- Swagger/OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-gmail</artifactId>
@@ -141,6 +154,24 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>

View File

@@ -0,0 +1,35 @@
package com.pablotj.restemailbridge.infrastructure.config;
import java.util.Arrays;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Value("${app.cors.allowed-origins}")
private String allowedOriginsString;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
List<String> allowedOrigins = Arrays.asList(allowedOriginsString.split(","));
config.setAllowedOriginPatterns(allowedOrigins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@@ -0,0 +1,8 @@
package com.pablotj.restemailbridge.infrastructure.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaConfig { }

View File

@@ -0,0 +1,25 @@
package com.pablotj.restemailbridge.infrastructure.config;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import java.util.Locale;
@Configuration
public class LocaleConfig {
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.of("en"));
resolver.setSupportedLocales(
List.of(Locale.of("es"),
Locale.of("en"),
Locale.of("gl"))
);
return resolver;
}
}

View File

@@ -0,0 +1,27 @@
package com.pablotj.restemailbridge.infrastructure.config;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
@Configuration
public class MessageConfig {
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("i18n/messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setUseCodeAsDefaultMessage(false);
return messageSource;
}
@Bean
public LocalValidatorFactoryBean validator(MessageSource messageSource) {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
bean.setValidationMessageSource(messageSource);
return bean;
}
}

View File

@@ -0,0 +1,46 @@
package com.pablotj.restemailbridge.infrastructure.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.parameters.Parameter;
import io.swagger.v3.oas.models.media.StringSchema;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springdoc.core.customizers.OpenApiCustomizer;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Rest Email Bridge API")
.version("v1")
.description("API for sending and managing emails")
.license(new License().name("Apache 2.0").url("https://www.apache.org/licenses/LICENSE-2.0"))
);
}
@Bean
public OpenApiCustomizer globalHeaderCustomizer() {
return openApi -> openApi.getPaths().values().forEach(pathItem ->
pathItem.readOperations().forEach(operation ->
operation.addParametersItem(
new Parameter()
.in("header")
.name("Accept-Language")
.description("Language for messages (en, es, gl)")
.required(false)
.schema(new StringSchema()
._default("en")
.addEnumItem("en")
.addEnumItem("es")
.addEnumItem("gl"))
)
)
);
}
}

View File

@@ -0,0 +1,31 @@
package com.pablotj.restemailbridge.infrastructure.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CorsConfigurationSource corsConfigurationSource;
public SecurityConfig(CorsConfigurationSource corsConfigurationSource) {
this.corsConfigurationSource = corsConfigurationSource;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build();
}
}

View File

@@ -0,0 +1,23 @@
package com.pablotj.restemailbridge.infrastructure.encryption;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import org.springframework.beans.factory.annotation.Value;
@Converter
public class EncryptionConverter implements AttributeConverter<String, String> {
@Value("${app.encryption.secret}")
private String secret;
@Override
public String convertToDatabaseColumn(String attribute) {
return attribute == null ? null : new EncryptionUtils(secret).encrypt(attribute);
}
@Override
public String convertToEntityAttribute(String dbData) {
return dbData == null ? null : new EncryptionUtils(secret).decrypt(dbData);
}
}

View File

@@ -0,0 +1,72 @@
package com.pablotj.restemailbridge.infrastructure.encryption;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class EncryptionUtils {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int TAG_LENGTH_BIT = 128;
private static final int IV_LENGTH_BYTE = 12;
private static final SecureRandom secureRandom = new SecureRandom();
private final SecretKey secretKey;
public EncryptionUtils(String secret) {
if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length != 32) {
throw new IllegalArgumentException("Secret key must be 32 bytes for AES-256");
}
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), ALGORITHM);
}
public String encrypt(String plainText) {
try {
byte[] iv = new byte[IV_LENGTH_BYTE];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
// Guardamos IV + ciphertext juntos
ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length);
byteBuffer.put(iv);
byteBuffer.put(cipherText);
return Base64.getEncoder().encodeToString(byteBuffer.array());
} catch (Exception e) {
throw new RuntimeException("Failed to encrypt text", e);
}
}
public String decrypt(String base64CipherText) {
try {
byte[] cipherMessage = Base64.getDecoder().decode(base64CipherText);
ByteBuffer byteBuffer = ByteBuffer.wrap(cipherMessage);
byte[] iv = new byte[IV_LENGTH_BYTE];
byteBuffer.get(iv);
byte[] cipherText = new byte[byteBuffer.remaining()];
byteBuffer.get(cipherText);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
byte[] plainText = cipher.doFinal(cipherText);
return new String(plainText, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("Failed to decrypt text", e);
}
}
}

View File

@@ -1,15 +1,25 @@
package com.pablotj.restemailbridge.infrastructure.persistence;
import com.pablotj.restemailbridge.domain.model.EmailStatus;
import com.pablotj.restemailbridge.infrastructure.encryption.EncryptionConverter;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "MAIL")
@Getter
@Setter
@@ -19,15 +29,29 @@ public class MailJpa {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 100, nullable = false)
@Column(length = 200, nullable = false)
@Convert(converter = EncryptionConverter.class)
private String sender;
@Column(length = 100, nullable = false)
@Column(length = 200, nullable = false)
private String recipient;
@Column(length = 50, nullable = false)
@Column(length = 150, nullable = false)
@Convert(converter = EncryptionConverter.class)
private String subjet;
@Column(length = 40000, nullable = false)
@Column(length = 7000, nullable = false)
@Convert(converter = EncryptionConverter.class)
private String body;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private EmailStatus status;
@CreatedDate
@Column(nullable = false)
private Instant createdAt;
@Column
private String errorDescription;
}

View File

@@ -20,7 +20,8 @@ public class MailRepositoryAdapter implements EmailRepository {
mailJpa.setRecipient(email.getTo());
mailJpa.setSubjet(email.getSubject());
mailJpa.setBody(email.getBody());
mailJpa.setStatus(email.getStatus());
mailJpa.setErrorDescription(email.getErrorDescription());
springDataMailRepository.save(mailJpa);
return email;

View File

@@ -17,4 +17,10 @@ public class GlobalExceptionHandler {
errors.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, String>> handleRuntimeException(RuntimeException ex) {
Map<String, String> error = Map.of("error", ex.getMessage());
return ResponseEntity.unprocessableEntity().body(error); // 422
}
}

View File

@@ -3,6 +3,13 @@ package com.pablotj.restemailbridge.infrastructure.rest;
import com.pablotj.restemailbridge.application.dto.EmailDTO;
import com.pablotj.restemailbridge.application.usecase.SendEmailUseCase;
import com.pablotj.restemailbridge.infrastructure.rest.dto.SendMailRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
@@ -10,18 +17,76 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller responsible for handling email-related requests.
* <p>
* Exposes endpoints under {@code /v1/mail} to send emails through the system.
* Delegates business logic to the {@link SendEmailUseCase}.
*/
@RestController
@RequestMapping("/v1/mail")
@Tag(name = "Mail API", description = "Endpoints for sending emails")
public class MailController {
private final SendEmailUseCase sendEmailUseCase;
/**
* Creates a new {@link MailController} instance.
*
* @param sendEmailUseCase the use case responsible for sending emails
*/
public MailController(SendEmailUseCase sendEmailUseCase) {
this.sendEmailUseCase = sendEmailUseCase;
}
/**
* Sends a new email using the provided request data.
* <p>
* The request payload is validated using {@link jakarta.validation.Valid}.
*
* @param request the email request containing sender, subject and body
* @return {@link ResponseEntity} with HTTP 200 (OK) if the email is sent successfully
*/
@PostMapping
@Operation(
summary = "Send an email",
description = "Sends an email using the provided sender, subject, and body.",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
required = true,
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(
name = "Basic email",
value = "{ \"from\": \"user@example.com\", \"subject\": \"Hello\", \"body\": \"Hi there!\" }"
)
)
)
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Email sent successfully"),
@ApiResponse(responseCode = "400", description = "Invalid request payload"),
@ApiResponse(
responseCode = "401",
description = "Unauthorized missing or invalid authentication token",
content = @Content(schema = @Schema(hidden = true))
),
@ApiResponse(
responseCode = "403",
description = "Forbidden the authenticated user cannot send to the specified recipient",
content = @Content(schema = @Schema(hidden = true))
),
@ApiResponse(
responseCode = "422",
description = "Unprocessable Entity domain validation failed (e.g. invalid email address, business rule violation)",
content = @Content(
mediaType = "application/json",
schema = @Schema(
example = "{ \"error\": \"Invalid recipient domain\" }"
)
)
),
@ApiResponse(responseCode = "500", description = "Unexpected server error")
})
public ResponseEntity<Void> send(@Valid @RequestBody SendMailRequest request) {
sendEmailUseCase.handle(new EmailDTO(request.from(), request.subject(), request.body()));
return ResponseEntity.ok().build();

View File

@@ -1,12 +1,26 @@
package com.pablotj.restemailbridge.infrastructure.rest.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.Length;
@Schema(description = "Request payload to send an email")
public record SendMailRequest(
@NotBlank @Email @Length(min = 4, max = 100) String from,
@NotBlank @Length(min=1, max = 30) String subject,
@NotBlank @Length(min=1, max = 4000) String body
@NotBlank(message = "{email.from.blank}")
@Email(message = "{email.from.invalid}")
@Length(min = 4, max = 100, message = "email.from.length}")
@Schema(description = "Sender email address", example = "user@example.com")
String from,
@NotBlank(message = "{email.subject.blank}")
@Length(min=1, max = 30, message = "{email.subject.length}")
@Schema(description = "Email subject", example = "Welcome to RestEmailBridge")
String subject,
@NotBlank(message = "{email.body.blank}")
@Length(min=1, max = 4000, message = "{email.body.length}")
@Schema(description = "Email body content", example = "Hello, thanks for signing up!")
String body
) {
}

View File

@@ -1,8 +1,18 @@
CREATE TABLE restemailbridge.mail
create table mail
(
id BIGSERIAL PRIMARY KEY,
body VARCHAR(255) NOT NULL,
recipient VARCHAR(255) NOT NULL,
sender VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL
id bigint generated by default as identity primary key,
body varchar(7000) not null,
recipient varchar(200) not null,
sender varchar(200) not null,
subjet varchar(150) not null,
created_at timestamp,
status varchar
constraint check_status
check (((status)::text = 'PENDING'::text) OR ((status)::text = 'SENT'::text) OR
((status)::text = 'FAILED'::text)),
error_description varchar(10000),
constraint check_error_description
check ((((status)::text = 'FAILED'::text) AND
((error_description IS NOT NULL) OR ((error_description)::text <> ''::text))) OR
(((status)::text <> 'FAILED'::text) AND (error_description IS NULL)))
);

View File

@@ -0,0 +1,9 @@
email.from.invalid=Sender email must be valid
email.from.blank=Sender email cannot be blank
email.from.length=Sender email must be between 4 and 100 characters
email.subject.blank=Subject cannot be blank
email.subject.length=Subject must be between 1 and 30 characters
email.body.blank=Body cannot be blank
email.body.length=Body must be between 1 and 4000 characters

View File

@@ -0,0 +1,9 @@
email.from.invalid=El remitente debe ser un correo válido
email.from.blank=El remitente no puede estar vacío
email.from.length=El remitente debe tener entre 4 y 100 caracteres
email.subject.blank=El asunto no puede estar vacío
email.subject.length=El asunto debe tener entre 1 y 30 caracteres
email.body.blank=El cuerpo no puede estar vacío
email.body.length=El cuerpo debe tener entre 1 y 4000 caracteres

View File

@@ -0,0 +1,9 @@
email.from.invalid=O remitente debe ser un correo válido
email.from.blank=O remitente non pode estar baleiro
email.from.length=O remitente debe ter entre 4 e 100 caracteres
email.subject.blank=O asunto non pode estar baleiro
email.subject.length=O asunto debe ter entre 1 e 30 caracteres
email.body.blank=O corpo non pode estar baleiro
email.body.length=O corpo debe ter entre 1 e 4000 caracteres

View File

@@ -0,0 +1,11 @@
package com.pablotj.restemailbridge.infrastructure;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = "com.pablotj.restemailbridge")
@EnableJpaRepositories(basePackages = "com.pablotj.restemailbridge")
@EntityScan(basePackages = "com.pablotj.restemailbridge")
public class RestEmailBridgeTestApplication {
}

View File

@@ -0,0 +1,40 @@
package com.pablotj.restemailbridge.infrastructure.persistence;
import com.pablotj.restemailbridge.domain.model.Email;
import com.pablotj.restemailbridge.domain.model.EmailStatus;
import com.pablotj.restemailbridge.infrastructure.config.JpaConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
@Import(JpaConfig.class)
@DataJpaTest
class MailRepositoryAdapterIT {
@Autowired
private SpringDataMailRepository springDataMailRepository;
@Test
void shouldSaveEmailSuccessfully() {
MailRepositoryAdapter adapter = new MailRepositoryAdapter(springDataMailRepository);
Email email = Email.create("sender@example.com", "recipient@example.com", "Subject", "Body");
Email saved = adapter.save(email);
assertThat(saved.getFrom()).isEqualTo("sender@example.com");
assertThat(saved.getTo()).isEqualTo("recipient@example.com");
assertThat(saved.getSubject()).isEqualTo("Subject");
assertThat(saved.getBody()).isEqualTo("Body");
assertThat(saved.getStatus()).isEqualTo(EmailStatus.PENDING);
assertThat(springDataMailRepository.findAll())
.hasSize(1)
.first()
.extracting("sender", "recipient")
.containsExactly("sender@example.com", "recipient@example.com");
}
}

View File

@@ -0,0 +1,53 @@
package com.pablotj.restemailbridge.infrastructure.rest;
import com.pablotj.restemailbridge.application.port.out.EmailPort;
import com.pablotj.restemailbridge.infrastructure.RestEmailBridgeTestApplication;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(
classes = RestEmailBridgeTestApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@AutoConfigureMockMvc
@ActiveProfiles("test")
class MailControllerIT {
@Autowired
private MockMvc mockMvc;
@MockBean
private EmailPort emailPort;
@Test
void shouldReturn200WhenEmailIsSent() throws Exception {
when(emailPort.sendEmail(any())).thenAnswer(invocation -> invocation.getArgument(0));
mockMvc.perform(post("/v1/mail")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"from\":\"sender@example.com\",\"subject\":\"Subject\",\"body\":\"Body\"}"))
.andExpect(status().isOk());
verify(emailPort).sendEmail(any());
}
@Test
void shouldReturn400WhenRequestIsInvalid() throws Exception {
mockMvc.perform(post("/v1/mail")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"subject\":\"Subject\",\"body\":\"Body\"}"))
.andExpect(status().isBadRequest());
}
}

View File

@@ -0,0 +1,27 @@
spring:
application:
name: restemailbridge
datasource:
url: jdbc:h2:mem:restemailbridge;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
server:
port: 0
servlet:
context-path: /api
app:
encryption:
secret: 0123456789ABCDEF0123456789ABCDEF
gmail:
oauth2:
clientId: dummy
clientSecret: dummy
redirectUri: http://localhost:8888/Callback