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 ""}. + * + * @return the header formatted content id, never {@code null} + */ + @NonNull + public String toHeaderValue() { + return "<" + value + ">"; + } +} + diff --git a/email/src/main/java/io/micronaut/email/Email.java b/email/src/main/java/io/micronaut/email/Email.java index 553ae142e..8dcb63d85 100644 --- a/email/src/main/java/io/micronaut/email/Email.java +++ b/email/src/main/java/io/micronaut/email/Email.java @@ -412,6 +412,45 @@ public Email.Builder attachment(@NonNull Consumer attachment attachment.accept(builder); return attachment(builder.build()); } + /** + * Attach a FileAttachment in a type-safe way, mapped to Attachment internally. + * @param fileAttachment FileAttachment to attach + * @return Email Builder + */ + @NonNull + public Email.Builder attachment(@NonNull FileAttachment fileAttachment) { + if (fileAttachment == null) { + throw new IllegalArgumentException("fileAttachment cannot be null"); + } + Attachment attachment = new Attachment( + fileAttachment.getFilename(), + fileAttachment.getContentType(), + fileAttachment.getContent(), + null, + null + ); + return attachment(attachment); + } + + /** + * Attach an InlineAttachment in a type-safe way, mapped to Attachment internally. + * @param inlineAttachment InlineAttachment to attach + * @return Email Builder + */ + @NonNull + public Email.Builder attachment(@NonNull InlineAttachment inlineAttachment) { + if (inlineAttachment == null) { + throw new IllegalArgumentException("inlineAttachment cannot be null"); + } + Attachment attachment = new Attachment( + inlineAttachment.getFilename(), + inlineAttachment.getContentType(), + inlineAttachment.getContent(), + inlineAttachment.getContentId() != null ? inlineAttachment.getContentId().value() : null, + "inline" + ); + return attachment(attachment); + } /** * diff --git a/email/src/main/java/io/micronaut/email/EmailAttachment.java b/email/src/main/java/io/micronaut/email/EmailAttachment.java new file mode 100644 index 000000000..5e03f7eca --- /dev/null +++ b/email/src/main/java/io/micronaut/email/EmailAttachment.java @@ -0,0 +1,55 @@ +/* + * 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; + +/** + * Strongly typed email attachment. Use {@link FileAttachment} for regular files, + * or {@link InlineAttachment} for embedded inline content. + * + * @author Vinit Shinde + * @since 3.0.0 + */ +public sealed interface EmailAttachment + permits FileAttachment, InlineAttachment { + + /** + * The filename as shown in the client's download/save dialogs. + * + * @return the filename + */ + @NonNull + String getFilename(); + + /** + * The attachment's content as bytes. + * + * @return the content bytes + */ + @NonNull + byte[] getContent(); + + /** + * The MIME type for the content. + * + * @return the MIME type + */ + @NonNull + String getContentType(); +} + diff --git a/email/src/main/java/io/micronaut/email/FileAttachment.java b/email/src/main/java/io/micronaut/email/FileAttachment.java new file mode 100644 index 000000000..1e4f280a5 --- /dev/null +++ b/email/src/main/java/io/micronaut/email/FileAttachment.java @@ -0,0 +1,216 @@ +/* + * 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 a downloadable file attachment for an email. + * + * Instances are immutable and should be created via {@link Builder}. + * + * @author Vinit Shinde + * @since 3.0.0 + */ +@Introspected +public final class FileAttachment implements EmailAttachment { + + @NonNull + @NotBlank + private final String filename; + + @NonNull + @NotNull + private final byte[] content; + + @NonNull + @NotBlank + private final String contentType; + + /** + * Internal constructor. Use {@link Builder} to create instances. + * + * @param filename the attachment filename + * @param contentType the MIME content type + * @param content the binary content + */ + private FileAttachment( + @NonNull String filename, + @NonNull String contentType, + @NonNull byte[] content) { + this.filename = filename; + this.contentType = contentType; + this.content = content; + } + + /** + * Creates a new builder for {@link FileAttachment}. + * + * @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; + } + + /** + * FileAttachment builder for fluent construction. + * + *

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() == "" + } + + void "test ContentId object method still works"() { + when: + def inline = InlineAttachment.builder() + .filename("logo.png") + .contentType("image/png") + .content("IMG".bytes) + .contentId(new ContentId("object-id")) // Object method + .build() + + then: + inline.contentId.value == "object-id" + } + + void "test ContentId equality and hashCode work correctly"() { + given: + def id1 = new ContentId("test") + def id2 = new ContentId("test") + def id3 = new ContentId("different") + + expect: + id1 == id2 + id1 != id3 + id1.hashCode() == id2.hashCode() + } + + void "test ContentId formats header value correctly"() { + when: + def cid = new ContentId("cid123") + + then: + cid.value == "cid123" + cid.toHeaderValue() == "" + } + + @Unroll + void "test toHeaderValue formats various content id values correctly: #desc"() { + when: + def cid = new ContentId(inputValue) + + then: + cid.toHeaderValue() == expectedHeaderValue + + where: + desc | inputValue || expectedHeaderValue + "simple alphanumeric" | "img-1" || "" + "with dots" | "attachment.123.xyz" || "" + "with underscores" | "image_file_001" || "" + "email-like format" | "logo@example.com" || "" + "uuid format" | "550e8400-e29b-41d4-a716" || "<550e8400-e29b-41d4-a716>" + "single character" | "x" || "" + "already has angle brackets" | "" || "<>" + } + + void "test toHeaderValue returns non-null value"() { + given: + def cid = new ContentId("test-id") + + when: + def headerValue = cid.toHeaderValue() + + then: + headerValue != null + headerValue instanceof String + } + + void "test ContentId rejects null value"() { + when: + new ContentId(null) + + then: + thrown(NullPointerException) + } + + // ATTACHMENT ISINLINE TESTS + + void "test Attachment isInline returns true for inline disposition"() { + given: + def attachment = new Attachment( + "logo.png", + "image/png", + "DATA".bytes, + "cid-123", + "inline" + ) + + expect: + attachment.isInline() + } + + void "test Attachment isInline returns false for null disposition"() { + given: + def attachment = new Attachment( + "document.pdf", + "application/pdf", + "DATA".bytes, + null, + null + ) + + expect: + !attachment.isInline() + } + + void "test Attachment isInline returns false for attachment disposition"() { + given: + def attachment = new Attachment( + "report.pdf", + "application/pdf", + "DATA".bytes, + null, + "attachment" + ) + + expect: + !attachment.isInline() + } + + @Unroll + void "test Attachment isInline with various dispositions: #desc"() { + given: + def attachment = new Attachment( + "file.txt", + "text/plain", + "DATA".bytes, + null, + disposition + ) + + expect: + attachment.isInline() == expectedIsInline + + where: + desc | disposition || expectedIsInline + "exactly inline" | "inline" || true + "null disposition" | null || false + "attachment disposition" | "attachment" || false + "empty string" | "" || false + "INLINE uppercase" | "INLINE" || false + "Inline mixed case" | "Inline" || false + "inline with spaces" | " inline " || false + "form-data" | "form-data" || false + } + + void "test InlineAttachment created attachment isInline is true"() { + given: + def inline = InlineAttachment.builder() + .filename("image.png") + .contentType("image/png") + .content("IMG".bytes) + .contentId("test-cid") + .build() + + when: + def email = Email.builder() + .from("sender@example.com") + .to("test@example.com") + .subject("Test") + .body("Body") + .attachment(inline) + .build() + + then: + email.attachments.size() == 1 + email.attachments[0].isInline() + } + + void "test FileAttachment created attachment isInline is false"() { + given: + def file = FileAttachment.builder() + .filename("document.pdf") + .contentType("application/pdf") + .content("PDF".bytes) + .build() + + when: + def email = Email.builder() + .from("sender@example.com") + .to("test@example.com") + .subject("Test") + .body("Body") + .attachment(file) + .build() + + then: + email.attachments.size() == 1 + !email.attachments[0].isInline() + } + + // BACKWARD COMPATIBILITY TESTS + + void "test legacy Attachment API still works"() { + given: + Attachment legacy = new Attachment("old.pdf", "application/pdf", "DATA".bytes, null, "attachment") + + when: + Email email = Email.builder() + .from("sender@example.com") + .to("test@example.com") + .subject("Test") + .body("Body") + .attachment(legacy) + .build() + + then: + email.attachments.size() == 1 + email.attachments[0].filename == "old.pdf" + } + + void "test mixing new FileAttachment and legacy Attachment"() { + given: + FileAttachment newFile = FileAttachment.builder() + .filename("new.pdf") + .contentType("application/pdf") + .content("NEW".bytes) + .build() + + Attachment legacy = new Attachment("old.doc", "application/msword", "OLD".bytes, null, null) + + when: + Email email = Email.builder() + .from("sender@example.com") + .to("test@example.com") + .subject("Test") + .body("Body") + .attachment(newFile) + .attachment(legacy) + .build() + + then: + email.attachments.size() == 2 + email.attachments*.filename == ["new.pdf", "old.doc"] + } + + // BUILDER VALIDATION TESTS + + void "test FileAttachment builder enforces required fields"() { + when: + FileAttachment.builder() + .filename("test.txt") + .contentType("text/plain") + .build() // Missing content + + then: + thrown(NullPointerException) + } + + void "test InlineAttachment builder enforces ContentId requirement"() { + when: + InlineAttachment.builder() + .filename("test.png") + .contentType("image/png") + .content("data".bytes) + .build() // Missing contentId + + then: + thrown(NullPointerException) + } + + void "test InlineAttachment builder rejects null String contentId"() { + when: + InlineAttachment.builder() + .filename("test.png") + .contentType("image/png") + .content("data".bytes) + .contentId((String) null) + .build() + + then: + thrown(NullPointerException) + } + + // ERROR HANDLING TESTS + + void "test Email builder rejects null FileAttachment"() { + when: + Email.builder() + .from("sender@example.com") + .to("test@example.com") + .subject("Test") + .body("Body") + .attachment((FileAttachment) null) + .build() + + then: + thrown(IllegalArgumentException) + } + + void "test Email builder rejects null InlineAttachment"() { + when: + Email.builder() + .from("sender@example.com") + .to("test@example.com") + .subject("Test") + .body("Body") + .attachment((InlineAttachment) null) + .build() + + then: + thrown(IllegalArgumentException) + } + + // HELPER METHODS + + private static FileAttachment file(String name) { + return FileAttachment.builder() + .filename(name) + .contentType("text/plain") + .content("test".bytes) + .build() + } + + private static InlineAttachment inline(String name, String cid) { + return InlineAttachment.builder() + .filename(name) + .contentType("image/png") + .content("test".bytes) + .contentId(cid) + .build() + } +}