Skip to content

Commit 35bbddc

Browse files
committed
Handle data URIs in HTML to comply with Graph's 4MB request limit. Update testing. Add comments.
1 parent 1459fe2 commit 35bbddc

2 files changed

Lines changed: 257 additions & 33 deletions

File tree

api/src/org/labkey/api/util/GraphTransportProvider.java

Lines changed: 219 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
import java.util.Enumeration;
7878
import java.util.List;
7979
import java.util.Properties;
80+
import java.util.concurrent.atomic.AtomicInteger;
8081

8182
import static org.mockito.ArgumentMatchers.any;
8283
import 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

Comments
 (0)