This is an automated email from the ASF dual-hosted git repository.

pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 3e355a3888a NIFI-16195 - ExtractEmailAttachments - Wrote part headers 
into attributes (#11631)
3e355a3888a is described below

commit 3e355a3888a8b33fb1aa15d2d90838d1171abf01
Author: dan-s1 <[email protected]>
AuthorDate: Mon Sep 7 05:40:47 2026 -0400

    NIFI-16195 - ExtractEmailAttachments - Wrote part headers into attributes 
(#11631)
---
 .../processors/email/ExtractEmailAttachments.java  |  39 +++++-
 .../email/TestExtractEmailAttachments.java         | 152 ++++++++++++++++++---
 2 files changed, 168 insertions(+), 23 deletions(-)

diff --git 
a/nifi-extension-bundles/nifi-email-bundle/nifi-email-processors/src/main/java/org/apache/nifi/processors/email/ExtractEmailAttachments.java
 
b/nifi-extension-bundles/nifi-email-bundle/nifi-email-processors/src/main/java/org/apache/nifi/processors/email/ExtractEmailAttachments.java
index c2763df5c0c..6ea2f6505cd 100644
--- 
a/nifi-extension-bundles/nifi-email-bundle/nifi-email-processors/src/main/java/org/apache/nifi/processors/email/ExtractEmailAttachments.java
+++ 
b/nifi-extension-bundles/nifi-email-bundle/nifi-email-processors/src/main/java/org/apache/nifi/processors/email/ExtractEmailAttachments.java
@@ -19,6 +19,7 @@ package org.apache.nifi.processors.email;
 import jakarta.activation.DataSource;
 import jakarta.mail.Address;
 import jakarta.mail.BodyPart;
+import jakarta.mail.Header;
 import jakarta.mail.MessagingException;
 import jakarta.mail.Multipart;
 import jakarta.mail.Session;
@@ -46,6 +47,7 @@ import java.io.BufferedInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
+import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -61,7 +63,8 @@ import java.util.Set;
         @WritesAttribute(attribute = "filename ", description = "The filename 
of the attachment"),
         @WritesAttribute(attribute = "email.attachment.parent.filename ", 
description = "The filename of the parent FlowFile"),
         @WritesAttribute(attribute = "email.attachment.parent.uuid", 
description = "The UUID of the original FlowFile."),
-        @WritesAttribute(attribute = "mime.type", description = "The mime type 
of the attachment.")})
+        @WritesAttribute(attribute = "mime.type", description = "The mime type 
of the attachment."),
+        @WritesAttribute(attribute = 
ExtractEmailAttachments.ATTACHMENT_HEADER_ATTRIBUTE_PREFIX + "<attachment 
header name>", description = "Attachment header.")})
 
 public class ExtractEmailAttachments extends AbstractProcessor {
     public static final String ATTACHMENT_ORIGINAL_FILENAME = 
"email.attachment.parent.filename";
@@ -80,6 +83,7 @@ public class ExtractEmailAttachments extends 
AbstractProcessor {
             .description("FlowFiles that could not be parsed")
             .build();
 
+    static final String ATTACHMENT_HEADER_ATTRIBUTE_PREFIX = 
"email.attachment.header.";
     private static final String ATTACHMENT_DISPOSITION = "attachment";
 
     private static final Set<Relationship> RELATIONSHIPS = Set.of(
@@ -117,12 +121,13 @@ public class ExtractEmailAttachments extends 
AbstractProcessor {
 
                 final String originalFlowFileName = 
originalFlowFile.getAttribute(CoreAttributes.FILENAME.key());
                 try {
-                    final List<DataSource> attachments = new ArrayList<>();
+                    final List<Attachment> attachments = new ArrayList<>();
                     parseAttachments(attachments, originalMessage, 0);
 
-                    for (final DataSource data : attachments) {
+                    for (final Attachment attachment : attachments) {
                         FlowFile split = session.create(originalFlowFile);
                         final Map<String, String> attributes = new HashMap<>();
+                        final DataSource data = attachment.dataSource();
                         final String name = data.getName();
                         if (name != null && !name.isBlank()) {
                             attributes.put(CoreAttributes.FILENAME.key(), 
name);
@@ -131,6 +136,13 @@ public class ExtractEmailAttachments extends 
AbstractProcessor {
                         if (contentType != null && !contentType.isBlank()) {
                             attributes.put(CoreAttributes.MIME_TYPE.key(), 
contentType);
                         }
+
+                        for (Map.Entry<String, String> entry : 
attachment.headers().entrySet()) {
+                            final String headerAttributeName = 
ATTACHMENT_HEADER_ATTRIBUTE_PREFIX + entry.getKey().toLowerCase();
+                            final String headerAttributeValue = 
entry.getValue();
+                            attributes.put(headerAttributeName, 
headerAttributeValue);
+                        }
+
                         String parentUuid = 
originalFlowFile.getAttribute(CoreAttributes.UUID.key());
                         attributes.put(ATTACHMENT_ORIGINAL_UUID, parentUuid);
                         attributes.put(ATTACHMENT_ORIGINAL_FILENAME, 
originalFlowFileName);
@@ -176,7 +188,7 @@ public class ExtractEmailAttachments extends 
AbstractProcessor {
         return RELATIONSHIPS;
     }
 
-    private void parseAttachments(final List<DataSource> attachments, final 
MimePart parentPart, final int depth) throws MessagingException, IOException {
+    private void parseAttachments(final List<Attachment> attachments, final 
MimePart parentPart, final int depth) throws MessagingException, IOException {
         final String disposition = parentPart.getDisposition();
 
         final Object parentContent = parentPart.getContent();
@@ -191,7 +203,24 @@ public class ExtractEmailAttachments extends 
AbstractProcessor {
             }
         } else if (ATTACHMENT_DISPOSITION.equalsIgnoreCase(disposition) || 
depth > 0) {
             final DataSource dataSource = 
parentPart.getDataHandler().getDataSource();
-            attachments.add(dataSource);
+            final Map<String, String> extractedHeaders = new HashMap<>();
+
+            if (parentPart instanceof final MimeBodyPart mimeBodyPart) {
+                final Enumeration<Header> headers = 
mimeBodyPart.getAllHeaders();
+                while (headers.hasMoreElements()) {
+                    final Header header = headers.nextElement();
+                    final String name = header.getName();
+                    if (name != null && !name.isBlank()) {
+                        final String value = header.getValue();
+                        extractedHeaders.put(name, value);
+                    }
+                }
+            }
+
+            final Attachment attachment = new Attachment(dataSource, 
extractedHeaders);
+            attachments.add(attachment);
         }
     }
 }
+
+record Attachment(DataSource dataSource, Map<String, String> headers) { }
diff --git 
a/nifi-extension-bundles/nifi-email-bundle/nifi-email-processors/src/test/java/org/apache/nifi/processors/email/TestExtractEmailAttachments.java
 
b/nifi-extension-bundles/nifi-email-bundle/nifi-email-processors/src/test/java/org/apache/nifi/processors/email/TestExtractEmailAttachments.java
index 6707290353d..6c1b96292b1 100644
--- 
a/nifi-extension-bundles/nifi-email-bundle/nifi-email-processors/src/test/java/org/apache/nifi/processors/email/TestExtractEmailAttachments.java
+++ 
b/nifi-extension-bundles/nifi-email-bundle/nifi-email-processors/src/test/java/org/apache/nifi/processors/email/TestExtractEmailAttachments.java
@@ -17,18 +17,26 @@
 
 package org.apache.nifi.processors.email;
 
+import jakarta.mail.Session;
+import jakarta.mail.internet.InternetAddress;
+import jakarta.mail.internet.MimeBodyPart;
+import jakarta.mail.internet.MimeMessage;
+import jakarta.mail.internet.MimeMultipart;
 import org.apache.nifi.util.MockFlowFile;
 import org.apache.nifi.util.TestRunner;
 import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
-import java.util.ArrayList;
-import java.util.Arrays;
+import java.io.ByteArrayOutputStream;
 import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.util.Map;
+import java.util.Properties;
 
 public class TestExtractEmailAttachments {
+    private static final String EXPECTED_CONTENT_TYPE_KEY = 
ExtractEmailAttachments.ATTACHMENT_HEADER_ATTRIBUTE_PREFIX + "content-type";
+    private static final String EXPECTED_CONTENT_DISPOSITION_KEY = 
ExtractEmailAttachments.ATTACHMENT_HEADER_ATTRIBUTE_PREFIX + 
"content-disposition";
+
     final String from = "Alice <[email protected]>";
     final String to = "[email protected]";
     final String subject = "Just a test email";
@@ -37,10 +45,15 @@ public class TestExtractEmailAttachments {
 
     final GenerateAttachment attachmentGenerator = new 
GenerateAttachment(from, to, subject, message, hostName);
 
+    TestRunner runner;
+
+    @BeforeEach
+    void setUp() {
+        runner = TestRunners.newTestRunner(ExtractEmailAttachments.class);
+    }
+
     @Test
     public void testValidEmailWithAttachments() {
-        final TestRunner runner = TestRunners.newTestRunner(new 
ExtractEmailAttachments());
-
         byte[] withAttachment = attachmentGenerator.withAttachments(1);
 
         runner.enqueue(withAttachment);
@@ -51,13 +64,17 @@ public class TestExtractEmailAttachments {
         runner.assertTransferCount(ExtractEmailAttachments.REL_ATTACHMENTS, 1);
         // Have a look at the attachments...
         final List<MockFlowFile> splits = 
runner.getFlowFilesForRelationship(ExtractEmailAttachments.REL_ATTACHMENTS);
-        splits.get(0).assertAttributeEquals("filename", "pom.xml-0");
+        final MockFlowFile split = splits.getFirst();
+        split.assertAttributeEquals("filename", "pom.xml-0");
+        final Map<String, String> expected = Map.of(
+                EXPECTED_CONTENT_DISPOSITION_KEY, "attachment; 
filename=\"pom.xml-0\"",
+                EXPECTED_CONTENT_TYPE_KEY, "text/plain; charset=utf-8"
+        );
+        assertAttachmentHeaderAttributes(split, expected);
     }
 
     @Test
     public void testValidEmailWithMultipleAttachments() {
-        final TestRunner runner = TestRunners.newTestRunner(new 
ExtractEmailAttachments());
-
         int amount = 3;
         byte[] withAttachment = attachmentGenerator.withAttachments(amount);
 
@@ -69,19 +86,22 @@ public class TestExtractEmailAttachments {
         runner.assertTransferCount(ExtractEmailAttachments.REL_ATTACHMENTS, 
amount);
 
         final List<MockFlowFile> splits = 
runner.getFlowFilesForRelationship(ExtractEmailAttachments.REL_ATTACHMENTS);
-
-        List<String> filenames = new ArrayList<>();
-        for (int a = 0; a < amount; a++) {
-            filenames.add(splits.get(a).getAttribute("filename"));
+        final String expectedContentType = "text/plain; charset=utf-8";
+        final List<Map<String, String>> expectedHeaderAttachmentAttributes = 
List.of(
+                Map.of(EXPECTED_CONTENT_DISPOSITION_KEY, "attachment; 
filename=\"pom.xml-0\"", EXPECTED_CONTENT_TYPE_KEY, expectedContentType),
+                Map.of(EXPECTED_CONTENT_DISPOSITION_KEY, "attachment; 
filename=\"pom.xml-1\"", EXPECTED_CONTENT_TYPE_KEY, expectedContentType),
+                Map.of(EXPECTED_CONTENT_DISPOSITION_KEY, "attachment; 
filename=\"pom.xml-2\"", EXPECTED_CONTENT_TYPE_KEY, expectedContentType)
+        );
+
+        for (int index = 0; index < amount; index++) {
+            final MockFlowFile split = splits.get(index);
+            split.assertAttributeEquals("filename", "pom.xml-" + index);
+            assertAttachmentHeaderAttributes(split, 
expectedHeaderAttachmentAttributes.get(index));
         }
-
-        assertTrue(filenames.containsAll(Arrays.asList("pom.xml-0", 
"pom.xml-1", "pom.xml-2")));
     }
 
     @Test
     public void testValidEmailWithoutAttachments() {
-        final TestRunner runner = TestRunners.newTestRunner(new 
ExtractEmailAttachments());
-
         byte[] simpleEmail = attachmentGenerator.simpleMessage();
 
         runner.enqueue(simpleEmail);
@@ -94,7 +114,6 @@ public class TestExtractEmailAttachments {
 
     @Test
     public void testInvalidEmail() {
-        final TestRunner runner = TestRunners.newTestRunner(new 
ExtractEmailAttachments());
         runner.enqueue("test test test chocolate".getBytes());
         runner.run();
 
@@ -102,4 +121,101 @@ public class TestExtractEmailAttachments {
         runner.assertTransferCount(ExtractEmailAttachments.REL_FAILURE, 1);
         runner.assertTransferCount(ExtractEmailAttachments.REL_ATTACHMENTS, 0);
     }
+
+    @Test
+    public void testDeeplyNestedMultipartMimeMessage() throws Exception {
+        final byte[] deeplyNestedMultipartMimeMessage = 
generateDeeplyNestedMultipartMimeMessage();
+        runner.enqueue(deeplyNestedMultipartMimeMessage);
+        runner.run();
+
+        runner.assertTransferCount(ExtractEmailAttachments.REL_ORIGINAL, 1);
+        runner.assertTransferCount(ExtractEmailAttachments.REL_FAILURE, 0);
+        runner.assertTransferCount(ExtractEmailAttachments.REL_ATTACHMENTS, 4);
+
+        final List<MockFlowFile> splits = 
runner.getFlowFilesForRelationship(ExtractEmailAttachments.REL_ATTACHMENTS);
+        final String expectedContentTransferEncodingKey = 
ExtractEmailAttachments.ATTACHMENT_HEADER_ATTRIBUTE_PREFIX + 
"content-transfer-encoding";
+
+        final List<Map<String, String>> expectedHeaderAttachmentAttributes = 
List.of(
+                Map.of(expectedContentTransferEncodingKey, "quoted-printable", 
EXPECTED_CONTENT_TYPE_KEY, "text/plain; charset=iso-8859-1"),
+                Map.of(expectedContentTransferEncodingKey, "quoted-printable", 
EXPECTED_CONTENT_TYPE_KEY, "text/html; charset=iso-8859-1"),
+                Map.of(EXPECTED_CONTENT_DISPOSITION_KEY, "inline; 
filename=\"inline_image.png\"", expectedContentTransferEncodingKey, "base64",
+                        EXPECTED_CONTENT_TYPE_KEY, "image/png; 
name=inline_image.png",
+                        
ExtractEmailAttachments.ATTACHMENT_HEADER_ATTRIBUTE_PREFIX + "content-id", 
"<0011223344556677@8899AABBCCDDEEFF>"),
+                Map.of(EXPECTED_CONTENT_DISPOSITION_KEY, 
getPdfContentDisposition(), expectedContentTransferEncodingKey, "base64", 
EXPECTED_CONTENT_TYPE_KEY, "application/pdf; name=my-attachment.pdf",
+                        
ExtractEmailAttachments.ATTACHMENT_HEADER_ATTRIBUTE_PREFIX + 
"content-description", "my-attachment.pdf")
+        );
+
+        for (int index = 0; index < splits.size(); index++) {
+            MockFlowFile split = splits.get(index);
+            assertAttachmentHeaderAttributes(split, 
expectedHeaderAttachmentAttributes.get(index));
+        }
+    }
+
+    private byte[] generateDeeplyNestedMultipartMimeMessage() throws Exception 
{
+        final Properties props = new Properties();
+        final Session session = Session.getDefaultInstance(props, null);
+        final MimeMessage message = new MimeMessage(session);
+        message.setFrom(new InternetAddress("[email protected]"));
+        message.addRecipient(MimeMessage.RecipientType.TO, new 
InternetAddress("[email protected]"));
+        message.setSubject("Deeply Nested Multipart Test");
+
+        final MimeMultipart mixedMultipart = new MimeMultipart("mixed");
+        final MimeMultipart relatedMultipart = new MimeMultipart("related");
+        relatedMultipart.setSubType("related; type=\"multipart/alternative\"");
+        final MimeMultipart alternativeMultipart = new 
MimeMultipart("alternative");
+
+        final MimeBodyPart plainTextPart = new MimeBodyPart();
+        plainTextPart.setContent("Hello World! This is plain text body.", 
"text/plain; charset=iso-8859-1");
+        plainTextPart.setHeader("Content-Transfer-Encoding", 
"quoted-printable");
+        alternativeMultipart.addBodyPart(plainTextPart);
+
+        final MimeBodyPart htmlTextPart = new MimeBodyPart();
+        htmlTextPart.setContent("<html><body><h1>Hello World!</h1> This is 
HTML body.</body></html>", "text/html; charset=iso-8859-1");
+        htmlTextPart.setHeader("Content-Transfer-Encoding", 
"quoted-printable");
+        alternativeMultipart.addBodyPart(htmlTextPart);
+
+        final MimeBodyPart alternativeWrapperPart = new MimeBodyPart();
+        alternativeWrapperPart.setContent(alternativeMultipart);
+        relatedMultipart.addBodyPart(alternativeWrapperPart);
+
+        final MimeBodyPart inlineImagePart = new MimeBodyPart();
+        
inlineImagePart.setContent("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
 "image/png; name=\"inline_image.png\"");
+        inlineImagePart.setDisposition("inline; 
filename=\"inline_image.png\"");
+        inlineImagePart.setHeader("Content-ID", 
"<0011223344556677@8899AABBCCDDEEFF>");
+        inlineImagePart.setHeader("Content-Transfer-Encoding", "base64");
+        relatedMultipart.addBodyPart(inlineImagePart);
+
+        final MimeBodyPart relatedWrapperPart = new MimeBodyPart();
+        relatedWrapperPart.setContent(relatedMultipart);
+        mixedMultipart.addBodyPart(relatedWrapperPart);
+
+        final MimeBodyPart pdfAttachmentPart = new MimeBodyPart();
+        
pdfAttachmentPart.setContent("JVBERi0xLjQKJdPr6gkwChMKMSAwIG9iagogIDw8IC9UeXBlIC9DYXRhbG9n...",
 "application/pdf; name=\"my-attachment.pdf\"");
+        pdfAttachmentPart.setDescription("my-attachment.pdf");
+        pdfAttachmentPart.setDisposition("attachment; 
filename=\"my-attachment.pdf\"");
+        pdfAttachmentPart.setHeader("Content-Transfer-Encoding", "base64");
+        pdfAttachmentPart.setHeader("Content-Disposition", 
getPdfContentDisposition());
+        mixedMultipart.addBodyPart(pdfAttachmentPart);
+        message.setContent(mixedMultipart);
+
+        final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        message.writeTo(outputStream);
+
+        return outputStream.toByteArray();
+    }
+
+    private String getPdfContentDisposition() {
+        return """
+                attachment;
+                    filename="my-attachment.pdf"; size=71521;
+                    creation-date="Thu, 13 Aug 2026 11:02:50 GMT";
+                    modification-date="Thu, 13 Aug 2026 11:01:24 GMT\"""";
+    }
+
+    private void assertAttachmentHeaderAttributes(MockFlowFile split, 
Map<String, String> expected) {
+        for (Map.Entry<String, String> entry : expected.entrySet()) {
+            // Must account for jakarta.mail.internet.MimeBodyPart writing 
MIME headers using canonical CRLF (\r\n)
+            split.assertAttributeEquals(entry.getKey(), 
entry.getValue().replace("\n", "\r\n"));
+        }
+    }
 }

Reply via email to