Compare commits

...

19 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
fff9362ea8 feat(validation): add request and domain validation for email use case 2025-09-14 14:46:23 +02:00
421d160c12 refactor(domain → application): move MailService to application layer and rename to EmailPort 2025-09-13 13:02:08 +02:00
45a98daae0 refactor(core): reorganize code and implement logging 2025-09-13 12:31:35 +02:00
6445278e9f refactor(core): add custom exceptions 2025-09-13 12:31:21 +02:00
8c39c1955e chore: add tokens to .gitignore 2025-09-13 12:30:03 +02:00
a846547d38 chore(config): configure Google OAuth credentials via environment variables 2025-09-13 12:29:20 +02:00
2d0e676a77 feat(logging): add Log4j2 implementation for application logging 2025-09-13 12:28:41 +02:00
53 changed files with 1527 additions and 112 deletions

View File

@@ -1,6 +1,13 @@
SPRING_PROFILES_ACTIVE=dev
APP_ENCRYPTION_SECRET=123456789
APP_ALLOWED_ORIGINS=http://localhost:8080
DB_NAME=EXAMPLE_DB
DB_USER=EXAMPLE
DB_PASSWORD=SECRET
DB_HOST=127.0.0.1
DB_PORT=5432
SPRING_PROFILES_ACTIVE=dev
GMAIL_OAUTH_CLIENT_ID=11111111111-1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a.apps.googleusercontent.com
GMAIL_OAUTH_CLIENT_SECRET=AAAAAA-1a1a1a1a1a1a1_A1a1_A1A1A1A1A

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
*.db
target
logs
tokens
.env
Icon?

View File

