diff --git a/readme.md b/readme.md index 5b94346..3f7aa28 100644 --- a/readme.md +++ b/readme.md @@ -17,8 +17,21 @@ Add the mailer service to the classpath by adding the following maven dependency ### Attachments -If you want to use attachments, you will have to create a bean implementing this interface: [AttachmentDataSource.java](src%2Fmain%2Fjava%2Fit%2Faboutbits%2Fspringboot%2Femailservice%2Flib%2FAttachmentDataSource.java) -This step is optional. +If you want to use attachments, you will have to define a bean implementing this interface: [AttachmentDataSource.java](src%2Fmain%2Fjava%2Fit%2Faboutbits%2Fspringboot%2Femailservice%2Flib%2FAttachmentDataSource.java) +This step is optional. Where the payloads are stored is a decision of the application: you can provide your own +implementation (e.g. S3), or register the ready-made `JdbcAttachmentDataSource` shipped with the library, which stores +payloads in the lib-owned table `email_service_attachment_payloads`: + +```java + +@Bean +public AttachmentDataSource attachmentDataSource(JdbcTemplate jdbcTemplate) { + return new JdbcAttachmentDataSource(jdbcTemplate); +} +``` + +By default, `JdbcAttachmentDataSource` creates its table on startup. If you prefer to manage the table yourself +(e.g. via Liquibase), disable the built-in migration using the constructor-flag and create the table in your own migrations. #### Inline (CID) attachments diff --git a/src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java b/src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java index 0c4bc36..f72adbf 100644 --- a/src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java +++ b/src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java @@ -15,9 +15,9 @@ import it.aboutbits.springboot.emailservice.lib.application.UnavailableAttachmentDataSource; import it.aboutbits.springboot.emailservice.lib.jpa.EmailRepository; import org.jspecify.annotations.NullMarked; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfigurationPackage; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.jdbc.core.JdbcTemplate; @@ -55,7 +55,7 @@ public QueryEmail queryEmail(EmailRepository emailRepository, EmailMapper emailM public ManageEmail manageEmail( EmailRepository emailRepository, JavaMailSender javaMailSender, - AttachmentDataSource attachmentDataSource, + ObjectProvider attachmentDataSource, EmailMapper emailMapper, @Value("${aboutbits.emailservice.scheduling.max-attempts:3}") int maxAttempts, @Value("${aboutbits.emailservice.scheduling.interval:30000}") long schedulerIntervalMillis, @@ -64,7 +64,7 @@ public ManageEmail manageEmail( return new ManageEmail( emailRepository, javaMailSender, - attachmentDataSource, + attachmentDataSource.getIfAvailable(UnavailableAttachmentDataSource::new), emailMapper, maxAttempts, Duration.ofMillis(schedulerIntervalMillis), @@ -93,10 +93,4 @@ public CleanupAttachmentFiles cleanupAttachments( ) { return new CleanupAttachmentFiles(queryEmail, manageEmail, callbacks, stuckCleanupRecoveryThreshold); } - - @Bean - @ConditionalOnMissingBean(AttachmentDataSource.class) - public AttachmentDataSource attachmentDataSource() { - return new UnavailableAttachmentDataSource(); - } } diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentDataSource.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentDataSource.java index 813e1e4..720c8fe 100644 --- a/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentDataSource.java +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentDataSource.java @@ -9,6 +9,13 @@ public interface AttachmentDataSource { InputStream getAttachmentPayload(long fileReference) throws AttachmentException; + /** + * Stores the payload and returns the reference used to read it back later. + * The implementation takes ownership of the stream and must close it. + * + * @param payload the attachment payload, closed by the implementation + * @return the reference to pass to {@link #getAttachmentPayload(long)} + */ long storeAttachmentPayload(InputStream payload) throws AttachmentException; void releaseAttachment(long fileReference) throws AttachmentException; diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/JdbcAttachmentDataSource.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/JdbcAttachmentDataSource.java new file mode 100644 index 0000000..e161fdb --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/JdbcAttachmentDataSource.java @@ -0,0 +1,106 @@ +package it.aboutbits.springboot.emailservice.lib.application; + +import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource; +import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NullMarked; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@NullMarked +public class JdbcAttachmentDataSource implements AttachmentDataSource { + private final JdbcTemplate jdbcTemplate; + + public JdbcAttachmentDataSource(JdbcTemplate jdbcTemplate) { + this(jdbcTemplate, true); + } + + public JdbcAttachmentDataSource(JdbcTemplate jdbcTemplate, boolean runMigrations) { + this.jdbcTemplate = jdbcTemplate; + if (runMigrations) { + migrate(); + } + } + + private void migrate() { + log.info("EmailService: running attachment payload DB migrations..."); + + jdbcTemplate.execute( + //@formatter:off + """ + + create table if not exists email_service_attachment_payloads + ( + id bigint generated by default as identity + primary key, + payload bytea not null, + created_at timestamp with time zone default now() not null + ); + """ + //@formatter:on + ); + + log.info("EmailService: attachment payload migrations done!"); + } + + @Override + public InputStream getAttachmentPayload(long fileReference) throws AttachmentException { + try { + var payload = jdbcTemplate.queryForObject( + "select payload from email_service_attachment_payloads where id = ?", + byte[].class, + fileReference + ); + if (payload == null) { + throw new AttachmentException("attachment payload not found: " + fileReference); + } + return new ByteArrayInputStream(payload); + } catch (EmptyResultDataAccessException e) { + throw new AttachmentException("attachment payload not found: " + fileReference, e); + } catch (DataAccessException e) { + throw new AttachmentException("failed to load attachment payload: " + fileReference, e); + } + } + + @Override + public long storeAttachmentPayload(InputStream payload) throws AttachmentException { + final byte[] bytes; + try (payload) { + bytes = payload.readAllBytes(); + } catch (IOException e) { + throw new AttachmentException("failed to read attachment payload", e); + } + + try { + var id = jdbcTemplate.queryForObject( + "insert into email_service_attachment_payloads (payload) values (?) returning id", + Long.class, + (Object) bytes + ); + if (id == null) { + throw new AttachmentException("failed to store attachment payload"); + } + return id; + } catch (DataAccessException e) { + throw new AttachmentException("failed to store attachment payload", e); + } + } + + @Override + public void releaseAttachment(long fileReference) throws AttachmentException { + try { + jdbcTemplate.update( + "delete from email_service_attachment_payloads where id = ?", + fileReference + ); + } catch (DataAccessException e) { + throw new AttachmentException("failed to release attachment payload: " + fileReference, e); + } + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/UnavailableAttachmentDataSource.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/UnavailableAttachmentDataSource.java index f2ba789..5120223 100644 --- a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/UnavailableAttachmentDataSource.java +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/UnavailableAttachmentDataSource.java @@ -4,6 +4,7 @@ import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException; import org.jspecify.annotations.NullMarked; +import java.io.IOException; import java.io.InputStream; @NullMarked @@ -15,7 +16,11 @@ public InputStream getAttachmentPayload(long fileReference) throws AttachmentExc @Override public long storeAttachmentPayload(InputStream payload) throws AttachmentException { - throw new AttachmentException("attachments not available"); + try (payload) { + throw new AttachmentException("attachments not available"); + } catch (IOException e) { + throw new AttachmentException("attachments not available", e); + } } @Override diff --git a/src/test/java/it/aboutbits/springboot/emailservice/EmailServiceConfigurationTest.java b/src/test/java/it/aboutbits/springboot/emailservice/EmailServiceConfigurationTest.java new file mode 100644 index 0000000..bb50571 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/emailservice/EmailServiceConfigurationTest.java @@ -0,0 +1,89 @@ +package it.aboutbits.springboot.emailservice; + +import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource; +import it.aboutbits.springboot.emailservice.lib.EmailState; +import it.aboutbits.springboot.emailservice.lib.application.EmailParameter; +import it.aboutbits.springboot.emailservice.lib.application.ManageEmail; +import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException; +import it.aboutbits.springboot.emailservice.lib.exception.EmailException; +import it.aboutbits.springboot.emailservice.support.database.WithPostgres; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +import java.io.ByteArrayInputStream; +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@SpringBootTest +@WithPostgres +@NullMarked +class EmailServiceConfigurationTest { + @Autowired + ApplicationContext applicationContext; + + @Autowired + ManageEmail manageEmail; + + @Test + void givenNoAttachmentDataSourceBean_context_shouldStart() { + assertThat(applicationContext.getBeanNamesForType(AttachmentDataSource.class)).isEmpty(); + assertThat(manageEmail).isNotNull(); + } + + @Test + void givenNoAttachmentDataSourceBean_scheduleWithoutAttachment_shouldSucceed() throws EmailException { + var result = manageEmail.schedule(getValidParameterWithoutAttachment()); + + assertThat(result.id()).isPositive(); + assertThat(result.state()).isEqualTo(EmailState.PENDING); + } + + @Test + void givenNoAttachmentDataSourceBean_scheduleWithAttachment_shouldFail() { + var parameter = getValidParameterWithAttachment(); + + assertThatExceptionOfType(EmailException.class).isThrownBy( + () -> manageEmail.schedule(parameter) + ).withCauseInstanceOf(AttachmentException.class); + } + + private static EmailParameter getValidParameterWithoutAttachment() { + return EmailParameter.builder() + .scheduledAt(OffsetDateTime.now()) + .email(EmailParameter.Email.builder() + .subject("Example email subject") + .textBody("Email body") + .htmlBody("

