From eccc4779fb46983e4766b9cbd86808523f0a05b5 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:37:23 -0400 Subject: [PATCH] feat(inbound): MailgunInboundParser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Mailgun as the second supported inbound provider alongside Postmark (from #27). Mailgun POSTs multipart/form-data with snake-case field names — the Spring controller already ingests a Map, so this parser just reads from that map and the controller routes via ?adapter=mailgun. Notes: - Mailgun's 'from' is typically 'Name ' — we extract the display name portion separately and fall back to the sender field for the email. Strips surrounding quotes on the name. - Mailgun hosts attachment content behind a URL (large attachments). We carry the URL through in downloadUrl; a follow-up worker can fetch + persist out-of-band. - Malformed attachments JSON degrades gracefully (empty list). 7 JUnit tests cover core field extraction, threading headers, provider-hosted attachment parsing, malformed attachments JSON, sender→from fallback, bare-email (no display name) handling, and the quote-stripping on display names. --- .../email/inbound/MailgunInboundParser.java | 135 ++++++++++++++++++ .../inbound/MailgunInboundParserTest.java | 114 +++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/main/java/dev/escalated/services/email/inbound/MailgunInboundParser.java create mode 100644 src/test/java/dev/escalated/services/email/inbound/MailgunInboundParserTest.java diff --git a/src/main/java/dev/escalated/services/email/inbound/MailgunInboundParser.java b/src/main/java/dev/escalated/services/email/inbound/MailgunInboundParser.java new file mode 100644 index 0000000..1ca930d --- /dev/null +++ b/src/main/java/dev/escalated/services/email/inbound/MailgunInboundParser.java @@ -0,0 +1,135 @@ +package dev.escalated.services.email.inbound; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * Parses Mailgun's inbound webhook payload into an + * {@link InboundMessage}. Mailgun POSTs {@code multipart/form-data} + * with snake-case field names: {@code sender}, {@code recipient}, + * {@code subject}, {@code body-plain}, {@code body-html}, + * {@code Message-Id}, {@code In-Reply-To}, {@code References}, plus + * a JSON-encoded {@code attachments} field. + * + *

The Spring controller already ingests a + * {@code Map} from the webhook body, so this parser + * just reads from that map. + * + *

Mailgun hosts attachment content behind provider URLs (for + * large attachments); we carry the URL through in + * {@link InboundAttachment#downloadUrl} so a follow-up worker can + * fetch + persist out-of-band. + */ +@Component +public class MailgunInboundParser implements InboundEmailParser { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String name() { + return "mailgun"; + } + + @Override + public InboundMessage parse(Object rawPayload) { + @SuppressWarnings("unchecked") + Map payload = rawPayload instanceof Map m + ? (Map) m + : MAPPER.convertValue(rawPayload, new TypeReference>() {}); + + String fromEmail = stringAt(payload, "sender"); + if (fromEmail == null || fromEmail.isEmpty()) { + fromEmail = stringAt(payload, "from"); + } + String fromName = extractFromName(stringAt(payload, "from")); + + String toEmail = stringAt(payload, "recipient"); + if (toEmail == null || toEmail.isEmpty()) { + toEmail = stringAt(payload, "To"); + } + + Map headers = new LinkedHashMap<>(); + putIfNonEmpty(headers, "Message-ID", stringAt(payload, "Message-Id")); + putIfNonEmpty(headers, "In-Reply-To", stringAt(payload, "In-Reply-To")); + putIfNonEmpty(headers, "References", stringAt(payload, "References")); + + return new InboundMessage( + fromEmail == null ? "" : fromEmail, + fromName, + toEmail == null ? "" : toEmail, + stringAt(payload, "subject"), + stringAt(payload, "body-plain"), + stringAt(payload, "body-html"), + stringAt(payload, "Message-Id"), + stringAt(payload, "In-Reply-To"), + stringAt(payload, "References"), + headers, + extractAttachments(stringAt(payload, "attachments")) + ); + } + + private static String stringAt(Map payload, String key) { + Object v = payload.get(key); + return v == null ? null : v.toString(); + } + + private static void putIfNonEmpty(Map headers, String key, String value) { + if (value != null && !value.isEmpty()) { + headers.put(key, value); + } + } + + /** + * Mailgun's {@code from} field is typically + * {@code "Full Name "} — extract the display name + * portion. Returns {@code null} when the input has no angle- + * bracketed email (bare email address). + */ + private static String extractFromName(String raw) { + if (raw == null || raw.isEmpty()) { + return null; + } + int angle = raw.indexOf('<'); + if (angle <= 0) { + return null; + } + String name = raw.substring(0, angle).trim(); + // Strip surrounding quotes if present. + if (name.length() >= 2 && name.startsWith("\"") && name.endsWith("\"")) { + name = name.substring(1, name.length() - 1); + } + return name.isEmpty() ? null : name; + } + + private static List extractAttachments(String attachmentsJson) { + if (attachmentsJson == null || attachmentsJson.isEmpty()) { + return new ArrayList<>(); + } + try { + List> entries = MAPPER.readValue( + attachmentsJson, new TypeReference>>() {}); + List list = new ArrayList<>(); + for (Map entry : entries) { + String name = stringAt(entry, "name"); + String contentType = stringAt(entry, "content-type"); + Long size = entry.get("size") instanceof Number n ? n.longValue() : null; + String url = stringAt(entry, "url"); + list.add(new InboundAttachment( + name == null ? "attachment" : name, + contentType == null ? "application/octet-stream" : contentType, + size, + null, + url + )); + } + return list; + } catch (Exception ex) { + return new ArrayList<>(); + } + } +} diff --git a/src/test/java/dev/escalated/services/email/inbound/MailgunInboundParserTest.java b/src/test/java/dev/escalated/services/email/inbound/MailgunInboundParserTest.java new file mode 100644 index 0000000..26c692d --- /dev/null +++ b/src/test/java/dev/escalated/services/email/inbound/MailgunInboundParserTest.java @@ -0,0 +1,114 @@ +package dev.escalated.services.email.inbound; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MailgunInboundParserTest { + + private Map sampleFormData() { + Map m = new HashMap<>(); + m.put("sender", "customer@example.com"); + m.put("from", "Customer "); + m.put("recipient", "support+abc@support.example.com"); + m.put("To", "support+abc@support.example.com"); + m.put("subject", "[ESC-00042] Help"); + m.put("body-plain", "Plain body"); + m.put("body-html", "

HTML body

"); + m.put("Message-Id", ""); + m.put("In-Reply-To", ""); + m.put("References", ""); + m.put("attachments", + "[{\"name\":\"report.pdf\",\"content-type\":\"application/pdf\",\"size\":5120,\"url\":\"https://mailgun.example/att/abc\"}]"); + return m; + } + + @Test + void nameIsMailgun() { + assertThat(new MailgunInboundParser().name()).isEqualTo("mailgun"); + } + + @Test + void parseExtractsCoreFields() { + InboundMessage m = new MailgunInboundParser().parse(sampleFormData()); + + assertThat(m.fromEmail()).isEqualTo("customer@example.com"); + assertThat(m.fromName()).isEqualTo("Customer"); + assertThat(m.toEmail()).isEqualTo("support+abc@support.example.com"); + assertThat(m.subject()).isEqualTo("[ESC-00042] Help"); + assertThat(m.bodyText()).isEqualTo("Plain body"); + assertThat(m.bodyHtml()).isEqualTo("

HTML body

"); + } + + @Test + void parseExtractsThreadingHeaders() { + InboundMessage m = new MailgunInboundParser().parse(sampleFormData()); + + assertThat(m.inReplyTo()).isEqualTo(""); + assertThat(m.references()).isEqualTo(""); + } + + @Test + void parseProviderHostedAttachments() { + InboundMessage m = new MailgunInboundParser().parse(sampleFormData()); + + assertThat(m.attachments()).hasSize(1); + InboundAttachment a = m.attachments().get(0); + assertThat(a.name()).isEqualTo("report.pdf"); + assertThat(a.contentType()).isEqualTo("application/pdf"); + assertThat(a.sizeBytes()).isEqualTo(5120L); + assertThat(a.downloadUrl()).isEqualTo("https://mailgun.example/att/abc"); + // Mailgun hosts content — no inline bytes. + assertThat(a.content()).isNull(); + } + + @Test + void parseHandlesMalformedAttachmentsJson() { + Map data = sampleFormData(); + data.put("attachments", "not json"); + + InboundMessage m = new MailgunInboundParser().parse(data); + + assertThat(m.attachments()).isEmpty(); + } + + @Test + void parseFallsBackSenderToFromOnMissing() { + Map data = new HashMap<>(); + data.put("from", "only-from@example.com"); + data.put("recipient", "support@example.com"); + data.put("subject", "hi"); + + InboundMessage m = new MailgunInboundParser().parse(data); + + assertThat(m.fromEmail()).isEqualTo("only-from@example.com"); + } + + @Test + void parseExtractFromNameReturnsNullWithoutAngleBrackets() { + Map data = new HashMap<>(); + data.put("sender", "bareemail@example.com"); + data.put("from", "bareemail@example.com"); + data.put("recipient", "support@example.com"); + data.put("subject", "hi"); + + InboundMessage m = new MailgunInboundParser().parse(data); + + assertThat(m.fromName()).isNull(); + } + + @Test + void parseStripsQuotesFromFromName() { + Map data = new HashMap<>(); + data.put("sender", "jane@example.com"); + data.put("from", "\"Jane Doe\" "); + data.put("recipient", "support@example.com"); + data.put("subject", "hi"); + + InboundMessage m = new MailgunInboundParser().parse(data); + + assertThat(m.fromName()).isEqualTo("Jane Doe"); + } +}