diff --git a/examples/mail/FileAttachmentExample.java b/examples/mail/FileAttachmentExample.java new file mode 100644 index 00000000..0f2ba820 --- /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 1x1 PNG image embedded directly; no local image file is required. + private static final String IMAGE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mOQanoEAAI4AX++PQw4AAAAAElFTkSuQmCC"; + + 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..8aecfcb4 --- /dev/null +++ b/examples/mail/InlineFileAttachmentExample.java @@ -0,0 +1,68 @@ +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 1x1 PNG image embedded directly; no local image file is required. + private static final String IMAGE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mOQanoEAAI4AX++PQw4AAAAAElFTkSuQmCC"; + + 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" + + "" + + ""; + + 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..891aaedf --- /dev/null +++ b/use-cases/threaded-replies.md @@ -0,0 +1,105 @@ +# 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. + +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 + +``` +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. +- **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 + 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.