Skip to content

Commit 6228391

Browse files
authored
add inline attachment support (#14)
* add inline attachment support * improve validation logic for inline attachments * set more information for both attachment types and update readme and tests * mark htmlBody nullable and add check
1 parent d4c2f2d commit 6228391

9 files changed

Lines changed: 307 additions & 32 deletions

File tree

readme.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,29 @@ Add the mailer service to the classpath by adding the following maven dependency
2020
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)
2121
This step is optional.
2222

23+
#### Inline (CID) attachments
24+
25+
To embed an attachment inline, set a `contentId` on the attachment and reference it in the `htmlBody` via the `cid:` scheme.
26+
This is the equivalent of `MimeMessageHelper.addInline(...)` and renders in all major email clients.
27+
28+
```java
29+
// @formatter:off
30+
EmailParameter.Email.builder()
31+
// ...
32+
.htmlBody("<img src=\"cid:header-logo\"><h1>Hello!</h1>")
33+
.attachment(EmailParameter.Email.Attachment.builder()
34+
.contentId("header-logo")
35+
.fileName("logo.png")
36+
.contentType("image/png")
37+
.payload(new ClassPathResource("/templates/mail/images/logo.png").getInputStream())
38+
.build())
39+
.build();
40+
// @formatter:on
41+
```
42+
43+
Attachments without a `contentId` are added as regular attachments. Each `contentId` must be unique and referenced
44+
in the `htmlBody` as `cid:contentId`, otherwise validation fails.
45+
2346
## Usage
2447

2548
### Sending an Email

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import it.aboutbits.springboot.emailservice.lib.model.Email;
44
import org.jspecify.annotations.NullMarked;
5+
import org.jspecify.annotations.Nullable;
56

