Compare commits

...

5 Commits

22 changed files with 411 additions and 35 deletions

View File

@ -1,5 +1,7 @@
SPRING_PROFILES_ACTIVE=dev SPRING_PROFILES_ACTIVE=dev
APP_ENCRYPTION_SECRET=123456789
DB_NAME=EXAMPLE_DB DB_NAME=EXAMPLE_DB
DB_USER=EXAMPLE DB_USER=EXAMPLE
DB_PASSWORD=SECRET DB_PASSWORD=SECRET

View File

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

View File

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

View File

@ -1,6 +1,9 @@
info: info:
app: app:
version: @project.version@ version: @project.version@
app:
encryption:
secret: ${APP_ENCRYPTION_SECRET}
spring: spring:
application: application:
name: restemailbridge name: restemailbridge
@ -26,6 +29,7 @@ springdoc:
path: /v3/api-docs path: /v3/api-docs
swagger-ui: swagger-ui:
path: /swagger-ui path: /swagger-ui
show-actuator: true
server: server:
port: 8080 port: 8080

View File

@ -13,6 +13,7 @@ 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}
GMAIL_OAUTH_CLIENT_ID: ${GMAIL_OAUTH_CLIENT_ID} GMAIL_OAUTH_CLIENT_ID: ${GMAIL_OAUTH_CLIENT_ID}
GMAIL_OAUTH_CLIENT_SECRET: ${GMAIL_OAUTH_CLIENT_SECRET} GMAIL_OAUTH_CLIENT_SECRET: ${GMAIL_OAUTH_CLIENT_SECRET}
networks: networks:

View File

@ -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();
}
}

View File

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

View File

@ -74,6 +74,14 @@
<version>1.39.0</version> <version>1.39.0</version>
</dependency> </dependency>
<!-- Swagger/OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.google.apis</groupId> <groupId>com.google.apis</groupId>
<artifactId>google-api-services-gmail</artifactId> <artifactId>google-api-services-gmail</artifactId>

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

View File

@ -1,15 +1,24 @@
package com.pablotj.restemailbridge.infrastructure.persistence; package com.pablotj.restemailbridge.infrastructure.persistence;
import com.pablotj.restemailbridge.domain.model.EmailStatus;
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 +28,29 @@ public class MailJpa {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@Column(length = 100, nullable = false) @Column(length = 200, nullable = false)
@Convert(converter = EncryptionConverter.class)
private String sender; private String sender;
@Column(length = 100, nullable = false) @Column(length = 200, nullable = false)
private String recipient; private String recipient;
@Column(length = 50, nullable = false) @Column(length = 150, nullable = false)
@Convert(converter = EncryptionConverter.class)
private String subjet; private String subjet;
@Column(length = 40000, 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;
} }

View File

@ -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;

View File

@ -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();

View File

@ -1,12 +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.Email;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Length;
@Schema(description = "Request payload to send an email")
public record SendMailRequest( public record SendMailRequest(
@NotBlank @Email @Length(min = 4, max = 100) String from, @NotBlank(message = "{email.from.blank}")
@NotBlank @Length(min=1, max = 30) String subject, @Email(message = "{email.from.invalid}")
@NotBlank @Length(min=1, max = 4000) String body @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, 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)))
); );

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