Html email body

") + .recipient("person1@example.com") + .fromAddress("somebody@aboutbits.it") + .fromName("somebody") + .build() + ).build(); + } + + private static EmailParameter getValidParameterWithAttachment() { + return EmailParameter.builder() + .scheduledAt(OffsetDateTime.now()) + .email(EmailParameter.Email.builder() + .subject("Example email subject") + .textBody("Email body") + .htmlBody("

Html email body

") + .recipient("person1@example.com") + .attachment( + EmailParameter.Email.Attachment.builder() + .contentType("image/png") + .fileName("x.png") + .payload(new ByteArrayInputStream(new byte[0])) + .build() + ) + .fromAddress("somebody@aboutbits.it") + .fromName("somebody") + .build() + ).build(); + } +} diff --git a/src/test/java/it/aboutbits/springboot/emailservice/lib/application/JdbcAttachmentDataSourceTest.java b/src/test/java/it/aboutbits/springboot/emailservice/lib/application/JdbcAttachmentDataSourceTest.java new file mode 100644 index 0000000..2b59a2c --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/emailservice/lib/application/JdbcAttachmentDataSourceTest.java @@ -0,0 +1,97 @@ +package it.aboutbits.springboot.emailservice.lib.application; + +import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException; +import it.aboutbits.springboot.emailservice.support.database.WithPostgres; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.io.ByteArrayInputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@SpringBootTest +@WithPostgres +@NullMarked +class JdbcAttachmentDataSourceTest { + @Autowired + JdbcTemplate jdbcTemplate; + + JdbcAttachmentDataSource attachmentDataSource; + + @BeforeEach + void setup() { + attachmentDataSource = new JdbcAttachmentDataSource(jdbcTemplate); + } + + @Test + void givenPayload_store_shouldBeReadableAgain() throws Exception { + var payload = new byte[]{1, 2, 3, 4, 5}; + + var fileReference = attachmentDataSource.storeAttachmentPayload(new ByteArrayInputStream(payload)); + + try (var stored = attachmentDataSource.getAttachmentPayload(fileReference)) { + assertThat(stored.readAllBytes()).isEqualTo(payload); + } + } + + @Test + void givenPayload_store_shouldClosePayloadStream() throws Exception { + var payload = new TrackingInputStream(new byte[]{1, 2, 3}); + + attachmentDataSource.storeAttachmentPayload(payload); + + assertThat(payload.closed).isTrue(); + } + + @Test + void givenMultiplePayloads_store_shouldReturnDistinctReferences() throws Exception { + var first = attachmentDataSource.storeAttachmentPayload(new ByteArrayInputStream(new byte[]{1})); + var second = attachmentDataSource.storeAttachmentPayload(new ByteArrayInputStream(new byte[]{2})); + + assertThat(first).isNotEqualTo(second); + } + + @Test + void givenUnknownReference_get_shouldFail() { + assertThatExceptionOfType(AttachmentException.class).isThrownBy( + () -> attachmentDataSource.getAttachmentPayload(1L) + ); + } + + @Test + void givenStoredPayload_release_shouldRemoveIt() throws Exception { + var fileReference = attachmentDataSource.storeAttachmentPayload(new ByteArrayInputStream(new byte[]{1, 2, 3})); + + attachmentDataSource.releaseAttachment(fileReference); + + assertThatExceptionOfType(AttachmentException.class).isThrownBy( + () -> attachmentDataSource.getAttachmentPayload(fileReference) + ); + } + + @Test + void givenUnknownReference_release_shouldBeIdempotent() { + assertThatCode( + () -> attachmentDataSource.releaseAttachment(1L) + ).doesNotThrowAnyException(); + } + + private static final class TrackingInputStream extends ByteArrayInputStream { + private boolean closed = false; + + private TrackingInputStream(byte[] buf) { + super(buf); + } + + @Override + public void close() { + closed = true; + } + } +}