Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
23 changes: 23 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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("<img src=\"cid:header-logo\"><h1>Hello!</h1>")
.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -13,6 +14,9 @@ public record EmailAttachmentDto(

String contentType,

@Nullable
String contentId,

long fileReference
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public record EmailDto(
List<String> recipients,

String textBody,

@Nullable
String htmlBody,

Set<EmailAttachmentDto> attachments,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -28,6 +34,8 @@ public record Email(
List<String> recipients,

String textBody,

@Nullable
String htmlBody,

@NotBlank
Expand All @@ -41,15 +49,58 @@ public record Email(
String replyToName,

@Singular
@Valid
Set<Attachment> 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) {
Comment thread
J0nasMayr marked this conversation as resolved.
return false;
}

var referencedContentIds = new HashSet<String>();
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<String> 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
) {
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -183,6 +184,7 @@ private void sendMail(
String replyToName,
List<String> recipients,
String subject,
@Nullable
String htmlBody,
String plainTextBody,
Set<EmailAttachment> attachments
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,7 @@ public class EmailAttachment {

private String contentType;

private String contentId;

private long fileReference;
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public record EmailContent(
List<String> recipients,

String textBody,

@Nullable
String htmlBody
) {
}
Loading