Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 7 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ 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.
Attachments work out of the box. The library ships a default that stores attachment payloads in the
lib-owned table `email_service_attachment_payloads`. The table is created automatically the first time the default is
used, unless `aboutbits.emailservice.migrations.enabled` is set to `false`.

If you want to store payloads somewhere else (e.g. S3), define your own bean implementing this
interface: [AttachmentDataSource.java](src%2Fmain%2Fjava%2Fit%2Faboutbits%2Fspringboot%2Femailservice%2Flib%2FAttachmentDataSource.java)
The default then backs off and its table is never created.

#### Inline (CID) attachments

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@
import it.aboutbits.springboot.emailservice.lib.application.EmailMapper;
import it.aboutbits.springboot.emailservice.lib.application.EmailMapperImpl;
import it.aboutbits.springboot.emailservice.lib.application.EmailServiceMigrator;
import it.aboutbits.springboot.emailservice.lib.application.JdbcAttachmentDataSource;
import it.aboutbits.springboot.emailservice.lib.application.ManageEmail;
import it.aboutbits.springboot.emailservice.lib.application.QueryEmail;
import it.aboutbits.springboot.emailservice.lib.application.SendScheduledEmails;
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.annotation.Value;
Expand Down Expand Up @@ -96,7 +96,14 @@ public CleanupAttachmentFiles cleanupAttachments(

@Bean
@ConditionalOnMissingBean(AttachmentDataSource.class)
public AttachmentDataSource attachmentDataSource() {
return new UnavailableAttachmentDataSource();
public JdbcAttachmentDataSource attachmentDataSource(
Comment thread
J0nasMayr marked this conversation as resolved.
Outdated
JdbcTemplate jdbcTemplate,
@Value("${aboutbits.emailservice.migrations.enabled:true}") boolean migrationsEnabled
) {
var dataSource = new JdbcAttachmentDataSource(jdbcTemplate);
if (migrationsEnabled) {
dataSource.migrate();
}
Comment thread
J0nasMayr marked this conversation as resolved.
Outdated
return dataSource;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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;
}

public 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);
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package it.aboutbits.springboot.emailservice.lib.application;

import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource;
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.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

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
AttachmentDataSource attachmentDataSource;

@Test
void defaultAttachmentDataSource_shouldBeJdbcBased() {
assertThat(attachmentDataSource).isInstanceOf(JdbcAttachmentDataSource.class);
}

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