67
@NullMarked
78
public record EmailAttachmentDto(
@@ -13,6 +14,9 @@ public record EmailAttachmentDto(
1314

1415
String contentType,
1516

17+
@Nullable
18+
String contentId,
19+
1620
long fileReference
1721
) {
1822
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public record EmailDto(
2626
List<String> recipients,
2727

2828
String textBody,
29+
30+
@Nullable
2931
String htmlBody,
3032

3133
Set<EmailAttachmentDto> attachments,

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package it.aboutbits.springboot.emailservice.lib.application;
22

3+
import jakarta.validation.Valid;
4+
import jakarta.validation.constraints.AssertTrue;
35
import jakarta.validation.constraints.NotBlank;
46
import jakarta.validation.constraints.NotEmpty;
57
import lombok.Builder;
@@ -9,13 +11,17 @@
911

1012
import java.io.InputStream;
1113
import java.time.OffsetDateTime;
14+
import java.util.HashSet;
1215
import java.util.List;
16+
import java.util.Objects;
1317
import java.util.Set;
18+
import java.util.regex.Pattern;
1419

1520
@Builder
1621
@NullMarked
1722
public record EmailParameter(
1823
OffsetDateTime scheduledAt,
24+
@Valid
1925
Email email
2026
) {
2127
@Builder
@@ -28,6 +34,8 @@ public record Email(
2834
List<String> recipients,
2935

3036
String textBody,
37+
38+
@Nullable
3139
String htmlBody,
3240

3341
@NotBlank
@@ -41,15 +49,58 @@ public record Email(
4149
String replyToName,
4250

4351
@Singular
52+
@Valid
4453
Set<Attachment> attachments
4554
) {
55+
// the "cid:" scheme is case-insensitive, the contentId itself is not
56+
private static final Pattern CID_REFERENCE_PATTERN = Pattern.compile(
57+
"cid:([^\\s\"'<>]+)",
58+
Pattern.CASE_INSENSITIVE
59+
);
60+
61+
@AssertTrue(message = "each inline attachment contentId must be referenced in the htmlBody as cid:contentId")
62+
public boolean isInlineAttachmentsValid() {
63+
var contentIds = inlineContentIds();
64+
if (contentIds.isEmpty()) {
65+
return true;
66+
}
67+
68+
if (htmlBody == null) {
69+
return false;
70+
}
71+
72+
var referencedContentIds = new HashSet<String>();
73+
var matcher = CID_REFERENCE_PATTERN.matcher(htmlBody);
74+
while (matcher.find()) {
75+
referencedContentIds.add(matcher.group(1));
76+
}
77+
78+
return referencedContentIds.containsAll(contentIds);
79+
}
80+
81+
@AssertTrue(message = "each inline attachment contentId must be unique")
82+
public boolean isInlineAttachmentContentIdsUnique() {
83+
var contentIds = inlineContentIds();
84+
return contentIds.size() == new HashSet<>(contentIds).size();
85+
}
86+
87+
private List<String> inlineContentIds() {
88+
return attachments.stream()
89+
.map(Attachment::contentId)
90+
.filter(Objects::nonNull)
91+
.toList();
92+
}
93+
4694
@Builder
4795
public record Attachment(
4896
InputStream payload,
4997
@NotBlank
5098
String fileName,
5199
@NotBlank
52-
String contentType
100+
String contentType,
101+
// if set, the attachment is embedded inline and can be referenced in the htmlBody as "cid:contentId"
102+
@Nullable
103+
String contentId
53104
) {
54105
}
55106
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ updated_at timestamp with time zone default now() not null,
7676
7777
alter table email_service_emails add column if not exists cleanup_start_time timestamp with time zone;
7878
79+
alter table email_service_email_attachments add column if not exists content_id text;
80+
7981
update email_service_emails
8082
set execution_end_time = sent_at
8183
where sent_at is not null

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.springframework.transaction.TransactionDefinition;
2323
import org.springframework.transaction.annotation.Transactional;
2424
import org.springframework.transaction.support.TransactionTemplate;
25+
import org.springframework.util.FileCopyUtils;
2526
import org.springframework.validation.annotation.Validated;
2627

2728
import java.io.IOException;
@@ -183,6 +184,7 @@ private void sendMail(
183184
String replyToName,
184185
List<String> recipients,
185186
String subject,
187+
@Nullable
186188
String htmlBody,
187189
String plainTextBody,
188190
Set<EmailAttachment> attachments
@@ -202,16 +204,23 @@ private void sendMail(
202204
}
203205
}
204206

205-
if (!htmlBody.isBlank()) {
207+
if (htmlBody != null && !htmlBody.isBlank()) {
206208
helper.setText(plainTextBody, htmlBody);
207209
} else {
208210
helper.setText(plainTextBody);
209211
}
210212

211213
for (var attachment : attachments) {
212-
var payload = attachmentDataSource.getAttachmentPayload(attachment.getFileReference());
213-
helper.addAttachment(attachment.getFileName(), new ByteArrayResource(payload.readAllBytes()));
214-
payload.close();
214+
var resource = new ByteArrayResource(FileCopyUtils.copyToByteArray(
215+
attachmentDataSource.getAttachmentPayload(attachment.getFileReference())
216+
));
217+
218+
var contentId = attachment.getContentId();
219+
if (contentId != null) {
220+
helper.addInline(contentId, attachment.getFileName(), resource, attachment.getContentType());
221+
} else {
222+
helper.addAttachment(attachment.getFileName(), resource, attachment.getContentType());
223+
}
215224
}
216225

217226
mailSender.send(message);
@@ -241,6 +250,7 @@ private Email fromParameter(EmailParameter parameter) throws AttachmentException
241250
var emailAttachment = new EmailAttachment();
242251
emailAttachment.setEmail(email);
243252
emailAttachment.setContentType(attachment.contentType());
253+
emailAttachment.setContentId(attachment.contentId());
244254
emailAttachment.setFileName(attachment.fileName());
245255
emailAttachment.setFileReference(reference);
246256

src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailAttachment.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,7 @@ public class EmailAttachment {
2929

3030
private String contentType;
3131

32+
private String contentId;
33+
3234
private long fileReference;
3335
}

src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailContent.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public record EmailContent(
2525
List<String> recipients,
2626

2727
String textBody,
28+
29+
@Nullable
2830
String htmlBody
2931
) {
3032
}

0 commit comments

Comments
 (0)