7777import java .util .Enumeration ;
7878import java .util .List ;
7979import java .util .Properties ;
80+ import java .util .concurrent .atomic .AtomicInteger ;
8081
8182import static org .mockito .ArgumentMatchers .any ;
8283import static org .mockito .ArgumentMatchers .anyString ;
@@ -95,12 +96,25 @@ public class GraphTransportProvider implements EmailTransportProvider
9596{
9697 private static final org .apache .logging .log4j .Logger LOG = LogHelper .getLogger (GraphTransportProvider .class , "Microsoft Graph Transport Provider" );
9798
99+ // Graph API has a hard ~4MB limit on request body size. Unlike SMTP (which typically allows 10-25MB),
100+ // this means we must handle large content differently: attachments over 3MB require upload sessions,
101+ // and data URIs in HTML must be converted to CID attachments to avoid exceeding the limit.
102+ // See: https://learn.microsoft.com/en-us/graph/outlook-large-attachments
103+
98104 // Size threshold for using upload sessions (3MB) - smaller attachments use inline base64 encoding
99105 private static final int LARGE_ATTACHMENT_THRESHOLD = 3 * 1024 * 1024 ;
100106
107+ // Maximum body size limit (3.5MB) - leaves room for other message JSON content within Graph API's ~4MB limit
108+ private static final int MAX_BODY_SIZE = (int ) (3.5 * 1024 * 1024 );
109+
110+ // Note: Retry logic for transient failures (429, 503, 504) is handled automatically by the
111+ // Graph SDK's built-in RetryHandler middleware. No custom retry logic needed here.
112+
101113 private final Properties _properties = new Properties ();
102114
103- // Lazily initialized Graph client - created on first use after configuration is loaded
115+ // GraphServiceClient is the SDK's entry point that translates fluent method calls into HTTP
116+ // requests to Microsoft Graph API endpoints like /users/{id}/sendMail and /users/{id}/messages.
117+ // Lazily initialized - created on first use after configuration is loaded.
104118 private volatile GraphServiceClient _graphClient = null ;
105119 private final Object _clientLock = new Object ();
106120
@@ -223,26 +237,33 @@ private GraphServiceClient getGraphClient()
223237
224238 /**
225239 * Send email via Graph API using the SDK.
226- * For messages with large attachments, uses draft + upload session approach.
227- * For messages without attachments or with small attachments, uses direct sendMail.
240+ * For messages with large attachments, use the draft and upload session approach.
241+ * For messages without attachments or with small attachments, use direct sendMail.
228242 */
229243 private void sendMessage (MimeMessage mm ) throws IOException , MessagingException
230244 {
245+ // Step 1: Extract MIME attachments from the message structure
231246 List <AttachmentInfo > attachments = extractAttachments (mm );
232247
233- // Check if any attachment exceeds the threshold for inline encoding
248+ // Step 2: Build the Graph message. This also scans the HTML body for data URIs and converts
249+ // them to CID attachments, adding them to the attachment list.
250+ com .microsoft .graph .models .Message graphMessage = buildGraphMessage (mm , attachments );
251+
252+ // Step 3: Check if any attachment (including converted data URIs) exceeds the threshold.
253+ // This check must happen AFTER buildGraphMessage because data URI conversion may add
254+ // large attachments that weren't in the original MIME structure.
234255 boolean hasLargeAttachments = attachments .stream ()
235256 .anyMatch (a -> a .content ().length > LARGE_ATTACHMENT_THRESHOLD );
236257
237258 if (hasLargeAttachments )
238259 {
239260 // Has large attachments - create draft, upload via upload sessions, then send
240- sendWithLargeAttachments (mm , attachments );
261+ sendWithLargeAttachments (graphMessage , attachments );
241262 }
242263 else
243264 {
244265 // No attachments or small attachments - send directly via sendMail
245- sendViaSdk (mm , attachments );
266+ sendViaSdk (graphMessage , attachments );
246267 }
247268 }
248269
@@ -318,32 +339,24 @@ else if (Part.ATTACHMENT.equalsIgnoreCase(disposition) ||
318339 */
319340 private byte [] readPartContent (BodyPart part ) throws IOException , MessagingException
320341 {
321- try (InputStream is = part .getInputStream ();
322- ByteArrayOutputStream baos = new ByteArrayOutputStream ())
342+ try (InputStream is = part .getInputStream ())
323343 {
324- byte [] buffer = new byte [8192 ];
325- int bytesRead ;
326- while ((bytesRead = is .read (buffer )) != -1 )
327- {
328- baos .write (buffer , 0 , bytesRead );
329- }
330- return baos .toByteArray ();
344+ return is .readAllBytes ();
331345 }
332346 }
333347
334348 /**
335349 * Send email using the Graph SDK with inline attachments (for messages without attachments
336350 * or with attachments smaller than 3MB).
351+ *
352+ * @param graphMessage the already-built Graph message (from buildGraphMessage)
353+ * @param attachments all attachments including any converted from data URIs
337354 */
338- private void sendViaSdk (MimeMessage mm , List <AttachmentInfo > attachments )
339- throws IOException , MessagingException
355+ private void sendViaSdk (com .microsoft .graph .models .Message graphMessage , List <AttachmentInfo > attachments )
340356 {
341357 GraphServiceClient client = getGraphClient ();
342358 String fromAddress = getFromAddress ();
343359
344- // Build the Graph Message object
345- com .microsoft .graph .models .Message graphMessage = buildGraphMessage (mm );
346-
347360 // Add small attachments inline (base64 encoded)
348361 if (!attachments .isEmpty ())
349362 {
@@ -389,15 +402,17 @@ private void sendViaSdk(MimeMessage mm, List<AttachmentInfo> attachments)
389402 * Send email with large attachments: create draft, upload via upload sessions, then send.
390403 * This approach is required for attachments over 3MB as Graph API doesn't support
391404 * inline base64 encoding for large files.
405+ *
406+ * @param graphMessage the already-built Graph message (from buildGraphMessage)
407+ * @param attachments all attachments including any converted from data URIs
392408 */
393- private void sendWithLargeAttachments (MimeMessage mm , List <AttachmentInfo > attachments )
394- throws IOException , MessagingException
409+ private void sendWithLargeAttachments (com . microsoft . graph . models . Message graphMessage , List <AttachmentInfo > attachments )
410+ throws IOException
395411 {
396412 GraphServiceClient client = getGraphClient ();
397413 String fromAddress = getFromAddress ();
398414
399- // Step 1: Create draft message
400- com .microsoft .graph .models .Message graphMessage = buildGraphMessage (mm );
415+ // Step 1: Create draft message from the pre-built Graph message
401416 com .microsoft .graph .models .Message draft = client .users ().byUserId (fromAddress )
402417 .messages ()
403418 .post (graphMessage );
@@ -456,8 +471,22 @@ private void sendWithLargeAttachments(MimeMessage mm, List<AttachmentInfo> attac
456471
457472 /**
458473 * Build a Graph Message object from a MimeMessage.
474+ * <p>
475+ * If the HTML body contains data URIs (e.g., {@code <img src="data:image/png;base64,...">}),
476+ * they are converted to CID attachments following email industry best practices. The data URI
477+ * is decoded, added to the attachments list, and replaced with a cid: reference in the HTML.
478+ * <p>
479+ * Why we handle this ourselves: The Microsoft Graph SDK is a REST API wrapper that sends
480+ * whatever content you provide - it doesn't parse or transform HTML bodies. Data URIs embedded
481+ * in HTML can cause issues: (1) they bloat the message body and may exceed Graph API's ~4MB
482+ * request limit, (2) some email clients (e.g., Gmail) block base64 data URIs for security
483+ * reasons. The proper email standard is to use CID (Content-ID) attachments with
484+ * {@code <img src="cid:...">} references, which this method implements.
485+ *
486+ * @param mm the MimeMessage to convert
487+ * @param attachments list of attachments; any data URIs converted from the HTML body are added here
459488 */
460- private com .microsoft .graph .models .Message buildGraphMessage (MimeMessage mm )
489+ private com .microsoft .graph .models .Message buildGraphMessage (MimeMessage mm , List < AttachmentInfo > attachments )
461490 throws MessagingException , IOException
462491 {
463492 com .microsoft .graph .models .Message message = new com .microsoft .graph .models .Message ();
@@ -470,13 +499,18 @@ private com.microsoft.graph.models.Message buildGraphMessage(MimeMessage mm)
470499 String [] bodyContent = extractBodyContent (mm );
471500 if (bodyContent [0 ] != null )
472501 {
473- // HTML content
502+ // HTML content - scan for data URIs and convert them to CID attachments.
503+ // Data URIs like "data:image/png;base64,..." are decoded and added to attachments list,
504+ // then replaced with "cid:<generated-id>" references in the HTML.
505+ String html = convertDataUrisToCidAttachments (bodyContent [0 ], attachments );
506+ validateBodySize (html );
474507 body .setContentType (BodyType .Html );
475- body .setContent (bodyContent [ 0 ] );
508+ body .setContent (html );
476509 }
477510 else if (bodyContent [1 ] != null )
478511 {
479- // Text content
512+ // Plain text content - no data URI conversion needed
513+ validateBodySize (bodyContent [1 ]);
480514 body .setContentType (BodyType .Text );
481515 body .setContent (bodyContent [1 ]);
482516 }
@@ -567,7 +601,8 @@ private void extractBodyFromMultipart(Multipart multipart, String[] bodyContent)
567601
568602 LOG .debug ("Part {}: disposition={}, contentType={}" , i , disposition , contentType );
569603
570- // Skip attachments
604+ // Skip attachments - this method only extracts body content (text/html or text/plain).
605+ // Attachments are handled separately by extractAttachments() in sendMessage().
571606 if (Part .ATTACHMENT .equalsIgnoreCase (disposition ))
572607 {
573608 continue ;
@@ -615,6 +650,116 @@ private boolean containsHtmlTags(String content)
615650 return content != null && HTML_TAG_PATTERN .matcher (content ).find ();
616651 }
617652
653+ // Pattern to match data URIs in HTML (e.g., src="data:image/png;base64,...")
654+ // Captures: group 1 = quote char, group 2 = MIME type, group 3 = base64 data
655+ private static final java .util .regex .Pattern DATA_URI_PATTERN =
656+ java .util .regex .Pattern .compile (
657+ "([\" '])data:([^;]+);base64,([^\" ']+)\\ 1" ,
658+ java .util .regex .Pattern .CASE_INSENSITIVE );
659+
660+ // Counter for generating unique Content-IDs for converted data URIs.
661+ // Uses AtomicInteger for thread safety since multiple threads may send emails concurrently.
662+ private final AtomicInteger _dataUriCounter = new AtomicInteger (0 );
663+
664+ /**
665+ * Scan HTML content for base64 data URIs and convert them to CID attachments.
666+ * This follows email industry best practices - embedded images should be sent as
667+ * MIME attachments with Content-ID references rather than inline data URIs.
668+ *
669+ * @param html the HTML content to scan
670+ * @param attachments list to add converted attachments to
671+ * @return updated HTML with data URIs replaced by cid: references
672+ */
673+ private String convertDataUrisToCidAttachments (String html , List <AttachmentInfo > attachments )
674+ {
675+ if (html == null || !html .contains ("data:" ))
676+ {
677+ return html ;
678+ }
679+
680+ java .util .regex .Matcher matcher = DATA_URI_PATTERN .matcher (html );
681+ StringBuilder result = new StringBuilder ();
682+
683+ while (matcher .find ())
684+ {
685+ String quote = matcher .group (1 );
686+ String mimeType = matcher .group (2 );
687+ String base64Data = matcher .group (3 );
688+
689+ try
690+ {
691+ // Decode the base64 content
692+ byte [] content = java .util .Base64 .getDecoder ().decode (base64Data );
693+
694+ // Generate a unique Content-ID
695+ String contentId = "datauri-" + System .currentTimeMillis () + "-" + _dataUriCounter .incrementAndGet ();
696+
697+ // Determine file extension from MIME type
698+ String extension = getExtensionForMimeType (mimeType );
699+ String fileName = contentId + extension ;
700+
701+ // Create attachment info and add to list
702+ attachments .add (new AttachmentInfo (fileName , mimeType , content , contentId ));
703+
704+ // Replace data URI with cid: reference
705+ matcher .appendReplacement (result , quote + "cid:" + contentId + quote );
706+
707+ LOG .debug ("Converted data URI to CID attachment: {} ({} bytes, type: {})" ,
708+ contentId , content .length , mimeType );
709+ }
710+ catch (IllegalArgumentException e )
711+ {
712+ // Invalid base64 - leave the data URI as-is
713+ LOG .warn ("Failed to decode base64 data URI, leaving as-is: {}" , e .getMessage ());
714+ matcher .appendReplacement (result , java .util .regex .Matcher .quoteReplacement (matcher .group (0 )));
715+ }
716+ }
717+ matcher .appendTail (result );
718+
719+ return result .toString ();
720+ }
721+
722+ /**
723+ * Get file extension for common MIME types.
724+ */
725+ private String getExtensionForMimeType (String mimeType )
726+ {
727+ if (mimeType == null ) return "" ;
728+ return switch (mimeType .toLowerCase ())
729+ {
730+ case "image/png" -> ".png" ;
731+ case "image/jpeg" , "image/jpg" -> ".jpg" ;
732+ case "image/gif" -> ".gif" ;
733+ case "image/webp" -> ".webp" ;
734+ case "image/svg+xml" -> ".svg" ;
735+ case "image/bmp" -> ".bmp" ;
736+ case "image/tiff" -> ".tiff" ;
737+ case "application/pdf" -> ".pdf" ;
738+ default -> "" ;
739+ };
740+ }
741+
742+ /**
743+ * Validate that body content doesn't exceed the Graph API size limit.
744+ *
745+ * @param bodyContent the body content (HTML or text)
746+ * @throws MessagingException if body exceeds MAX_BODY_SIZE
747+ */
748+ private void validateBodySize (String bodyContent ) throws MessagingException
749+ {
750+ if (bodyContent != null )
751+ {
752+ int bodySize = bodyContent .getBytes (StandardCharsets .UTF_8 ).length ;
753+ if (bodySize > MAX_BODY_SIZE )
754+ {
755+ throw new MessagingException (String .format (
756+ "Email body size (%d bytes) exceeds maximum allowed size (%d bytes). " +
757+ "Consider moving large embedded content to attachments." ,
758+ bodySize , MAX_BODY_SIZE ));
759+ }
760+ }
761+ }
762+
618763 /**
619764 * Add a small attachment directly to a draft message.
620765 */
@@ -1156,5 +1301,50 @@ private MimeMessage createTestMessageWithHtmlBody() throws Exception
11561301 message .setContent ("<html><body><h1>Hello</h1><p>This is an HTML email.</p></body></html>" , "text/html" );
11571302 return message ;
11581303 }
1304+
1305+ private MimeMessage createTestMessageWithDataUri () throws Exception
1306+ {
1307+ Properties props = new Properties ();
1308+ Session session = Session .getDefaultInstance (props );
1309+ MimeMessage message = new MimeMessage (session );
1310+ message .setFrom (new InternetAddress (TEST_FROM_ADDRESS ));
1311+ message .setRecipient (Message .RecipientType .TO , new InternetAddress (TEST_TO_ADDRESS ));
1312+ message .setSubject ("Test email with data URI" );
1313+ // Small PNG: 1x1 red pixel
1314+ String base64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" ;
1315+ String html = "<html><body><p>Image:</p><img src=\" data:image/png;base64," + base64Png + "\" /></body></html>" ;
1316+ message .setContent (html , "text/html" );
1317+ return message ;
1318+ }
1319+
1320+ @ Test
1321+ public void testDataUriConvertedToCidAttachment () throws Exception
1322+ {
1323+ doNothing ().when (mockSendMailRequestBuilder ).post (any (SendMailPostRequestBody .class ));
1324+
1325+ GraphTransportProvider provider = createTestProvider ();
1326+ provider .send (createTestMessageWithDataUri ());
1327+
1328+ ArgumentCaptor <SendMailPostRequestBody > captor = ArgumentCaptor .forClass (SendMailPostRequestBody .class );
1329+ verify (mockSendMailRequestBuilder ).post (captor .capture ());
1330+
1331+ com .microsoft .graph .models .Message message = captor .getValue ().getMessage ();
1332+
1333+ // Verify HTML body now has cid: reference instead of data URI
1334+ assertNotNull ("Body should not be null" , message .getBody ());
1335+ assertEquals (BodyType .Html , message .getBody ().getContentType ());
1336+ String bodyContent = message .getBody ().getContent ();
1337+ assertTrue ("Body should contain cid: reference" , bodyContent .contains ("cid:" ));
1338+ assertFalse ("Body should not contain data URI" , bodyContent .contains ("data:image/png;base64" ));
1339+
1340+ // Verify attachment was created from the data URI
1341+ assertNotNull ("Attachments should not be null" , message .getAttachments ());
1342+ assertEquals ("Should have one attachment (converted from data URI)" , 1 , message .getAttachments ().size ());
1343+
1344+ FileAttachment attachment = (FileAttachment ) message .getAttachments ().get (0 );
1345+ assertTrue ("Attachment should be inline" , attachment .getIsInline ());
1346+ assertNotNull ("Attachment should have content ID" , attachment .getContentId ());
1347+ assertEquals ("image/png" , attachment .getContentType ());
1348+ }
11591349 }
11601350}
0 commit comments