Compare commits
19 Commits
682d801836
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a42a8dd3a5 | |||
| ab0e2ffa68 | |||
| e0692f7913 | |||
| bcb97312cd | |||
| 8fa2845220 | |||
| fea733f990 | |||
| ee88068d99 | |||
| f04fb14192 | |||
| fb0ddf391f | |||
| d417a46a06 | |||
| c541119cf0 | |||
| 54798b7554 | |||
| fff9362ea8 | |||
| 421d160c12 | |||
| 45a98daae0 | |||
| 6445278e9f | |||
| 8c39c1955e | |||
| a846547d38 | |||
| 2d0e676a77 |
@@ -1,6 +1,13 @@
|
|||||||
|
SPRING_PROFILES_ACTIVE=dev
|
||||||
|
|
||||||
|
APP_ENCRYPTION_SECRET=123456789
|
||||||
|
APP_ALLOWED_ORIGINS=http://localhost:8080
|
||||||
|
|
||||||
DB_NAME=EXAMPLE_DB
|
DB_NAME=EXAMPLE_DB
|
||||||
DB_USER=EXAMPLE
|
DB_USER=EXAMPLE
|
||||||
DB_PASSWORD=SECRET
|
DB_PASSWORD=SECRET
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=5432
|
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
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
*.db
|
*.db
|
||||||
target
|
target
|
||||||
logs
|
logs
|
||||||
|
tokens
|
||||||
.env
|
.env
|
||||||
|
|
||||||
Icon?
|
Icon?
|
||||||
@@ -6,5 +6,8 @@ RUN mvn clean package -DskipTests
|
|||||||
FROM openjdk:21-jdk
|
FROM openjdk:21-jdk
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/bootstrap/target/*.jar app.jar
|
COPY --from=build /app/bootstrap/target/*.jar app.jar
|
||||||
|
|
||||||
EXPOSE 8080
|
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"]
|
||||||
@@ -16,5 +16,32 @@
|
|||||||
<groupId>jakarta.validation</groupId>
|
<groupId>jakarta.validation</groupId>
|
||||||
<artifactId>jakarta.validation-api</artifactId>
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package com.pablotj.restemailbridge.application.port;
|
|
||||||
|
|
||||||
public interface EmailConfigurationPort {
|
|
||||||
String getDefaultRecipient();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.pablotj.restemailbridge.application.port.in;
|
||||||
|
|
||||||
|
public interface EmailDefaultConfigPort {
|
||||||
|
String getDefaultRecipient();
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.pablotj.restemailbridge.domain.service;
|
package com.pablotj.restemailbridge.application.port.out;
|
||||||
|
|
||||||
import com.pablotj.restemailbridge.domain.model.Email;
|
import com.pablotj.restemailbridge.domain.model.Email;
|
||||||
|
|
||||||
public interface EmailService {
|
public interface EmailPort {
|
||||||
|
|
||||||
Email sendEmail(Email email);
|
Email sendEmail(Email email);
|
||||||
}
|
}
|
||||||
@@ -1,32 +1,58 @@
|
|||||||
package com.pablotj.restemailbridge.application.usecase;
|
package com.pablotj.restemailbridge.application.usecase;
|
||||||
|
|
||||||
import com.pablotj.restemailbridge.application.dto.EmailDTO;
|
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.model.Email;
|
||||||
import com.pablotj.restemailbridge.domain.repository.EmailRepository;
|
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 {
|
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;
|
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.emailConfigurationPort = emailConfigurationPort;
|
||||||
this.emailService = emailService;
|
this.emailService = emailService;
|
||||||
this.emailRepository = emailRepository;
|
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) {
|
public void handle(EmailDTO emailDTO) {
|
||||||
String to = emailConfigurationPort.getDefaultRecipient();
|
String to = emailConfigurationPort.getDefaultRecipient();
|
||||||
Email email = emailService.sendEmail(
|
|
||||||
Email.builder()
|
Email email = Email.create(emailDTO.from(), to, emailDTO.subject(), emailDTO.body());
|
||||||
.from(emailDTO.from())
|
|
||||||
.to(to)
|
emailValidatorService.validate(email);
|
||||||
.subject(emailDTO.subject())
|
|
||||||
.body(emailDTO.body())
|
log.info("Sending email from {} to {}", emailDTO.from(), to);
|
||||||
.build()
|
|
||||||
);
|
try {
|
||||||
|
email = emailService.sendEmail(email);
|
||||||
|
email.markAsSent();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error sending email", e);
|
||||||
|
email.markAsFailed(e.getMessage());
|
||||||
|
}
|
||||||
emailRepository.save(email);
|
emailRepository.save(email);
|
||||||
|
log.info("Email successfully sent and persisted to repository for recipient {}", to);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,17 +26,22 @@
|
|||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Swagger/OpenAPI -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>slf4j-api</artifactId>
|
||||||
<version>${springdoc.version}</version>
|
<version>2.0.13</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Actuator -->
|
<!-- Actuator -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-logging</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Test -->
|
<!-- Test -->
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
|||||||
public class RestEmailBridgeApplication {
|
public class RestEmailBridgeApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(RestEmailBridgeApplication.class, args);
|
SpringApplication app = new SpringApplication(RestEmailBridgeApplication.class);
|
||||||
|
app.addInitializers(new EnvironmentValidatorInitializer());
|
||||||
|
app.run(args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
info:
|
info:
|
||||||
app:
|
app:
|
||||||
version: @project.version@
|
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:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: restemailbridge
|
name: restemailbridge
|
||||||
@@ -8,6 +21,18 @@ spring:
|
|||||||
resources:
|
resources:
|
||||||
add-mappings: false
|
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:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: validate
|
ddl-auto: validate
|
||||||
@@ -15,6 +40,7 @@ spring:
|
|||||||
hibernate.transaction.jta.platform: org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform
|
hibernate.transaction.jta.platform: org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: true
|
format_sql: true
|
||||||
|
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||||
show-sql: true
|
show-sql: true
|
||||||
|
|
||||||
jackson:
|
jackson:
|
||||||
@@ -26,26 +52,10 @@ springdoc:
|
|||||||
path: /v3/api-docs
|
path: /v3/api-docs
|
||||||
swagger-ui:
|
swagger-ui:
|
||||||
path: /swagger-ui
|
path: /swagger-ui
|
||||||
|
show-actuator: true
|
||||||
|
|
||||||
server:
|
gmail:
|
||||||
port: 8080
|
oauth2:
|
||||||
servlet:
|
clientId: ${GMAIL_OAUTH_CLIENT_ID}
|
||||||
context-path: /api
|
clientSecret: ${GMAIL_OAUTH_CLIENT_SECRET}
|
||||||
forward-headers-strategy: framework
|
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
|
|
||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
- "5005:5005"
|
||||||
|
- "8888:8888"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
environment:
|
environment:
|
||||||
@@ -11,8 +13,15 @@ services:
|
|||||||
DB_PORT: ${DB_PORT}
|
DB_PORT: ${DB_PORT}
|
||||||
DB_USER: ${DB_USER}
|
DB_USER: ${DB_USER}
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
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:
|
networks:
|
||||||
- network
|
- network
|
||||||
|
volumes:
|
||||||
|
- ./tokens:/app/tokens
|
||||||
|
- ./logs:/app/logs
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -16,5 +16,38 @@
|
|||||||
<groupId>jakarta.validation</groupId>
|
<groupId>jakarta.validation</groupId>
|
||||||
<artifactId>jakarta.validation-api</artifactId>
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>maven_central</id>
|
||||||
|
<name>Maven Central</name>
|
||||||
|
<url>https://repo.maven.apache.org/maven2/</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
</project>
|
</project>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.pablotj.restemailbridge.domain.model;
|
package com.pablotj.restemailbridge.domain.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
@@ -7,9 +8,30 @@ import lombok.Getter;
|
|||||||
@Builder
|
@Builder
|
||||||
public class Email {
|
public class Email {
|
||||||
|
|
||||||
private String from;
|
private final String from;
|
||||||
private String to;
|
private final String to;
|
||||||
private String subject;
|
private final String subject;
|
||||||
private String body;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.pablotj.restemailbridge.domain.model;
|
||||||
|
|
||||||
|
public enum EmailStatus {
|
||||||
|
SENT, FAILED, PENDING
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,22 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<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>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@@ -33,12 +49,99 @@
|
|||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.reflections</groupId>
|
<groupId>org.reflections</groupId>
|
||||||
<artifactId>reflections</artifactId>
|
<artifactId>reflections</artifactId>
|
||||||
<version>0.10.2</version>
|
<version>0.10.2</version>
|
||||||
</dependency>
|
</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 -->
|
<!-- MapStruct -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.mapstruct</groupId>
|
<groupId>org.mapstruct</groupId>
|
||||||
@@ -51,5 +154,30 @@
|
|||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>maven_central</id>
|
||||||
|
<name>Maven Central</name>
|
||||||
|
<url>https://repo.maven.apache.org/maven2/</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
</project>
|
</project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 { }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
package com.pablotj.restemailbridge.infrastructure.persistence;
|
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.Column;
|
||||||
|
import jakarta.persistence.Convert;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EntityListeners;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.Instant;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
@Table(name = "MAIL")
|
@Table(name = "MAIL")
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@@ -19,15 +29,29 @@ public class MailJpa {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(length = 200, nullable = false)
|
||||||
|
@Convert(converter = EncryptionConverter.class)
|
||||||
private String sender;
|
private String sender;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(length = 200, nullable = false)
|
||||||
private String recipient;
|
private String recipient;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(length = 150, nullable = false)
|
||||||
|
@Convert(converter = EncryptionConverter.class)
|
||||||
private String subjet;
|
private String subjet;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(length = 7000, nullable = false)
|
||||||
|
@Convert(converter = EncryptionConverter.class)
|
||||||
private String body;
|
private String body;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private EmailStatus status;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String errorDescription;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public class MailRepositoryAdapter implements EmailRepository {
|
|||||||
mailJpa.setRecipient(email.getTo());
|
mailJpa.setRecipient(email.getTo());
|
||||||
mailJpa.setSubjet(email.getSubject());
|
mailJpa.setSubjet(email.getSubject());
|
||||||
mailJpa.setBody(email.getBody());
|
mailJpa.setBody(email.getBody());
|
||||||
|
mailJpa.setStatus(email.getStatus());
|
||||||
|
mailJpa.setErrorDescription(email.getErrorDescription());
|
||||||
springDataMailRepository.save(mailJpa);
|
springDataMailRepository.save(mailJpa);
|
||||||
|
|
||||||
return email;
|
return email;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,13 @@ package com.pablotj.restemailbridge.infrastructure.rest;
|
|||||||
import com.pablotj.restemailbridge.application.dto.EmailDTO;
|
import com.pablotj.restemailbridge.application.dto.EmailDTO;
|
||||||
import com.pablotj.restemailbridge.application.usecase.SendEmailUseCase;
|
import com.pablotj.restemailbridge.application.usecase.SendEmailUseCase;
|
||||||
import com.pablotj.restemailbridge.infrastructure.rest.dto.SendMailRequest;
|
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 jakarta.validation.Valid;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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
|
@RestController
|
||||||
@RequestMapping("/v1/mail")
|
@RequestMapping("/v1/mail")
|
||||||
|
@Tag(name = "Mail API", description = "Endpoints for sending emails")
|
||||||
public class MailController {
|
public class MailController {
|
||||||
|
|
||||||
private final SendEmailUseCase sendEmailUseCase;
|
private final SendEmailUseCase sendEmailUseCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new {@link MailController} instance.
|
||||||
|
*
|
||||||
|
* @param sendEmailUseCase the use case responsible for sending emails
|
||||||
|
*/
|
||||||
public MailController(SendEmailUseCase sendEmailUseCase) {
|
public MailController(SendEmailUseCase sendEmailUseCase) {
|
||||||
this.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
|
@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) {
|
public ResponseEntity<Void> send(@Valid @RequestBody SendMailRequest request) {
|
||||||
sendEmailUseCase.handle(new EmailDTO(request.from(), request.subject(), request.body()));
|
sendEmailUseCase.handle(new EmailDTO(request.from(), request.subject(), request.body()));
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
package com.pablotj.restemailbridge.infrastructure.rest.dto;
|
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(
|
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,
|
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,
|
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
|
String body
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
CREATE TABLE restemailbridge.mail
|
create table mail
|
||||||
(
|
(
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id bigint generated by default as identity primary key,
|
||||||
body VARCHAR(255) NOT NULL,
|
body varchar(7000) not null,
|
||||||
recipient VARCHAR(255) NOT NULL,
|
recipient varchar(200) not null,
|
||||||
sender VARCHAR(255) NOT NULL,
|
sender varchar(200) not null,
|
||||||
subject VARCHAR(255) 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)))
|
||||||
);
|
);
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
99
infrastructure/src/main/resources/log4j2.properties
Normal file
99
infrastructure/src/main/resources/log4j2.properties
Normal 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
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
27
infrastructure/src/test/resources/application.yml
Normal file
27
infrastructure/src/test/resources/application.yml
Normal 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
|
||||||
Reference in New Issue
Block a user