Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,18 @@ 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);
}
```

#### Inline (CID) attachments

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,7 +55,7 @@ public QueryEmail queryEmail(EmailRepository emailRepository, EmailMapper emailM
public ManageEmail manageEmail(
EmailRepository emailRepository,
JavaMailSender javaMailSender,
AttachmentDataSource attachmentDataSource,
ObjectProvider<AttachmentDataSource> attachmentDataSource,
EmailMapper emailMapper,
@Value("${aboutbits.emailservice.scheduling.max-attempts:3}") int maxAttempts,
@Value("${aboutbits.emailservice.scheduling.interval:30000}") long schedulerIntervalMillis,
Expand All @@ -64,7 +64,7 @@ public ManageEmail manageEmail(
return new ManageEmail(
emailRepository,
javaMailSender,
attachmentDataSource,
attachmentDataSource.getIfAvailable(UnavailableAttachmentDataSource::new),
emailMapper,
maxAttempts,
Duration.ofMillis(schedulerIntervalMillis),
Expand Down Expand Up @@ -93,10 +93,4 @@ public CleanupAttachmentFiles cleanupAttachments(
) {
return new CleanupAttachmentFiles(queryEmail, manageEmail, callbacks, stuckCleanupRecoveryThreshold);
}

@Bean
@ConditionalOnMissingBean(AttachmentDataSource.class)
public AttachmentDataSource attachmentDataSource() {
return new UnavailableAttachmentDataSource();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
J0nasMayr marked this conversation as resolved.
*
* @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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 = jdbcTemplate;
migrate();
Comment thread
J0nasMayr marked this conversation as resolved.
Outdated
}

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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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("<h1>Html email body</h1>")
.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("<h1>Html email body</h1>")
.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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}