@@ -6,5 +6,8 @@ RUN mvn clean package -DskipTests
FROM openjdk:21-jdk
WORKDIR /app
COPY --from=build /app/bootstrap/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","app.jar"]
EXPOSE 5005
ENTRYPOINT ["java", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-Xmx512m", "-Xms256m", "-jar", "app.jar"]

View File

@@ -16,5 +16,32 @@
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<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>
</project>

View File

@@ -1,5 +0,0 @@
package com.pablotj.restemailbridge.application.port;
public interface EmailConfigurationPort {
String getDefaultRecipient();
}

View File

@@ -0,0 +1,5 @@
package com.pablotj.restemailbridge.application.port.in;
public interface EmailDefaultConfigPort {
String getDefaultRecipient();
}

View File

@@ -1,8 +1,8 @@
package com.pablotj.restemailbridge.domain.service;
package com.pablotj.restemailbridge.application.port.out;
import com.pablotj.restemailbridge.domain.model.Email;
public interface EmailService {
public interface EmailPort {
Email sendEmail(Email email);
}

View File

@@ -1,32 +1,58 @@
package com.pablotj.restemailbridge.application.usecase;
import com.pablotj.restemailbridge.application.dto.EmailDTO;
import com.pablotj.restemailbridge.application.port.EmailConfigurationPort;
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.repository.EmailRepository;
import com.pablotj.restemailbridge.domain.service.EmailService;
import com.pablotj.restemailbridge.domain.service.EmailValidatorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Use case for sending emails.
* <p>
* Retrieves the default recipient from EmailDefaultConfigPort, sends the email using EmailPort,
* and persists it via EmailRepository.
*/
public class SendEmailUseCase {
private final EmailConfigurationPort emailConfigurationPort;
private final EmailService emailService;
private static final Logger log = LoggerFactory.getLogger(SendEmailUseCase.class);
private final EmailValidatorService emailValidatorService;
private final EmailDefaultConfigPort emailConfigurationPort;
private final EmailPort emailService;
private final EmailRepository emailRepository;
public SendEmailUseCase(EmailConfigurationPort emailConfigurationPort, EmailService emailService, EmailRepository emailRepository) {
public SendEmailUseCase(EmailValidatorService emailValidatorService, EmailDefaultConfigPort emailConfigurationPort, EmailPort emailService, EmailRepository emailRepository) {
this.emailValidatorService = emailValidatorService;
this.emailConfigurationPort = emailConfigurationPort;
this.emailService = emailService;
this.emailRepository = emailRepository;
}
/**
* Handles sending an email based on the provided DTO.
*
* @param emailDTO DTO containing from, subject, and body
*/
public void handle(EmailDTO emailDTO) {
String to = emailConfigurationPort.getDefaultRecipient();
Email email = emailService.sendEmail(
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);
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,17 +26,22 @@
<scope>runtime</scope>
</dependency>
<!-- Swagger/OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<!-- Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Test -->

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,26 +52,10 @@ springdoc:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui
show-actuator: true
server:
port: 8080
servlet:
context-path: /api
forward-headers-strategy: framework
---
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
gmail:
oauth2:
clientId: ${GMAIL_OAUTH_CLIENT_ID}
clientSecret: ${GMAIL_OAUTH_CLIENT_SECRET}
redirectUri: http://localhost:8888/Callback

View File

@@ -3,6 +3,8 @@ services:
build: .
ports:
- "8080:8080"
- "5005:5005"
- "8888:8888"
depends_on:
- db
environment:
@@ -11,8 +13,15 @@ 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:
- network
volumes:
- ./tokens:/app/tokens
- ./logs:/app/logs
db:
image: postgres:15
ports:

View File

@@ -16,5 +16,38 @@
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<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,22 @@
package com.pablotj.restemailbridge.domain.service;
import com.pablotj.restemailbridge.domain.model.Email;
public class EmailValidatorService {
/**
* Validates business rules for Email.
*/
public void validate(Email email) {
if (email == null) throw new IllegalArgumentException("Email cannot be null");
if (email.getTo() == null || !email.getTo().matches(".+@.+\\..+"))
throw new IllegalArgumentException("Recipient email is invalid");
if (email.getFrom() == null || email.getFrom().isBlank())
throw new IllegalArgumentException("Sender email is required");
if (email.getSubject() == null || email.getSubject().isBlank())
throw new IllegalArgumentException("Subject is required");
if (email.getBody() == null || email.getBody().isBlank())
throw new IllegalArgumentException("Body is required");
}
}

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

@@ -23,6 +23,22 @@
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -33,12 +49,99 @@
<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>
<version>0.10.2</version>
</dependency>
<dependency>
<groupId>com.google.http-client</groupId>
<artifactId>google-http-client-jackson2</artifactId>
<version>1.43.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.api-client/google-api-client -->
<dependency>
<groupId>com.google.api-client</groupId>
<artifactId>google-api-client</artifactId>
<version>1.35.2</version>
</dependency>
<dependency>
<groupId>com.google.oauth-client</groupId>
<artifactId>google-oauth-client-jetty</artifactId>
<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>
<version>v1-rev110-1.25.0</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>javax.mail</groupId>
<artifactId>javax.mail-api</artifactId>
<version>1.6.2</version>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.2</version>
</dependency>
<dependency>
<groupId>com.sun.activation</groupId>
<artifactId>jakarta.activation</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.24.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<version>2.24.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.24.1</version>
</dependency>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
@@ -51,5 +154,30 @@
<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>
<id>maven_central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2/</url>
</repository>
</repositories>
</project>

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

@@ -1,12 +0,0 @@
package com.pablotj.restemailbridge.infrastructure.config;
import com.pablotj.restemailbridge.application.port.EmailConfigurationPort;
import org.springframework.stereotype.Component;
@Component
public class EmailConfigurationAdapter implements EmailConfigurationPort {
@Override
public String getDefaultRecipient() {
return "1234@1234.com";
}
}

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,18 @@
package com.pablotj.restemailbridge.infrastructure.config;
import com.pablotj.restemailbridge.application.port.in.EmailDefaultConfigPort;
import com.pablotj.restemailbridge.application.port.out.EmailPort;
import com.pablotj.restemailbridge.application.usecase.SendEmailUseCase;
import com.pablotj.restemailbridge.domain.repository.EmailRepository;
import com.pablotj.restemailbridge.domain.service.EmailValidatorService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class UseCaseConfig {
@Bean
public SendEmailUseCase sendEmailUseCase(EmailDefaultConfigPort emailConfigurationPort, EmailPort emailService, EmailRepository emailRepository) {
return new SendEmailUseCase(new EmailValidatorService(), emailConfigurationPort, emailService, emailRepository);
}
}

View File

@@ -1,17 +0,0 @@
package com.pablotj.restemailbridge.infrastructure.config.spring;
import com.pablotj.restemailbridge.application.port.EmailConfigurationPort;
import com.pablotj.restemailbridge.application.usecase.SendEmailUseCase;
import com.pablotj.restemailbridge.domain.repository.EmailRepository;
import com.pablotj.restemailbridge.domain.service.EmailService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class UseCaseConfig {
@Bean
public SendEmailUseCase sendEmailUseCase(EmailConfigurationPort emailConfigurationPort, EmailService emailService, EmailRepository emailRepository) {
return new SendEmailUseCase(emailConfigurationPort, emailService, emailRepository);
}
}

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

@@ -0,0 +1,13 @@
package com.pablotj.restemailbridge.infrastructure.exception;
import java.io.Serial;
public class GmailConfigurationException extends RuntimeException {
@Serial
private static final long serialVersionUID = 1L;
public GmailConfigurationException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,13 @@
package com.pablotj.restemailbridge.infrastructure.exception;
import java.io.Serial;
public class GmailInitializationException extends RuntimeException {
@Serial
private static final long serialVersionUID = 1L;
public GmailInitializationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,14 @@
package com.pablotj.restemailbridge.infrastructure.exception;
import java.io.Serial;
public class GmailSendErrorException extends RuntimeException {
@Serial
private static final long serialVersionUID = 1L;
public GmailSendErrorException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,12 @@
package com.pablotj.restemailbridge.infrastructure.mail;
import com.pablotj.restemailbridge.application.port.in.EmailDefaultConfigPort;
import org.springframework.stereotype.Component;
@Component
public class GmailDefaultConfigAdapter implements EmailDefaultConfigPort {
@Override
public String getDefaultRecipient() {
return "pablodelatorree@gmail.com";
}
}

View File

@@ -0,0 +1,182 @@
package com.pablotj.restemailbridge.infrastructure.mail;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp;
import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver;
import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.store.FileDataStoreFactory;
import com.google.api.services.gmail.Gmail;
import com.google.api.services.gmail.GmailScopes;
import com.google.api.services.gmail.model.Message;
import com.pablotj.restemailbridge.domain.model.Email;
import com.pablotj.restemailbridge.application.port.out.EmailPort;
import com.pablotj.restemailbridge.infrastructure.exception.GmailConfigurationException;
import com.pablotj.restemailbridge.infrastructure.exception.GmailInitializationException;
import com.pablotj.restemailbridge.infrastructure.exception.GmailSendErrorException;
import com.pablotj.restemailbridge.infrastructure.mail.config.GmailOAuth2Properties;
import jakarta.annotation.PostConstruct;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
/**
* Gmail implementation of the EmailPort using OAuth2 authentication.
* <p>
* Handles sending emails via Gmail API and manages credentials stored in a local token folder.
*/
@Component
public class GmailOAuth2MailAdapter implements EmailPort {
private static final Logger log = LoggerFactory.getLogger(GmailOAuth2MailAdapter.class);
private static final String APPLICATION_NAME = "MailServiceApi";
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
private static final List<String> SCOPES = Collections.singletonList(GmailScopes.GMAIL_SEND);
private static final String TOKENS_DIRECTORY_PATH = "/app/tokens";
private final GmailOAuth2Properties properties;
private Gmail service;
/**
* Constructor injecting Gmail OAuth2 properties.
*
* @param properties OAuth2 configuration for Gmail
*/
public GmailOAuth2MailAdapter(GmailOAuth2Properties properties) {
this.properties = properties;
}
/**
* Initializes the Gmail client with OAuth2 credentials.
* <p>
* Loads the token from the token folder, sets up the OAuth2 flow,
* and initializes the Gmail API client.
*
* @throws GmailConfigurationException if token folder or credentials are missing
* @throws GmailInitializationException if any other initialization error occurs
*/
@PostConstruct
public void init() {
try {
log.info("Initializing Gmail OAuth2 client...");
GoogleClientSecrets clientSecrets = new GoogleClientSecrets()
.setInstalled(
new GoogleClientSecrets.Details()
.setClientId(properties.clientId())
.setClientSecret(properties.clientSecret())
.setRedirectUris(Collections.singletonList(properties.redirectUri()))
);
File tokenFolder = new File(TOKENS_DIRECTORY_PATH);
if (!tokenFolder.exists() || !tokenFolder.isDirectory()) {
log.error("Token folder not found: {}", TOKENS_DIRECTORY_PATH);
throw new GmailConfigurationException("Token folder missing: " + TOKENS_DIRECTORY_PATH);
}
GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
JSON_FACTORY,
clientSecrets,
SCOPES)
.setDataStoreFactory(new FileDataStoreFactory(tokenFolder))
.setAccessType("offline")
.build();
LocalServerReceiver receiver = new LocalServerReceiver.Builder().setPort(8888).build();
Credential credential = new AuthorizationCodeInstalledApp(flow, receiver).authorize("user");
if (credential == null) {
log.error("No stored credentials found. Generate tokens first.");
throw new GmailConfigurationException("No stored credentials found. Generate tokens first.");
}
service = new Gmail.Builder(GoogleNetHttpTransport.newTrustedTransport(), JSON_FACTORY, credential)
.setApplicationName(APPLICATION_NAME)
.build();
log.info("Gmail OAuth2 client initialized successfully.");
} catch (Exception e) {
if (e instanceof GmailConfigurationException configEx) {
throw configEx;
}
log.error("Failed to initialize Gmail client");
throw new GmailInitializationException("Failed to initialize Gmail client", e);
}
}
/**
* Sends an email using the Gmail API.
*
* @param email Email object containing recipient, sender, subject, and body
* @return The same Email object after sending
* @throws GmailSendErrorException if sending fails
*/
@Override
public Email sendEmail(Email email) {
try {
log.info("Sending email to {}", email.getTo());
MimeMessage message = createEmail(email.getTo(), email.getFrom(), email.getSubject(), email.getBody());
sendMessage(service, message);
log.info("Email sent successfully to {}", email.getTo());
} catch (Exception e) {
log.error("Failed to send email to {}", email.getTo());
throw new GmailSendErrorException("Failed to send email", e);
}
return email;
}
/**
* Creates a MimeMessage from the given parameters.
*
* @param to Recipient email
* @param from Sender email
* @param subject Email subject
* @param bodyText Email body
* @return MimeMessage ready to be sent
* @throws MessagingException if creation fails
*/
private static MimeMessage createEmail(String to, String from, String subject, String bodyText) throws MessagingException {
Properties props = new Properties();
Session session = Session.getDefaultInstance(props, null);
MimeMessage email = new MimeMessage(session);
email.setReplyTo(new jakarta.mail.Address[]{new InternetAddress(from)});
email.setFrom(new InternetAddress(from));
email.addRecipient(jakarta.mail.Message.RecipientType.TO, new InternetAddress(to));
email.setSubject(subject);
email.setText(bodyText);
return email;
}
/**
* Sends the MimeMessage using the Gmail API.
*
* @param service Gmail client
* @param email MimeMessage to send
* @throws MessagingException if message creation fails
* @throws IOException if API call fails
*/
private static void sendMessage(Gmail service, MimeMessage email) throws MessagingException, IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
email.writeTo(buffer);
String encodedEmail = Base64.getUrlEncoder().encodeToString(buffer.toByteArray());
Message message = new Message();
message.setRaw(encodedEmail);
service.users().messages().send("me", message).execute();
}
}

View File

@@ -1,15 +0,0 @@
package com.pablotj.restemailbridge.infrastructure.mail;
import com.pablotj.restemailbridge.domain.model.Email;
import com.pablotj.restemailbridge.domain.service.EmailService;
import org.springframework.stereotype.Component;
@Component
public class GmailOAuth2MailService implements EmailService {
@Override
public Email sendEmail(Email email) {
System.out.println("Sending email " + email.getSubject() + " to " + email.getTo());
return email;
}
}

View File

@@ -0,0 +1,9 @@
package com.pablotj.restemailbridge.infrastructure.mail.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(GmailOAuth2Properties.class)
public class GmailConfig {
}

View File

@@ -0,0 +1,10 @@
package com.pablotj.restemailbridge.infrastructure.mail.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "gmail.oauth2")
public record GmailOAuth2Properties(
String clientId,
String clientSecret,
String redirectUri
) {}

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(nullable = false)
@Column(length = 200, nullable = false)
@Convert(converter = EncryptionConverter.class)
private String sender;
@Column(nullable = false)
@Column(length = 200, nullable = false)
private String recipient;
@Column(nullable = false)
@Column(length = 150, nullable = false)
@Convert(converter = EncryptionConverter.class)
private String subjet;
@Column(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

@@ -0,0 +1,26 @@
package com.pablotj.restemailbridge.infrastructure.rest;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
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,8 +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(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,99 @@
# ========== Log4j2 Properties Configuration ==========
status = error
name = PropertiesConfig
property.basePath = logs
# ========== Appenders ==========
# Console
appender.console.type = Console
appender.console.name = ConsoleAppender
appender.console.layout.type = PatternLayout
appender.console.layout.pattern = [%d{yyyy-MM-dd HH:mm:ss}] [%t] %-5level %logger{36} - %msg%n
# Rolling File INFO+ (DEBUG e INFO)
appender.info.type = RollingFile
appender.info.name = InfoFileAppender
appender.info.fileName = ${basePath}/info.log
appender.info.filePattern = ${basePath}/info-%d{yyyy-MM-dd}-%i.log.gz
appender.info.layout.type = PatternLayout
appender.info.layout.pattern = [%d{yyyy-MM-dd HH:mm:ss}] [%t] %-5level %logger{36} - %msg%n
appender.info.policies.type = Policies
appender.info.policies.time.type = TimeBasedTriggeringPolicy
appender.info.policies.time.interval = 1
appender.info.policies.size.type = SizeBasedTriggeringPolicy
appender.info.policies.size.size = 10MB
appender.info.filter.threshold.type = ThresholdFilter
appender.info.filter.threshold.level = debug
appender.info.filter.threshold.onMatch = ACCEPT
appender.info.filter.threshold.onMismatch = DENY
# Rolling File ERROR+ (ERROR y FATAL)
appender.error.type = RollingFile
appender.error.name = ErrorFileAppender
appender.error.fileName = ${basePath}/error.log
appender.error.filePattern = ${basePath}/error-%d{yyyy-MM-dd}-%i.log.gz
appender.error.layout.type = PatternLayout
appender.error.layout.pattern = [%d{yyyy-MM-dd HH:mm:ss}] [%t] %-5level %logger{36} - %msg%n
appender.error.policies.type = Policies
appender.error.policies.time.type = TimeBasedTriggeringPolicy
appender.error.policies.time.interval = 1
appender.error.policies.size.type = SizeBasedTriggeringPolicy
appender.error.policies.size.size = 10MB
appender.error.filter.threshold.type = ThresholdFilter
appender.error.filter.threshold.level = error
appender.error.filter.threshold.onMatch = ACCEPT
appender.error.filter.threshold.onMismatch = DENY
# Rolling File for Database logs (Hibernate / JDBC)
appender.db.type = RollingFile
appender.db.name = DBFileAppender
appender.db.fileName = ${basePath}/db.log
appender.db.filePattern = ${basePath}/db-%d{yyyy-MM-dd}-%i.log.gz
appender.db.layout.type = PatternLayout
appender.db.layout.pattern = [%d{yyyy-MM-dd HH:mm:ss}] [%t] %-5level %logger{36} - %msg%n
appender.db.policies.type = Policies
appender.db.policies.time.type = TimeBasedTriggeringPolicy
appender.db.policies.time.interval = 1
appender.db.policies.size.type = SizeBasedTriggeringPolicy
appender.db.policies.size.size = 10MB
# ========== Loggers ==========
# App logs
logger.app.name = com.pablotj
logger.app.level = debug
logger.app.additivity = false
logger.app.appenderRefs = console, info, error
logger.app.appenderRef.console.ref = ConsoleAppender
logger.app.appenderRef.info.ref = InfoFileAppender
logger.app.appenderRef.error.ref = ErrorFileAppender
# Hibernate SQL
logger.hibernate.name = org.hibernate.SQL
logger.hibernate.level = debug
logger.hibernate.additivity = false
logger.hibernate.appenderRefs = db
logger.hibernate.appenderRef.db.ref = DBFileAppender
# Hibernate parameter binding
#logger.hibernate.type.name = org.hibernate.type.descriptor.sql.BasicBinder
#logger.hibernate.type.level = trace
#logger.hibernate.type.additivity = false
#logger.hibernate.type.appenderRefs = db
#logger.hibernate.type.appenderRef.db.ref = DBFileAppender
# Spring JDBC Template logs
#logger.spring.sql.name = org.springframework.jdbc.core.JdbcTemplate
#logger.spring.sql.level = debug
#logger.spring.sql.additivity = false
#logger.spring.sql.appenderRefs = db
#logger.spring.sql.appenderRef.db.ref = DBFileAppender
# ========== Root Logger ==========
rootLogger.level = info
rootLogger.appenderRefs = console, info, error
rootLogger.appenderRef.console.ref = ConsoleAppender
rootLogger.appenderRef.info.ref = InfoFileAppender
rootLogger.appenderRef.error.ref = ErrorFileAppender

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