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