diff --git a/email/src/main/java/io/micronaut/email/Attachment.java b/email/src/main/java/io/micronaut/email/Attachment.java index 25f0aeb7c..d859c2d5b 100644 --- a/email/src/main/java/io/micronaut/email/Attachment.java +++ b/email/src/main/java/io/micronaut/email/Attachment.java @@ -15,11 +15,9 @@ */ package io.micronaut.email; -import io.micronaut.core.annotation.Creator; import io.micronaut.core.annotation.Introspected; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; - +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.io.DataInputStream; @@ -56,6 +54,8 @@ public class Attachment { @Nullable private final String disposition; + public static final String INLINE = "inline"; + /** * * @param filename filename to show up in email @@ -131,6 +131,16 @@ public String getId() { public String getDisposition() { return this.disposition; } + + /** + * Checks whether this attachment should be treated as inline. + * + * @return {@code true} if the disposition is {@code "inline"} + * @since 3.0.0 + */ + public boolean isInline() { + return INLINE.equals(disposition); + } /** * Attachment's builder. diff --git a/email/src/main/java/io/micronaut/email/ContentId.java b/email/src/main/java/io/micronaut/email/ContentId.java new file mode 100644 index 000000000..fc6c163c1 --- /dev/null +++ b/email/src/main/java/io/micronaut/email/ContentId.java @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2025 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.micronaut.email; + +import io.micronaut.core.annotation.NonNull; + +import java.util.Objects; + +/** + * Value type for an RFC 2392 Content-ID, used for inline email attachments. + * + * Instances represent the identifier part of a Content-ID header (without + * surrounding angle brackets). Use {@link #toHeaderValue()} to obtain a header-safe + * representation (with < and >). + * + * @since 3.0.0 + */ +public record ContentId(@NonNull String value) { + + /** + * Constructs a new {@code ContentId}. + * + *
The {@code value} must not be {@code null}. A {@link NullPointerException} + * is thrown otherwise. This enforces the requirement that inline email + * attachments define a non-null Content-ID.
+ * + * @throws NullPointerException if {@code value} is {@code null} + */ + public ContentId { + Objects.requireNonNull(value, "ContentId value must not be null"); + } + + /** + * Returns the formatted header value suitable for use in a Content-ID header. + * + * For example, a value of {@code "img-1"} will be returned as {@code "Example: + *
+ * FileAttachment.builder()
+ * .filename("report.pdf")
+ * .contentType("application/pdf")
+ * .content(file)
+ * .build();
+ *
+ *
+ * @author Vinit Shinde
+ */
+ public static class Builder {
+ private String filename;
+ private String contentType;
+ private byte[] content;
+
+ /**
+ * Set the filename to be used when the attachment is downloaded.
+ *
+ * @param filename the download filename, must not be null
+ * @return this builder
+ */
+ @NonNull
+ public Builder filename(@NonNull String filename) {
+ this.filename = filename;
+ return this;
+ }
+
+ /**
+ * Set the MIME content type for this attachment.
+ *
+ * @param contentType the MIME type, must not be null
+ * @return this builder
+ */
+ @NonNull
+ public Builder contentType(@NonNull String contentType) {
+ this.contentType = contentType;
+ return this;
+ }
+
+ /**
+ * Set the attachment content as a byte array.
+ *
+ * @param content the content bytes, must not be null
+ * @return this builder
+ */
+ @NonNull
+ public Builder content(@NonNull byte[] content) {
+ this.content = content;
+ return this;
+ }
+
+ /**
+ * Read the content from a {@link File} and set it on the builder.
+ *
+ * @param file the file to read, must not be null
+ * @return this builder
+ * @throws IllegalArgumentException if the file cannot be read
+ */
+ @NonNull
+ public Builder content(@NonNull File file) {
+ try (DataInputStream dis = new DataInputStream(new FileInputStream(file))) {
+ byte[] bytes = new byte[(int) file.length()];
+ dis.readFully(bytes);
+ return content(bytes);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Could not read file for attachment", e);
+ }
+ }
+
+ /**
+ * Read the content from an {@link InputStream} and set it on the builder.
+ *
+ * @param inputStream the input stream to read, must not be null
+ * @return this builder
+ * @throws IllegalArgumentException if the stream cannot be read
+ */
+ @NonNull
+ public Builder content(@NonNull InputStream inputStream) {
+ try {
+ return content(inputStream.readAllBytes());
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Could not read input stream for attachment", e);
+ }
+ }
+
+ /**
+ * Build the immutable {@link FileAttachment} instance.
+ *
+ * @return a new FileAttachment
+ * @throws NullPointerException if any required property is missing
+ */
+ @NonNull
+ public FileAttachment build() {
+ return new FileAttachment(
+ Objects.requireNonNull(filename, "filename required"),
+ Objects.requireNonNull(contentType, "contentType required"),
+ Objects.requireNonNull(content, "content required")
+ );
+ }
+ }
+}
diff --git a/email/src/main/java/io/micronaut/email/InlineAttachment.java b/email/src/main/java/io/micronaut/email/InlineAttachment.java
new file mode 100644
index 000000000..354fcf767
--- /dev/null
+++ b/email/src/main/java/io/micronaut/email/InlineAttachment.java
@@ -0,0 +1,249 @@
+/*
+ * Copyright 2017-2025 original authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.micronaut.email;
+
+import io.micronaut.core.annotation.Introspected;
+import io.micronaut.core.annotation.NonNull;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.util.Objects;
+
+/**
+ * Represents an inline attachment for HTML emails. Use {@link ContentId} for CID URLs.
+ *
+ * @author Vinit Shinde
+ * @since 3.0.0
+ */
+@Introspected
+public final class InlineAttachment implements EmailAttachment {
+
+ @NonNull
+ @NotBlank
+ private final String filename;
+
+ @NonNull
+ @NotNull
+ private final byte[] content;
+
+ @NonNull
+ @NotBlank
+ private final String contentType;
+
+ @NonNull
+ private final ContentId contentId;
+
+ /**
+ * Creates a new {@link InlineAttachment} for embedding content within an HTML email.
+ * Typically used via the builder API.
+ *
+ * @param filename the name of the file to display in the email client.
+ * @param contentType the MIME content type of the attachment (e.g. "image/png").
+ * @param content the attachment data as a byte array.
+ * @param contentId a unique content ID for referencing in HTML content ("cid:" URLs).
+ */
+ private InlineAttachment(
+ @NonNull String filename,
+ @NonNull String contentType,
+ @NonNull byte[] content,
+ @NonNull ContentId contentId
+ ) {
+ this.filename = filename;
+ this.contentType = contentType;
+ this.content = content;
+ this.contentId = contentId;
+ }
+
+ /**
+ * Creates a builder for {@link InlineAttachment}.
+ *
+ * @return The builder instance.
+ */
+ @NonNull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Returns the filename to show up in the email.
+ *
+ * @return filename to show up in email
+ */
+ @Override
+ @NonNull
+ public String getFilename() {
+ return filename;
+ }
+
+ /**
+ * Returns the file content as bytes.
+ *
+ * @return file content bytes
+ */
+ @Override
+ @NonNull
+ public byte[] getContent() {
+ return content;
+ }
+
+ /**
+ * Returns the MIME content type for the file.
+ *
+ * @return file content type
+ */
+ @Override
+ @NonNull
+ public String getContentType() {
+ return contentType;
+ }
+
+ /**
+ * Returns the RFC 2392 content ID (for 'cid:' URL in HTML).
+ *
+ * @return the ContentId used for inline references
+ */
+ @NonNull
+ public ContentId getContentId() {
+ return contentId;
+ }
+
+
+ /**
+ * Builder for {@link InlineAttachment}.
+ *
+ * Example:
+ *
+ * InlineAttachment.builder()
+ * .filename("logo.png")
+ * .contentType("image/png")
+ * .content(imageBytes)
+ * .contentId("logo123")
+ * .build();
+ *
+ *
+ * @author Vinit Shinde
+ */
+ public static class Builder {
+ private String filename;
+ private String contentType;
+ private byte[] content;
+ private ContentId contentId;
+
+ /**
+ *
+ * @param filename filename to show up in email
+ * @return InlineAttachment's builder
+ */
+ @NonNull
+ public Builder filename(@NonNull String filename) {
+ this.filename = filename;
+ return this;
+ }
+
+ /**
+ *
+ * @param contentType file content type
+ * @return InlineAttachment's builder
+ */
+ @NonNull
+ public Builder contentType(@NonNull String contentType) {
+ this.contentType = contentType;
+ return this;
+ }
+
+ /**
+ *
+ * @param content file content
+ * @return Attachment's builder
+ */
+ @NonNull
+ public Builder content(@NonNull byte[] content) {
+ this.content = content;
+ return this;
+ }
+
+ /**
+ *
+ * @param file file
+ * @return InlineAttachment's builder
+ */
+ @NonNull
+ public Builder content(@NonNull File file) {
+ try (DataInputStream dis = new DataInputStream(new FileInputStream(file))) {
+ byte[] bytes = new byte[(int) file.length()];
+ dis.readFully(bytes);
+ return content(bytes);
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Could not read file for inline attachment", e);
+ }
+ }
+
+ /**
+ *
+ * @param inputStream Content's inputStream
+ * @return InlineAttachment's builder
+ */
+ @NonNull
+ public Builder content(@NonNull InputStream inputStream) {
+ try {
+ return content(inputStream.readAllBytes());
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Could not read input stream for inline attachment", e);
+ }
+ }
+
+ /**
+ * Sets the content ID.
+ * @param contentId the ContentId object
+ * @return this builder
+ */
+ @NonNull
+ public Builder contentId(@NonNull ContentId contentId) {
+ this.contentId = contentId;
+ return this;
+ }
+
+ /**
+ * Sets the content ID from a string value (convenience method).
+ * @param contentId the identifier value (must be unique per email)
+ * @return this builder
+ */
+ @NonNull
+ public Builder contentId(@NonNull String contentId) {
+ this.contentId = new ContentId(contentId);
+ return this;
+ }
+
+ /**
+ *
+ * @return an InlineAttachment.
+ */
+ @NonNull
+ public InlineAttachment build() {
+ return new InlineAttachment(
+ Objects.requireNonNull(filename, "filename required"),
+ Objects.requireNonNull(contentType, "contentType required"),
+ Objects.requireNonNull(content, "content required"),
+ Objects.requireNonNull(contentId, "contentId required")
+ );
+ }
+ }
+}
diff --git a/email/src/test/groovy/io/micronaut/email/EmailAttachmentSpec.groovy b/email/src/test/groovy/io/micronaut/email/EmailAttachmentSpec.groovy
new file mode 100644
index 000000000..6d72cf3f7
--- /dev/null
+++ b/email/src/test/groovy/io/micronaut/email/EmailAttachmentSpec.groovy
@@ -0,0 +1,436 @@
+package io.micronaut.email
+
+import spock.lang.Specification
+import spock.lang.Unroll
+
+class EmailAttachmentSpec extends Specification {
+
+ // CONVERSION TESTS: FileAttachment/InlineAttachment → Attachment
+
+ void "test FileAttachment builder maps correctly to internal legacy Attachment"() {
+ given: "A file attachment created via Builder"
+ byte[] content = "PDF-DATA".bytes
+ FileAttachment fileAtt = FileAttachment.builder()
+ .filename("doc.pdf")
+ .contentType("application/pdf")
+ .content(content)
+ .build()
+
+ when: "It is added to the Email Builder"
+ Email email = Email.builder()
+ .from("sender@example.com")
+ .to("test@example.com")
+ .subject("Test")
+ .body("Body")
+ .attachment(fileAtt)
+ .build()
+
+ then: "The internal list contains the legacy Attachment mapped correctly"
+ email.attachments.size() == 1
+ with(email.attachments[0]) {
+ filename == "doc.pdf"
+ contentType == "application/pdf"
+ content == content
+ id == null // Files have no ID
+ disposition == null // Files default to null
+ }
+ }
+
+ void "test InlineAttachment builder maps correctly to internal legacy Attachment"() {
+ given: "An inline attachment created via Builder"
+ byte[] content = "IMG-DATA".bytes
+ InlineAttachment inlineAtt = InlineAttachment.builder()
+ .filename("logo.png")
+ .contentType("image/png")
+ .content(content)
+ .contentId("logo123") // Using String convenience method
+ .build()
+
+ when: "It is added to the Email Builder"
+ Email email = Email.builder()
+ .from("sender@example.com")
+ .to("test@example.com")
+ .subject("Test")
+ .body("Body")
+ .attachment(inlineAtt)
+ .build()
+
+ then: "The internal list contains the legacy Attachment mapped correctly"
+ email.attachments.size() == 1
+ with(email.attachments[0]) {
+ filename == "logo.png"
+ contentType == "image/png"
+ content == content
+ id == "logo123" // ID is preserved
+ disposition == "inline" // Disposition is inline
+ }
+ }
+
+ // DATA-DRIVEN TESTS: Ordering and Type Mixing
+
+ @Unroll
+ void "test mixing multiple attachments preserves order: #desc"() {
+ when: "We add multiple attachments to the builder"
+ def emailBuilder = Email.builder()
+ .from("sender@example.com")
+ .to("test@example.com")
+ .subject("Test")
+ .body("Body")
+
+ attachments.each { att ->
+ if (att instanceof FileAttachment) emailBuilder.attachment((FileAttachment) att)
+ if (att instanceof InlineAttachment) emailBuilder.attachment((InlineAttachment) att)
+ }
+
+ def email = emailBuilder.build()
+
+ then: "The size, filenames, and dispositions match exactly"
+ email.attachments.size() == expectedFilenames.size()
+ email.attachments*.filename == expectedFilenames
+ email.attachments*.disposition == expectedDispositions
+
+ where:
+ desc | attachments || expectedFilenames | expectedDispositions
+ "Two Files" | [file("a.txt"), file("b.txt")] || ["a.txt", "b.txt"] | [null, null]
+ "Two Inline" | [inline("a.png", "1"), inline("b.png", "2")] || ["a.png", "b.png"] | ["inline", "inline"]
+ "Mixed (File First)" | [file("doc.pdf"), inline("sig.png", "cid1")] || ["doc.pdf", "sig.png"] | [null, "inline"]
+ "Mixed (Inline First)"| [inline("header.jpg", "cid2"), file("terms.txt")]|| ["header.jpg", "terms.txt"] | ["inline", null]
+ "Sandwich" | [inline("top.png", "1"), file("mid.txt"), inline("bot.png", "2")] || ["top.png", "mid.txt", "bot.png"] | ["inline", null, "inline"]
+ }
+
+ // CONTENTID TESTS (New Feature)
+
+ void "test ContentId String convenience method"() {
+ when:
+ def inline = InlineAttachment.builder()
+ .filename("logo.png")
+ .contentType("image/png")
+ .content("IMG".bytes)
+ .contentId("simple-string") // String method
+ .build()
+
+ then:
+ inline.contentId.value == "simple-string"
+ inline.contentId.toHeaderValue() == "