Skip to content

Commit e7d46c0

Browse files
authored
add new default AttachmentDataSource and add migrate it if necessary (#17)
* add new default AttachmentDataSource and add migrate it if necessary * update readme * make default attachment data source opt-in * add more docs and add test for config * requested changes
1 parent 5631805 commit e7d46c0

7 files changed

Lines changed: 323 additions & 12 deletions

File tree

readme.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,21 @@ Add the mailer service to the classpath by adding the following maven dependency
1717

1818
### Attachments
1919

20-
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)
21-
This step is optional.
20+
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)
21+
This step is optional. Where the payloads are stored is a decision of the application: you can provide your own
22+
implementation (e.g. S3), or register the ready-made `JdbcAttachmentDataSource` shipped with the library, which stores
23+
payloads in the lib-owned table `email_service_attachment_payloads`:
24+
25+
```java
26+
27+
@Bean
28+
public AttachmentDataSource attachmentDataSource(JdbcTemplate jdbcTemplate) {
29+
return new JdbcAttachmentDataSource(jdbcTemplate);
30+
}
31+
```
32+
33+
By default, `JdbcAttachmentDataSource` creates its table on startup. If you prefer to manage the table yourself
34+
(e.g. via Liquibase), disable the built-in migration using the constructor-flag and create the table in your own migrations.
2235

2336
#### Inline (CID) attachments
2437

