diff --git a/readme.md b/readme.md
index f634ff9..5b94346 100644
--- a/readme.md
+++ b/readme.md
@@ -20,6 +20,29 @@ Add the mailer service to the classpath by adding the following maven dependency
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.
+#### Inline (CID) attachments
+
+To embed an attachment inline, set a `contentId` on the attachment and reference it in the `htmlBody` via the `cid:` scheme.
+This is the equivalent of `MimeMessageHelper.addInline(...)` and renders in all major email clients.
+
+```java
+// @formatter:off
+EmailParameter.Email.builder()
+ // ...
+ .htmlBody("
Hello!
")
+ .attachment(EmailParameter.Email.Attachment.builder()
+ .contentId("header-logo")
+ .fileName("logo.png")
+ .contentType("image/png")
+ .payload(new ClassPathResource("/templates/mail/images/logo.png").getInputStream())
+ .build())
+ .build();
+// @formatter:on
+```
+
+Attachments without a `contentId` are added as regular attachments. Each `contentId` must be unique and referenced
+in the `htmlBody` as `cid:contentId`, otherwise validation fails.
+
## Usage
### Sending an Email
diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailAttachmentDto.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailAttachmentDto.java
index dbf6f2d..99574ea 100644
--- a/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailAttachmentDto.java
+++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailAttachmentDto.java
@@ -2,6 +2,7 @@
import it.aboutbits.springboot.emailservice.lib.model.Email;
import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
@NullMarked
public record EmailAttachmentDto(
@@ -13,6 +14,9 @@ public record EmailAttachmentDto(
String contentType,
+ @Nullable
+ String contentId,
+
long fileReference
) {
}
diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailDto.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailDto.java
index eefe73c..7528009 100644
--- a/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailDto.java
+++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailDto.java
@@ -26,6 +26,8 @@ public record EmailDto(
List recipients,
String textBody,
+
+ @Nullable
String htmlBody,
Set attachments,
diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailParameter.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailParameter.java
index 841032d..5a131c4 100644
--- a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailParameter.java
+++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailParameter.java
@@ -1,5 +1,7 @@
package it.aboutbits.springboot.emailservice.lib.application;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Builder;
@@ -9,13 +11,17 @@
import java.io.InputStream;
import java.time.OffsetDateTime;
+import java.util.HashSet;
import java.util.List;
+import java.util.Objects;
import java.util.Set;
+import java.util.regex.Pattern;
@Builder
@NullMarked
public record EmailParameter(
OffsetDateTime scheduledAt,
+ @Valid
Email email
) {
@Builder
@@ -28,6 +34,8 @@ public record Email(
List recipients,
String textBody,
+
+ @Nullable
String htmlBody,
@NotBlank
@@ -41,15 +49,58 @@ public record Email(
String replyToName,
@Singular
+ @Valid
Set attachments
) {
+ // the "cid:" scheme is case-insensitive, the contentId itself is not
+ private static final Pattern CID_REFERENCE_PATTERN = Pattern.compile(
+ "cid:([^\\s\"'<>]+)",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ @AssertTrue(message = "each inline attachment contentId must be referenced in the htmlBody as cid:contentId")
+ public boolean isInlineAttachmentsValid() {
+ var contentIds = inlineContentIds();
+ if (contentIds.isEmpty()) {
+ return true;
+ }
+
+ if (htmlBody == null) {
+ return false;
+ }
+
+ var referencedContentIds = new HashSet();
+ var matcher = CID_REFERENCE_PATTERN.matcher(htmlBody);
+ while (matcher.find()) {
+ referencedContentIds.add(matcher.group(1));
+ }
+
+ return referencedContentIds.containsAll(contentIds);
+ }
+
+ @AssertTrue(message = "each inline attachment contentId must be unique")
+ public boolean isInlineAttachmentContentIdsUnique() {
+ var contentIds = inlineContentIds();
+ return contentIds.size() == new HashSet<>(contentIds).size();
+ }
+
+ private List inlineContentIds() {
+ return attachments.stream()
+ .map(Attachment::contentId)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
@Builder
public record Attachment(
InputStream payload,
@NotBlank
String fileName,
@NotBlank
- String contentType
+ String contentType,
+ // if set, the attachment is embedded inline and can be referenced in the htmlBody as "cid:contentId"
+ @Nullable
+ String contentId
) {
}
}
diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailServiceMigrator.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailServiceMigrator.java
index 722e745..66d3b79 100644
--- a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailServiceMigrator.java
+++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailServiceMigrator.java
@@ -76,6 +76,8 @@ updated_at timestamp with time zone default now() not null,
alter table email_service_emails add column if not exists cleanup_start_time timestamp with time zone;
+ alter table email_service_email_attachments add column if not exists content_id text;
+
update email_service_emails
set execution_end_time = sent_at
where sent_at is not null
diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmail.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmail.java
index 12cc6ce..ab86d16 100644
--- a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmail.java
+++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmail.java
@@ -22,6 +22,7 @@
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
+import org.springframework.util.FileCopyUtils;
import org.springframework.validation.annotation.Validated;
import java.io.IOException;
@@ -183,6 +184,7 @@ private void sendMail(
String replyToName,
List recipients,
String subject,
+ @Nullable
String htmlBody,
String plainTextBody,
Set attachments
@@ -202,16 +204,23 @@ private void sendMail(
}
}
- if (!htmlBody.isBlank()) {
+ if (htmlBody != null && !htmlBody.isBlank()) {
helper.setText(plainTextBody, htmlBody);
} else {
helper.setText(plainTextBody);
}
for (var attachment : attachments) {
- var payload = attachmentDataSource.getAttachmentPayload(attachment.getFileReference());
- helper.addAttachment(attachment.getFileName(), new ByteArrayResource(payload.readAllBytes()));
- payload.close();
+ var resource = new ByteArrayResource(FileCopyUtils.copyToByteArray(
+ attachmentDataSource.getAttachmentPayload(attachment.getFileReference())
+ ));
+
+ var contentId = attachment.getContentId();
+ if (contentId != null) {
+ helper.addInline(contentId, attachment.getFileName(), resource, attachment.getContentType());
+ } else {
+ helper.addAttachment(attachment.getFileName(), resource, attachment.getContentType());
+ }
}
mailSender.send(message);
@@ -241,6 +250,7 @@ private Email fromParameter(EmailParameter parameter) throws AttachmentException
var emailAttachment = new EmailAttachment();
emailAttachment.setEmail(email);
emailAttachment.setContentType(attachment.contentType());
+ emailAttachment.setContentId(attachment.contentId());
emailAttachment.setFileName(attachment.fileName());
emailAttachment.setFileReference(reference);
diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailAttachment.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailAttachment.java
index b089bb9..bdc00ad 100644
--- a/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailAttachment.java
+++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailAttachment.java
@@ -29,5 +29,7 @@ public class EmailAttachment {
private String contentType;
+ private String contentId;
+
private long fileReference;
}
diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailContent.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailContent.java
index dc68116..3ec09f9 100644
--- a/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailContent.java
+++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailContent.java
@@ -25,6 +25,8 @@ public record EmailContent(
List recipients,
String textBody,
+
+ @Nullable
String htmlBody
) {
}
diff --git a/src/test/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmailTest.java b/src/test/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmailTest.java
index 1c6b9e8..c2d83e0 100644
--- a/src/test/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmailTest.java
+++ b/src/test/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmailTest.java
@@ -5,10 +5,18 @@
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 jakarta.mail.Part;
+import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
+import jakarta.mail.internet.MimeMultipart;
+import jakarta.validation.ConstraintViolationException;
import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mail.MailSendException;
@@ -18,6 +26,8 @@
import java.io.ByteArrayInputStream;
import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -153,6 +163,106 @@ void givenRequiredParameterWithAttachedFiles_sendOrFail_shouldSendImmediately()
verify(javaMailSender, times(1)).send(any(MimeMessage.class));
}
+ @Test
+ void givenInlineAttachment_schedule_shouldPersistContentId() throws EmailException, AttachmentException {
+ when(attachmentDataSource.storeAttachmentPayload(any())).thenReturn(33L);
+
+ var parameter = getValidParameterWithInlineAttachment(
+ "Html email body
",
+ inlineAttachment("header-logo")
+ );
+
+ var result = manageEmail.schedule(parameter);
+
+ assertThat(result.id()).isPositive();
+ assertThat(result.state()).isEqualTo(EmailState.PENDING);
+ assertThat(result.attachments()).hasSize(1);
+ assertThat(result.attachments().iterator().next().contentId()).isEqualTo("header-logo");
+ }
+
+ @Test
+ void givenInlineAndRegularAttachment_sendOrFail_shouldAddInlineAndRegularMimeParts() throws Exception {
+ when(attachmentDataSource.storeAttachmentPayload(any())).thenReturn(33L);
+ when(attachmentDataSource.getAttachmentPayload(anyLong()))
+ .thenAnswer(_ -> new ByteArrayInputStream(new byte[]{1, 2, 3}));
+
+ var parameter = getValidParameterWithInlineAttachment(
+ "Html email body
",
+ inlineAttachment("header-logo"),
+ regularAttachment("x.png")
+ );
+
+ var result = manageEmail.sendOrFail(parameter);
+
+ assertThat(result.state()).isEqualTo(EmailState.SENT);
+
+ var captor = ArgumentCaptor.forClass(MimeMessage.class);
+ verify(javaMailSender).send(captor.capture());
+ var message = captor.getValue();
+ message.saveChanges();
+
+ assertThat(message.getContentType()).startsWith("multipart/mixed");
+
+ var parts = flattenParts(message.getContent());
+
+ var inlinePart = parts.stream()
+ .filter(part -> hasDisposition(part, Part.INLINE))
+ .findFirst()
+ .orElseThrow();
+ assertThat(inlinePart.getContentID()).isEqualTo("");
+ assertThat(inlinePart.getContentType()).startsWith("image/png");
+ assertThat(inlinePart.getFileName()).isEqualTo("logo.png");
+
+ var attachmentPart = parts.stream()
+ .filter(part -> hasDisposition(part, Part.ATTACHMENT))
+ .findFirst()
+ .orElseThrow();
+ assertThat(attachmentPart.getFileName()).isEqualTo("x.png");
+ }
+
+ @Test
+ void givenUppercaseCidReference_schedule_shouldSucceed() throws EmailException, AttachmentException {
+ when(attachmentDataSource.storeAttachmentPayload(any())).thenReturn(33L);
+
+ var parameter = getValidParameterWithInlineAttachment(
+ "Html email body
",
+ inlineAttachment("header-logo")
+ );
+
+ var result = manageEmail.schedule(parameter);
+
+ assertThat(result.state()).isEqualTo(EmailState.PENDING);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "'', header-logo",
+ ", header-logo",
+ "Html email body
, header-logo",
+ "
, header-logo",
+ "Html email body
, ' '"
+ })
+ void givenInvalidInlineAttachment_schedule_shouldFail(@Nullable String htmlBody, String contentId) {
+ var parameter = getValidParameterWithInlineAttachment(htmlBody, inlineAttachment(contentId));
+
+ assertThatExceptionOfType(ConstraintViolationException.class).isThrownBy(
+ () -> manageEmail.schedule(parameter)
+ );
+ }
+
+ @Test
+ void givenDuplicateContentIds_schedule_shouldFail() {
+ var parameter = getValidParameterWithInlineAttachment(
+ "Html email body
",
+ inlineAttachment("header-logo"),
+ inlineAttachment("header-logo", "other.png")
+ );
+
+ assertThatExceptionOfType(ConstraintViolationException.class).isThrownBy(
+ () -> manageEmail.schedule(parameter)
+ );
+ }
+
@Test
void givenAttachmentError_sendOrFail_shouldFail() throws EmailException, AttachmentException {
when(attachmentDataSource.storeAttachmentPayload(any())).thenThrow(new AttachmentException());
@@ -182,16 +292,16 @@ 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")
- .recipient("person2@example.com")
- .fromAddress("somebody@aboutbits.it")
- .fromName("somebody")
- .replyToAddress("somebodyElse@aboutbits.it")
- .replyToName("somebodyElse")
- .build()
+ .subject("Example email subject")
+ .textBody("Email body")
+ .htmlBody("Html email body
")
+ .recipient("person1@example.com")
+ .recipient("person2@example.com")
+ .fromAddress("somebody@aboutbits.it")
+ .fromName("somebody")
+ .replyToAddress("somebodyElse@aboutbits.it")
+ .replyToName("somebodyElse")
+ .build()
).build();
}
@@ -199,23 +309,92 @@ 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")
- .recipient("person2@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")
- .replyToAddress("somebodyElse@aboutbits.it")
- .replyToName("somebodyElse")
- .build()
+ .subject("Example email subject")
+ .textBody("Email body")
+ .htmlBody("Html email body
")
+ .recipient("person1@example.com")
+ .recipient("person2@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")
+ .replyToAddress("somebodyElse@aboutbits.it")
+ .replyToName("somebodyElse")
+ .build()
).build();
}
+
+ private static EmailParameter getValidParameterWithInlineAttachment(
+ @Nullable String htmlBody,
+ EmailParameter.Email.Attachment... attachments
+ ) {
+ var emailBuilder = EmailParameter.Email.builder()
+ .subject("Example email subject")
+ .textBody("Email body")
+ .recipient("person1@example.com")
+ .fromAddress("somebody@aboutbits.it")
+ .fromName("somebody");
+
+ if (htmlBody != null) {
+ emailBuilder.htmlBody(htmlBody);
+ }
+ for (var attachment : attachments) {
+ emailBuilder.attachment(attachment);
+ }
+
+ return EmailParameter.builder()
+ .scheduledAt(OffsetDateTime.now())
+ .email(emailBuilder.build())
+ .build();
+ }
+
+ private static EmailParameter.Email.Attachment inlineAttachment(String contentId) {
+ return inlineAttachment(contentId, "logo.png");
+ }
+
+ private static EmailParameter.Email.Attachment inlineAttachment(String contentId, String fileName) {
+ return EmailParameter.Email.Attachment.builder()
+ .contentType("image/png")
+ .fileName(fileName)
+ .contentId(contentId)
+ .payload(new ByteArrayInputStream(new byte[]{1, 2, 3}))
+ .build();
+ }
+
+ private static EmailParameter.Email.Attachment regularAttachment(String fileName) {
+ return EmailParameter.Email.Attachment.builder()
+ .contentType("image/png")
+ .fileName(fileName)
+ .payload(new ByteArrayInputStream(new byte[]{1, 2, 3}))
+ .build();
+ }
+
+ private static List flattenParts(Object content) throws Exception {
+ var parts = new ArrayList();
+ if (content instanceof MimeMultipart multipart) {
+ for (var i = 0; i < multipart.getCount(); i++) {
+ var part = (MimeBodyPart) multipart.getBodyPart(i);
+ var partContent = part.getContent();
+ if (partContent instanceof MimeMultipart) {
+ parts.addAll(flattenParts(partContent));
+ } else {
+ parts.add(part);
+ }
+ }
+ }
+ return parts;
+ }
+
+ private static boolean hasDisposition(MimeBodyPart part, String disposition) {
+ try {
+ return disposition.equalsIgnoreCase(part.getDisposition());
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
}