Compare commits

..

No commits in common. "f04fb141923695dc91097bb88bce7a04d525ecd9" and "fff9362ea8df981e43942f50fc0a4cd0f66a0313" have entirely different histories.

22 changed files with 35 additions and 411 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
package com.pablotj.restemailbridge.domain.model;
import java.time.Instant;
import lombok.Builder;
import lombok.Getter;
@ -8,30 +7,9 @@ import lombok.Getter;
@Builder
public class Email {
private final String from;
private final String to;
private final String subject;
private final String body;
private EmailStatus status;
private final Instant createdAt;
private String errorDescription;
private String from;
private String to;
private String subject;
private String body;
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

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

View File

@ -74,14 +74,6 @@
<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>

View File

@ -1,8 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,46 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,72 +0,0 @@
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,24 +1,15 @@
package com.pablotj.restemailbridge.infrastructure.persistence;
import com.pablotj.restemailbridge.domain.model.EmailStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "MAIL")
@Getter
@Setter
@ -28,29 +19,15 @@ public class MailJpa {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 200, nullable = false)
@Convert(converter = EncryptionConverter.class)
@Column(length = 100, nullable = false)
private String sender;
@Column(length = 200, nullable = false)
@Column(length = 100, nullable = false)
private String recipient;
@Column(length = 150, nullable = false)
@Convert(converter = EncryptionConverter.class)
@Column(length = 50, nullable = false)
private String subjet;
@Column(length = 7000, nullable = false)
@Convert(converter = EncryptionConverter.class)
@Column(length = 40000, nullable = false)
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,8 +20,7 @@ public class MailRepositoryAdapter implements EmailRepository {
mailJpa.setRecipient(email.getTo());
mailJpa.setSubjet(email.getSubject());
mailJpa.setBody(email.getBody());
mailJpa.setStatus(email.getStatus());
mailJpa.setErrorDescription(email.getErrorDescription());
springDataMailRepository.save(mailJpa);
return email;

View File

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

View File

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

View File

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

View File

@ -1,9 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,9 +0,0 @@
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