src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
import it.aboutbits.springboot.emailservice.lib.application.UnavailableAttachmentDataSource;
1616
import it.aboutbits.springboot.emailservice.lib.jpa.EmailRepository;
1717
import org.jspecify.annotations.NullMarked;
18+
import org.springframework.beans.factory.ObjectProvider;
1819
import org.springframework.beans.factory.annotation.Value;
1920
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
20-
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
2121
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
2222
import org.springframework.context.annotation.Bean;
2323
import org.springframework.jdbc.core.JdbcTemplate;
@@ -55,7 +55,7 @@ public QueryEmail queryEmail(EmailRepository emailRepository, EmailMapper emailM
5555
public ManageEmail manageEmail(
5656
EmailRepository emailRepository,
5757
JavaMailSender javaMailSender,
58-
AttachmentDataSource attachmentDataSource,
58+
ObjectProvider<AttachmentDataSource> attachmentDataSource,
5959
EmailMapper emailMapper,
6060
@Value("${aboutbits.emailservice.scheduling.max-attempts:3}") int maxAttempts,
6161
@Value("${aboutbits.emailservice.scheduling.interval:30000}") long schedulerIntervalMillis,
@@ -64,7 +64,7 @@ public ManageEmail manageEmail(
6464
return new ManageEmail(
6565
emailRepository,
6666
javaMailSender,
67-
attachmentDataSource,
67+
attachmentDataSource.getIfAvailable(UnavailableAttachmentDataSource::new),
6868
emailMapper,
6969
maxAttempts,
7070
Duration.ofMillis(schedulerIntervalMillis),
@@ -93,10 +93,4 @@ public CleanupAttachmentFiles cleanupAttachments(
9393
) {
9494
return new CleanupAttachmentFiles(queryEmail, manageEmail, callbacks, stuckCleanupRecoveryThreshold);
9595
}
96-
97-
@Bean
98-
@ConditionalOnMissingBean(AttachmentDataSource.class)
99-
public AttachmentDataSource attachmentDataSource() {
100-
return new UnavailableAttachmentDataSource();
101-
}
10296
}

src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentDataSource.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
public interface AttachmentDataSource {
1010
InputStream getAttachmentPayload(long fileReference) throws AttachmentException;
1111

12+
/**
13+
* Stores the payload and returns the reference used to read it back later.
14+
* The implementation takes ownership of the stream and must close it.
15+
*
16+
* @param payload the attachment payload, closed by the implementation
17+
* @return the reference to pass to {@link #getAttachmentPayload(long)}
18+
*/
1219
long storeAttachmentPayload(InputStream payload) throws AttachmentException;
1320

1421
void releaseAttachment(long fileReference) throws AttachmentException;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package it.aboutbits.springboot.emailservice.lib.application;
2+
3+
import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource;
4+
import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.jspecify.annotations.NullMarked;
7+
import org.springframework.dao.DataAccessException;
8+
import org.springframework.dao.EmptyResultDataAccessException;
9+
import org.springframework.jdbc.core.JdbcTemplate;
10+
11+
import java.io.ByteArrayInputStream;
12+
import java.io.IOException;
13+
import java.io.InputStream;
14+
15+
@Slf4j
16+
@NullMarked
17+
public class JdbcAttachmentDataSource implements AttachmentDataSource {
18+
private final JdbcTemplate jdbcTemplate;
19+
20+
public JdbcAttachmentDataSource(JdbcTemplate jdbcTemplate) {
21+
this(jdbcTemplate, true);
22+
}
23+
24+
public JdbcAttachmentDataSource(JdbcTemplate jdbcTemplate, boolean runMigrations) {
25+
this.jdbcTemplate = jdbcTemplate;
26+
if (runMigrations) {
27+
migrate();
28+
}
29+
}
30+
31+
private void migrate() {
32+
log.info("EmailService: running attachment payload DB migrations...");
33+
34+
jdbcTemplate.execute(
35+
//@formatter:off
36+
"""
37+
38+
create table if not exists email_service_attachment_payloads
39+
(
40+
id bigint generated by default as identity
41+
primary key,
42+
payload bytea not null,
43+
created_at timestamp with time zone default now() not null
44+
);
45+
"""
46+
//@formatter:on
47+
);
48+
49+
log.info("EmailService: attachment payload migrations done!");
50+
}
51+
52+
@Override
53+
public InputStream getAttachmentPayload(long fileReference) throws AttachmentException {
54+
try {
55+
var payload = jdbcTemplate.queryForObject(
56+
"select payload from email_service_attachment_payloads where id = ?",
57+
byte[].class,
58+
fileReference
59+
);
60+
if (payload == null) {
61+
throw new AttachmentException("attachment payload not found: " + fileReference);
62+
}
63+
return new ByteArrayInputStream(payload);
64+
} catch (EmptyResultDataAccessException e) {
65+
throw new AttachmentException("attachment payload not found: " + fileReference, e);
66+
} catch (DataAccessException e) {
67+
throw new AttachmentException("failed to load attachment payload: " + fileReference, e);
68+
}
69+
}
70+
71+
@Override
72+
public long storeAttachmentPayload(InputStream payload) throws AttachmentException {
73+
final byte[] bytes;
74+
try (payload) {
75+
bytes = payload.readAllBytes();
76+
} catch (IOException e) {
77+
throw new AttachmentException("failed to read attachment payload", e);
78+
}
79+
80+
try {
81+
var id = jdbcTemplate.queryForObject(
82+
"insert into email_service_attachment_payloads (payload) values (?) returning id",
83+
Long.class,
84+
(Object) bytes
85+
);
86+
if (id == null) {
87+
throw new AttachmentException("failed to store attachment payload");
88+
}
89+
return id;
90+
} catch (DataAccessException e) {
91+
throw new AttachmentException("failed to store attachment payload", e);
92+
}
93+
}
94+
95+
@Override
96+
public void releaseAttachment(long fileReference) throws AttachmentException {
97+
try {
98+
jdbcTemplate.update(
99+
"delete from email_service_attachment_payloads where id = ?",
100+
fileReference
101+
);
102+
} catch (DataAccessException e) {
103+
throw new AttachmentException("failed to release attachment payload: " + fileReference, e);
104+
}
105+
}
106+
}

src/main/java/it/aboutbits/springboot/emailservice/lib/application/UnavailableAttachmentDataSource.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException;
55
import org.jspecify.annotations.NullMarked;
66

7+
import java.io.IOException;
78
import java.io.InputStream;
89

910
@NullMarked
@@ -15,7 +16,11 @@ public InputStream getAttachmentPayload(long fileReference) throws AttachmentExc
1516

1617
@Override
1718
public long storeAttachmentPayload(InputStream payload) throws AttachmentException {
18-
throw new AttachmentException("attachments not available");
19+
try (payload) {
20+
throw new AttachmentException("attachments not available");
21+
} catch (IOException e) {
22+
throw new AttachmentException("attachments not available", e);
23+
}
1924
}
2025

2126
@Override
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package it.aboutbits.springboot.emailservice;
2+
3+
import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource;
4+
import it.aboutbits.springboot.emailservice.lib.EmailState;
5+
import it.aboutbits.springboot.emailservice.lib.application.EmailParameter;
6+
import it.aboutbits.springboot.emailservice.lib.application.ManageEmail;
7+
import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException;
8+
import it.aboutbits.springboot.emailservice.lib.exception.EmailException;
9+
import it.aboutbits.springboot.emailservice.support.database.WithPostgres;
10+
import org.jspecify.annotations.NullMarked;
11+
import org.junit.jupiter.api.Test;
12+
import org.springframework.beans.factory.annotation.Autowired;
13+
import org.springframework.boot.test.context.SpringBootTest;
14+
import org.springframework.context.ApplicationContext;
15+
16+
import java.io.ByteArrayInputStream;
17+
import java.time.OffsetDateTime;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
21+
22+
@SpringBootTest
23+
@WithPostgres
24+
@NullMarked
25+
class EmailServiceConfigurationTest {
26+
@Autowired
27+
ApplicationContext applicationContext;
28+
29+
@Autowired
30+
ManageEmail manageEmail;
31+
32+
@Test
33+
void givenNoAttachmentDataSourceBean_context_shouldStart() {
34+
assertThat(applicationContext.getBeanNamesForType(AttachmentDataSource.class)).isEmpty();
35+
assertThat(manageEmail).isNotNull();
36+
}
37+
38+
@Test
39+
void givenNoAttachmentDataSourceBean_scheduleWithoutAttachment_shouldSucceed() throws EmailException {
40+
var result = manageEmail.schedule(getValidParameterWithoutAttachment());
41+
42+
assertThat(result.id()).isPositive();
43+
assertThat(result.state()).isEqualTo(EmailState.PENDING);
44+
}
45+
46+
@Test
47+
void givenNoAttachmentDataSourceBean_scheduleWithAttachment_shouldFail() {
48+
var parameter = getValidParameterWithAttachment();
49+
50+
assertThatExceptionOfType(EmailException.class).isThrownBy(
51+
() -> manageEmail.schedule(parameter)
52+
).withCauseInstanceOf(AttachmentException.class);
53+
}
54+
55+
private static EmailParameter getValidParameterWithoutAttachment() {
56+
return EmailParameter.builder()
57+
.scheduledAt(OffsetDateTime.now())
58+
.email(EmailParameter.Email.builder()
59+
.subject("Example email subject")
60+
.textBody("Email body")
61+
.htmlBody("<h1>Html email body</h1>")
62+
.recipient("person1@example.com")
63+
.fromAddress("somebody@aboutbits.it")
64+
.fromName("somebody")
65+
.build()
66+
).build();
67+
}
68+
69+
private static EmailParameter getValidParameterWithAttachment() {
70+
return EmailParameter.builder()
71+
.scheduledAt(OffsetDateTime.now())
72+
.email(EmailParameter.Email.builder()
73+
.subject("Example email subject")
74+
.textBody("Email body")
75+
.htmlBody("<h1>Html email body</h1>")
76+
.recipient("person1@example.com")
77+
.attachment(
78+
EmailParameter.Email.Attachment.builder()
79+
.contentType("image/png")
80+
.fileName("x.png")
81+
.payload(new ByteArrayInputStream(new byte[0]))
82+
.build()
83+
)
84+
.fromAddress("somebody@aboutbits.it")
85+
.fromName("somebody")
86+
.build()
87+
).build();
88+
}
89+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package it.aboutbits.springboot.emailservice.lib.application;
2+
3+
import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException;
4+
import it.aboutbits.springboot.emailservice.support.database.WithPostgres;
5+
import org.jspecify.annotations.NullMarked;
6+
import org.junit.jupiter.api.BeforeEach;
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.boot.test.context.SpringBootTest;
10+
import org.springframework.jdbc.core.JdbcTemplate;
11+
12+
import java.io.ByteArrayInputStream;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
import static org.assertj.core.api.Assertions.assertThatCode;
16+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
17+
18+
@SpringBootTest
19+
@WithPostgres
20+
@NullMarked
21+
class JdbcAttachmentDataSourceTest {
22+
@Autowired
23+
JdbcTemplate jdbcTemplate;
24+
25+
JdbcAttachmentDataSource attachmentDataSource;
26+
27+
@BeforeEach
28+
void setup() {
29+
attachmentDataSource = new JdbcAttachmentDataSource(jdbcTemplate);
30+
}
31+
32+
@Test
33+
void givenPayload_store_shouldBeReadableAgain() throws Exception {
34+
var payload = new byte[]{1, 2, 3, 4, 5};
35+
36+
var fileReference = attachmentDataSource.storeAttachmentPayload(new ByteArrayInputStream(payload));
37+
38+
try (var stored = attachmentDataSource.getAttachmentPayload(fileReference)) {
39+
assertThat(stored.readAllBytes()).isEqualTo(payload);
40+
}
41+
}
42+
43+
@Test
44+
void givenPayload_store_shouldClosePayloadStream() throws Exception {
45+
var payload = new TrackingInputStream(new byte[]{1, 2, 3});
46+
47+
attachmentDataSource.storeAttachmentPayload(payload);
48+
49+
assertThat(payload.closed).isTrue();
50+
}
51+
52+
@Test
53+
void givenMultiplePayloads_store_shouldReturnDistinctReferences() throws Exception {
54+
var first = attachmentDataSource.storeAttachmentPayload(new ByteArrayInputStream(new byte[]{1}));
55+
var second = attachmentDataSource.storeAttachmentPayload(new ByteArrayInputStream(new byte[]{2}));
56+
57+
assertThat(first).isNotEqualTo(second);
58+
}
59+
60+
@Test
61+
void givenUnknownReference_get_shouldFail() {
62+
assertThatExceptionOfType(AttachmentException.class).isThrownBy(
63+
() -> attachmentDataSource.getAttachmentPayload(1L)
64+
);
65+
}
66+
67+
@Test
68+
void givenStoredPayload_release_shouldRemoveIt() throws Exception {
69+
var fileReference = attachmentDataSource.storeAttachmentPayload(new ByteArrayInputStream(new byte[]{1, 2, 3}));
70+
71+
attachmentDataSource.releaseAttachment(fileReference);
72+
73+
assertThatExceptionOfType(AttachmentException.class).isThrownBy(
74+
() -> attachmentDataSource.getAttachmentPayload(fileReference)
75+
);
76+
}
77+
78+
@Test
79+
void givenUnknownReference_release_shouldBeIdempotent() {
80+
assertThatCode(
81+
() -> attachmentDataSource.releaseAttachment(1L)
82+
).doesNotThrowAnyException();
83+
}
84+
85+
private static final class TrackingInputStream extends ByteArrayInputStream {
86+
private boolean closed = false;
87+
88+
private TrackingInputStream(byte[] buf) {
89+
super(buf);
90+
}
91+
92+
@Override
93+
public void close() {
94+
closed = true;
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)