From 22e699e2bb1b318eea438065ddff39b42de86f40 Mon Sep 17 00:00:00 2001 From: svkaizoku Date: Wed, 21 Oct 2026 12:21:37 +0200 Subject: [PATCH 1/4] feat(examples): Add mail attachment and threading examples Add Java examples for sending attachments (regular and inline/CID) and for replies and no-thread sends: - FileAttachmentExample and InlineFileAttachmentExample show a regular attachment and an inline image referenced by Content-ID. - ThreadingExample replies inside an existing thread using the smtp-id from the delivered webhook event. - PreventThreadingExample keeps same-subject emails in separate Gmail conversations with a unique X-Entity-Ref-ID header. Add use-cases/threaded-replies.md explaining why the smtp-id, not the X-Message-Id returned by the send API, is the value that threads a reply, and how to verify the result in Gmail. --- examples/mail/FileAttachmentExample.java | 64 +++++++++++ .../mail/InlineFileAttachmentExample.java | 70 ++++++++++++ examples/mail/PreventThreadingExample.java | 64 +++++++++++ examples/mail/ThreadingExample.java | 71 ++++++++++++ use-cases/README.md | 1 + use-cases/threaded-replies.md | 102 ++++++++++++++++++ 6 files changed, 372 insertions(+) create mode 100644 examples/mail/FileAttachmentExample.java create mode 100644 examples/mail/InlineFileAttachmentExample.java create mode 100644 examples/mail/PreventThreadingExample.java create mode 100644 examples/mail/ThreadingExample.java create mode 100644 use-cases/threaded-replies.md diff --git a/examples/mail/FileAttachmentExample.java b/examples/mail/FileAttachmentExample.java new file mode 100644 index 00000000..b3d1e246 --- /dev/null +++ b/examples/mail/FileAttachmentExample.java @@ -0,0 +1,64 @@ +import com.sendgrid.Method; +import com.sendgrid.Request; +import com.sendgrid.Response; +import com.sendgrid.SendGrid; +import com.sendgrid.helpers.mail.Mail; +import com.sendgrid.helpers.mail.objects.Attachments; +import com.sendgrid.helpers.mail.objects.Content; +import com.sendgrid.helpers.mail.objects.Email; +import java.io.IOException; + +public class FileAttachmentExample { + + // Small 96x64 PNG image embedded directly; no local image file is required. + private static final String IMAGE_BASE64 = + "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"; + + public static void main(String[] args) throws IOException { + attachment(); + } + + public static void attachment() throws IOException { + String apiKey = System.getenv("SENDGRID_API_KEY"); + if (apiKey == null || apiKey.trim().isEmpty()) { + throw new IllegalStateException( + "Set the SENDGRID_API_KEY environment variable."); + } + + String fromAddress = "sender@example.com"; + String toAddress = "recipient@example.com"; + + Mail mail = new Mail( + new Email(fromAddress), + "SendGrid Java: File attachment example", + new Email(toAddress), + new Content("text/plain", + "Please find the attached image.")); + + Attachments attachment = new Attachments(); + attachment.setContent(IMAGE_BASE64); + attachment.setType("image/png"); + attachment.setFilename("mail.png"); + attachment.setDisposition("attachment"); + mail.addAttachments(attachment); + + sendEmail(apiKey, mail); + } + + private static void sendEmail(String apiKey, Mail mail) + throws IOException { + Request request = new Request(); + request.setMethod(Method.POST); + request.setEndpoint("mail/send"); + request.setBody(mail.build()); + + Response response = new SendGrid(apiKey).api(request); + System.out.println("HTTP status: " + response.getStatusCode()); + + if (response.getStatusCode() < 200 + || response.getStatusCode() >= 300) { + System.err.println(response.getBody()); + throw new IllegalStateException("Email sending failed."); + } + } +} diff --git a/examples/mail/InlineFileAttachmentExample.java b/examples/mail/InlineFileAttachmentExample.java new file mode 100644 index 00000000..6cfbe0f3 --- /dev/null +++ b/examples/mail/InlineFileAttachmentExample.java @@ -0,0 +1,70 @@ +import com.sendgrid.Method; +import com.sendgrid.Request; +import com.sendgrid.Response; +import com.sendgrid.SendGrid; +import com.sendgrid.helpers.mail.Mail; +import com.sendgrid.helpers.mail.objects.Attachments; +import com.sendgrid.helpers.mail.objects.Content; +import com.sendgrid.helpers.mail.objects.Email; +import java.io.IOException; + +public class InlineFileAttachmentExample { + + private static final String CONTENT_ID = "company-logo"; + + // Small 96x64 PNG image embedded directly; no local image file is required. + private static final String IMAGE_BASE64 = + "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"; + + public static void main(String[] args) throws IOException { + inline(); + } + + private static void inline() throws IOException { + String apiKey = System.getenv("SENDGRID_API_KEY"); + if (apiKey == null || apiKey.trim().isEmpty()) { + throw new IllegalStateException( + "Set the SENDGRID_API_KEY environment variable."); + } + + String fromAddress = "sender@example.com"; + String toAddress = "recipient@example.com"; + + String html = """ + + + +

Welcome!

+

This image is embedded in the email:

+ Company logo + + + """; + + Mail mail = new Mail( + new Email(fromAddress), + "SendGrid Java: Inline image example", + new Email(toAddress), + new Content("text/html", html) + ); + + Attachments inlineImage = new Attachments(); + inlineImage.setContent(IMAGE_BASE64); + inlineImage.setType("image/png"); + inlineImage.setFilename("mail.png"); + inlineImage.setDisposition("inline"); + inlineImage.setContentId(CONTENT_ID); + mail.addAttachments(inlineImage); + + Request request = new Request(); + request.setMethod(Method.POST); + request.setEndpoint("mail/send"); + request.setBody(mail.build()); + + Response response = new SendGrid(apiKey).api(request); + System.out.println("HTTP status: " + response.getStatusCode()); + if (response.getStatusCode() < 200 || response.getStatusCode() > 299) { + System.err.println(response.getBody()); + } + } +} diff --git a/examples/mail/PreventThreadingExample.java b/examples/mail/PreventThreadingExample.java new file mode 100644 index 00000000..ab04dfdf --- /dev/null +++ b/examples/mail/PreventThreadingExample.java @@ -0,0 +1,64 @@ +import com.sendgrid.Method; +import com.sendgrid.Request; +import com.sendgrid.Response; +import com.sendgrid.SendGrid; +import com.sendgrid.helpers.mail.Mail; +import com.sendgrid.helpers.mail.objects.Content; +import com.sendgrid.helpers.mail.objects.Email; +import java.io.IOException; +import java.util.UUID; + +public class PreventThreadingExample { + public static void main(String[] args) throws IOException { + String apiKey = System.getenv("SENDGRID_API_KEY"); + if (apiKey == null || apiKey.trim().isEmpty()) { + throw new IllegalStateException( + "Set the SENDGRID_API_KEY environment variable."); + } + + String fromAddress = "sender@example.com"; + String toAddress = "recipient@example.com"; + + // Gmail groups emails that share a subject into one conversation. + // A unique X-Entity-Ref-ID header on each email keeps them separate. + // See https://docs.customer.io/messaging/channels/email/headers/custom-mail-headers/ + for (int i = 1; i <= 3; i++) { + Mail mail = new Mail( + new Email(fromAddress), + "Invoice Reminder", + new Email(toAddress), + new Content("text/plain", + "This is email #" + i + ". It should appear as its own conversation.")); + mail.addHeader("X-Entity-Ref-ID", UUID.randomUUID().toString()); + + String sgMessageId = sendEmail(apiKey, mail); + System.out.println("Email #" + i + " sent: " + sgMessageId); + } + + System.out.println("All emails sent with unique X-Entity-Ref-ID headers."); + System.out.println("Each should appear as a separate conversation in Gmail."); + } + + // Returns the X-Message-Id SendGrid assigned to the email. + private static String sendEmail(String apiKey, Mail mail) throws IOException { + Request request = new Request(); + request.setMethod(Method.POST); + request.setEndpoint("mail/send"); + request.setBody(mail.build()); + + Response response = new SendGrid(apiKey).api(request); + System.out.println("HTTP status: " + response.getStatusCode()); + + if (response.getStatusCode() < 200 + || response.getStatusCode() >= 300) { + System.err.println(response.getBody()); + throw new IllegalStateException("Email sending failed."); + } + + String messageId = response.getHeaders().get("X-Message-Id"); + if (messageId == null) { + throw new IllegalStateException("SendGrid did not return X-Message-Id."); + } + return messageId; + } +} diff --git a/examples/mail/ThreadingExample.java b/examples/mail/ThreadingExample.java new file mode 100644 index 00000000..aed3099d --- /dev/null +++ b/examples/mail/ThreadingExample.java @@ -0,0 +1,71 @@ +import com.sendgrid.Method; +import com.sendgrid.Request; +import com.sendgrid.Response; +import com.sendgrid.SendGrid; +import com.sendgrid.helpers.mail.Mail; +import com.sendgrid.helpers.mail.objects.Content; +import com.sendgrid.helpers.mail.objects.Email; +import java.io.IOException; + +public class ThreadingExample { + public static void main(String[] args) throws IOException { + String apiKey = System.getenv("SENDGRID_API_KEY"); + if (apiKey == null || apiKey.trim().isEmpty()) { + throw new IllegalStateException( + "Set the SENDGRID_API_KEY environment variable."); + } + + String fromAddress = "sender@example.com"; + String toAddress = "recipient@example.com"; + + if (args.length == 0) { + // Step 1: send the first email. Its smtp-id arrives in the "delivered" + // webhook event, not in this response. + Mail first = new Mail( + new Email(fromAddress), + "SendGrid Java: Threading example", + new Email(toAddress), + new Content("text/plain", "This starts the thread.")); + String sgMessageId = sendEmail(apiKey, first); + System.out.println("X-Message-Id: " + sgMessageId); + System.out.println("Find the smtp-id for this message in its delivered " + + "webhook event, then run again with it as the argument."); + return; + } + + // Step 2: reply using the smtp-id from the delivered webhook event, + // for example "". + String smtpId = args[0]; + Mail reply = new Mail( + new Email(fromAddress), + "Re: SendGrid Java: Threading example", + new Email(toAddress), + new Content("text/plain", "This reply should group with the first email.")); + reply.addHeader("In-Reply-To", smtpId); + reply.addHeader("References", smtpId); + sendEmail(apiKey, reply); + } + + // Returns the X-Message-Id SendGrid assigned to the email. + private static String sendEmail(String apiKey, Mail mail) throws IOException { + Request request = new Request(); + request.setMethod(Method.POST); + request.setEndpoint("mail/send"); + request.setBody(mail.build()); + + Response response = new SendGrid(apiKey).api(request); + System.out.println("HTTP status: " + response.getStatusCode()); + + if (response.getStatusCode() < 200 + || response.getStatusCode() >= 300) { + System.err.println(response.getBody()); + throw new IllegalStateException("Email sending failed."); + } + + String messageId = response.getHeaders().get("X-Message-Id"); + if (messageId == null) { + throw new IllegalStateException("SendGrid did not return X-Message-Id."); + } + return messageId; + } +} diff --git a/use-cases/README.md b/use-cases/README.md index 204d8609..4d8c31f5 100644 --- a/use-cases/README.md +++ b/use-cases/README.md @@ -6,6 +6,7 @@ Please [open an issue](https://github.com/sendgrid/sendgrid-ruby/issues) or [mak * [Transactional Templates](transactional-templates.md) * [Legacy Templates](legacy-templates.md) * [Personalizations](personalizations.md) +* [Threaded Replies](threaded-replies.md) # Twilio Use Cases * [Twilio Setup](twilio-setup.md) diff --git a/use-cases/threaded-replies.md b/use-cases/threaded-replies.md new file mode 100644 index 00000000..0554e171 --- /dev/null +++ b/use-cases/threaded-replies.md @@ -0,0 +1,102 @@ +# Threaded Replies + +This example goes over how to send a reply that stays in the same email thread as an earlier message. You can find documentation for the Event Webhook [here](https://sendgrid.com/docs/API_Reference/Webhooks/event.html), for the Webhooks API [here](https://www.twilio.com/docs/sendgrid/api-reference/webhooks), and for Mail Send [here](https://docs.sendgrid.com/api-reference/mail-send/mail-send). + +A reply sent without threading headers shows up in Gmail as a new conversation. For example, a follow-up to an order confirmation lands as a separate message instead of under the original. Setting `In-Reply-To` and `References` to the original email's `smtp-id` keeps the reply in the same thread in Gmail, Outlook, and Apple Mail. + +A complete two-step version is in [ThreadingExample.java](../examples/mail/ThreadingExample.java). + +## Which ID to use + +**Short answer:** reply with the `smtp-id` from the `delivered` webhook event. +The `X-Message-Id` from the send response is not the value that threads a +reply. + +Mail clients group messages by the RFC 5322 `Message-ID` header. The webhook's +`smtp-id` is that `Message-ID`, so it's the value to use. + +## Sequence + +``` +your app SendGrid webhook endpoint + | POST /mail/send | | + |---------------------------->| | + | response: X-Message-Id | | + |<----------------------------| | + | store X-Message-Id | | + | | delivered (smtp-id) | + | |----------------------------->| + | | | match on sg_message_id, + | | | store smtp-id + | POST /mail/send | | + | In-Reply-To: smtp-id | | + |---------------------------->| | +``` + +## Matching a webhook event to a send + +`sg_message_id` in an event is the response's `X-Message-Id` followed by a +suffix. To match, take the part before the first `.` and compare it with the +stored `X-Message-Id`: + +``` +sg_message_id: abc123XYZ.recvd-example-1-0 + └──────┘ + match against stored X-Message-Id: abc123XYZ +``` + +When it matches, save the event's `smtp-id` on the stored record. This rule is +based on one sample, so confirm it on several real sends before you depend on +it. + +Example `delivered` event (trimmed): + +```json +{ + "email": "recipient@example.com", + "event": "delivered", + "sg_event_id": "ZXhhbXBsZS1ldmVudC1pZA", + "sg_message_id": "abc123XYZ.recvd-example-1-0", + "smtp-id": "", + "timestamp": 1700000000 +} +``` + +The `processed` event carries the same `smtp-id`, so it also works if you need +the ID before delivery. + +## Troubleshooting + +- **The reply starts a new thread.** Check which value you sent. The + `X-Message-Id` will not thread. Send the `smtp-id`, and keep its angle + brackets. +- **There is no `smtp-id` yet.** The reply has to wait for the `delivered` or + `processed` event. Don't send it from the API response alone. +- **No event matches the stored ID.** Compare the part of `sg_message_id` before + the first `.` with the stored `X-Message-Id`, character for character. +- **The same event arrives twice.** Webhooks can repeat, so make the save + idempotent: writing the same `smtp-id` twice should change nothing. + +## Verify the thread in Gmail + +Check the headers after the reply arrives. This shows whether the reply carries the right values. + +1. Open the original email in Gmail and confirm the reply appears under it, in the same conversation. +2. On the reply, click the three-dot **More** menu next to the reply button, then choose **Show original**. +3. In the raw headers, find these lines: + - `Message-ID:` on the original email. It should match the `smtp-id` from the `delivered` event, including the angle brackets. + - `In-Reply-To:` on the reply. It should contain the same `smtp-id`. + - `References:` on the reply. It should contain the same `smtp-id`. +4. If the reply shows in a separate conversation, compare its `In-Reply-To` and `References` values with the original's `Message-ID`. A mismatch means the reply was sent with the wrong ID. + +## Viewing SendGrid deliverability webhook events + +The Event Webhook needs a public URL to post to. For a first test, use a temporary endpoint: + +1. Open [webhook.site](https://webhook.site) and copy the unique URL it gives you. +2. In the SendGrid dashboard, open the Event Webhook settings, paste the URL, and enable the `delivered` and `processed` events. +3. Run `ThreadingExample` to send the first email. Refresh webhook.site and look for the `delivered` event, then check its `sg_message_id` and `smtp-id`. + +Other tools that work the same way include [Beeceptor](https://beeceptor.com), which can also mock responses, and the [Svix webhook testing guide](https://www.svix.com/resources/faq/best-webhook-testing-inspecting-and-debugging-tools-2026/), which compares several options. + +Temporary endpoints are public, so anyone with the URL can read what is posted to it. Don't send real customer data through one. From 0f982bc7d8ccba9b651ee42e73e1f1391181f0f8 Mon Sep 17 00:00:00 2001 From: svkaizoku Date: Wed, 21 Oct 2026 12:37:14 +0200 Subject: [PATCH 2/4] chore(examples): use a 1x1 PNG in attachment examples Shrink the embedded image from 96x64 to 1x1 so the examples carry a few dozen bytes of base64 instead of a full logo. Co-Authored-By: Claude Haiku 5.5 --- examples/mail/FileAttachmentExample.java | 4 ++-- examples/mail/InlineFileAttachmentExample.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/mail/FileAttachmentExample.java b/examples/mail/FileAttachmentExample.java index b3d1e246..0f2ba820 100644 --- a/examples/mail/FileAttachmentExample.java +++ b/examples/mail/FileAttachmentExample.java @@ -10,9 +10,9 @@ public class FileAttachmentExample { - // Small 96x64 PNG image embedded directly; no local image file is required. + // Small 1x1 PNG image embedded directly; no local image file is required. private static final String IMAGE_BASE64 = - "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"; + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mOQanoEAAI4AX++PQw4AAAAAElFTkSuQmCC"; public static void main(String[] args) throws IOException { attachment(); diff --git a/examples/mail/InlineFileAttachmentExample.java b/examples/mail/InlineFileAttachmentExample.java index 6cfbe0f3..649fe458 100644 --- a/examples/mail/InlineFileAttachmentExample.java +++ b/examples/mail/InlineFileAttachmentExample.java @@ -12,9 +12,9 @@ public class InlineFileAttachmentExample { private static final String CONTENT_ID = "company-logo"; - // Small 96x64 PNG image embedded directly; no local image file is required. + // Small 1x1 PNG image embedded directly; no local image file is required. private static final String IMAGE_BASE64 = - "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"; + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mOQanoEAAI4AX++PQw4AAAAAElFTkSuQmCC"; public static void main(String[] args) throws IOException { inline(); From b08370ca599adaf2cffbe46f63ba59a9fa904007 Mon Sep 17 00:00:00 2001 From: svkaizoku Date: Wed, 21 Oct 2026 13:07:35 +0200 Subject: [PATCH 3/4] fix(examples): build the inline attachment example on Java 11 Replace the text block with string concatenation. Text blocks need Java 15, and the library supports Java 8 and 11. Co-Authored-By: Claude Haiku 5.5 --- examples/mail/InlineFileAttachmentExample.java | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/examples/mail/InlineFileAttachmentExample.java b/examples/mail/InlineFileAttachmentExample.java index 649fe458..8aecfcb4 100644 --- a/examples/mail/InlineFileAttachmentExample.java +++ b/examples/mail/InlineFileAttachmentExample.java @@ -30,16 +30,14 @@ private static void inline() throws IOException { String fromAddress = "sender@example.com"; String toAddress = "recipient@example.com"; - String html = """ - - - -

Welcome!

-

This image is embedded in the email:

- Company logo - - - """; + String html = "" + + "" + + "" + + "

Welcome!

" + + "

This image is embedded in the email:

" + + "\"Company" + + "" + + ""; Mail mail = new Mail( new Email(fromAddress), From 1b31a7a86a11674b59f350385be2638bf5e20a58 Mon Sep 17 00:00:00 2001 From: svkaizoku Date: Wed, 21 Oct 2026 13:23:54 +0200 Subject: [PATCH 4/4] docs(use-cases): note the Re: subject for threaded replies A reply should use the original subject with "Re: " in front, so Gmail groups it with the original message. Co-Authored-By: Claude Haiku 5.5 --- use-cases/threaded-replies.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/use-cases/threaded-replies.md b/use-cases/threaded-replies.md index 0554e171..891aaedf 100644 --- a/use-cases/threaded-replies.md +++ b/use-cases/threaded-replies.md @@ -15,6 +15,8 @@ reply. Mail clients group messages by the RFC 5322 `Message-ID` header. The webhook's `smtp-id` is that `Message-ID`, so it's the value to use. +The reply's subject should be the original subject with `Re: ` in front. Gmail uses the subject as a second signal when grouping, so a mismatched subject is more likely to start a new conversation. + ## Sequence ``` @@ -70,6 +72,7 @@ the ID before delivery. - **The reply starts a new thread.** Check which value you sent. The `X-Message-Id` will not thread. Send the `smtp-id`, and keep its angle brackets. +- **The reply is in a separate conversation.** Check that the subject is the original subject with `Re: ` in front, as well as the `smtp-id`. - **There is no `smtp-id` yet.** The reply has to wait for the `delivered` or `processed` event. Don't send it from the API response alone. - **No event matches the stored ID.** Compare the part of `sg_message_id` before