Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security
- **Inbound email replies are accepted only from the ticket's requester.** A
message that threaded onto a ticket (by `In-Reply-To`, `References`, or a
subject reference such as `[ESC-00001]`) was added as a reply whoever sent it,
under the `From` name and address. A threaded message now becomes a reply only
when `From` matches the ticket's requester email (case-insensitive); it is
posted as the requester and reopens a resolved or closed ticket. Anyone else's
message, including one naming an agent's address, opens a new ticket and leaves
the matched one untouched. With `escalated.email.inbound-secret` configured,
only the signed Reply-To address links a message to a ticket.

## [0.1.1] - 2026-09-13

### Security
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ An embeddable helpdesk system for Spring Boot applications. Add a full-featured
23. **CSAT Ratings** -- Customer satisfaction surveys with token-based access
24. **2FA (TOTP)** -- Time-based one-time password support for agent accounts
25. **Guest Access** -- Token-based ticket access without authentication
26. **Inbound Email** -- Single webhook endpoint with Postmark + Mailgun + AWS SES parsers, signed Reply-To verification, and Message-ID-based ticket resolution
26. **Inbound Email** -- Single webhook endpoint with Postmark + Mailgun + AWS SES parsers, signed Reply-To ticket resolution, and replies accepted only from the ticket's requester

## Requirements

Expand Down Expand Up @@ -269,7 +269,11 @@ POST /escalated/webhook/email/inbound?adapter=ses

The adapter can be selected via the query parameter or the `X-Escalated-Adapter` header. Your provider must attach the shared secret as an `X-Escalated-Inbound-Secret` header, which is compared with `MessageDigest.isEqual` (timing-safe).

The service resolves inbound messages to existing tickets via, in order: canonical `Message-ID` headers, signed `Reply-To` verification, and subject-reference tags. Unmatched messages with real content create a new ticket; SNS subscription confirmations and empty body+subject messages are skipped.
Because the webhook requires `escalated.email.inbound-secret`, outbound mail carries the signed `Reply-To` address (`reply+{id}.{hmac8}@domain`), and that address is the only thing that links an inbound message to an existing ticket. When the service is called without a secret configured, it falls back to the canonical `Message-ID` headers and subject-reference tags (e.g. `[ESC-00001]`).

A matched message becomes a reply only when its `From` address is the ticket's requester (compared case-insensitively). The reply is posted as the requester, never as whoever the `From` header names, and it reopens a resolved or closed ticket. Agents reply in the app, not by email. Mail from anyone else, including an address that belongs to an agent, opens a new ticket of its own and leaves the matched ticket untouched. Unmatched messages with real content create a new ticket; SNS subscription confirmations and empty body+subject messages are skipped.

`From` can still be forged, so also have your inbound provider enforce SPF, DKIM and DMARC.

See the [inbound email docs](https://docs.escalated.dev/inbound-email) for provider setup, the response shape, and a ready-to-paste curl test recipe.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
* inserted by the inbound controller once the service lands.</li>
* </ol>
*
* <p>Message-IDs and ticket references are guessable, so once an
* inbound secret is configured (and outbound mail therefore carries the
* signed Reply-To) only path 3 identifies a ticket. Without a secret the
* unsigned paths remain as a compatibility mode. Either way a match is
* only a lookup: {@link InboundEmailService} still requires the sender
* to be the ticket's requester before posting a reply.
*
* <p>Mirrors the NestJS {@code InboundRouterService} resolution order
* and the Laravel/Rails/Django/Adonis/WordPress/.NET ports.
*/
Expand All @@ -60,6 +67,24 @@ public Optional<Ticket> resolveTicket(InboundMessage message) {
return Optional.empty();
}

// 3. Signed Reply-To on the recipient address. With a secret
// configured this is the only path that links mail to a ticket.
String secret = properties.getEmail() == null ? null : properties.getEmail().getInboundSecret();
if (secret != null && !secret.isBlank()) {
if (message.toEmail() == null) {
return Optional.empty();
}
Optional<Long> verified = MessageIdUtil.verifyReplyTo(message.toEmail(), secret);
if (verified.isEmpty()) {
return Optional.empty();
}
Optional<Ticket> ticket = ticketRepository.findById(verified.get());
if (ticket.isEmpty()) {
log.debug("[InboundEmailRouter] Reply-To verified but ticket #{} not found", verified.get());
}
return ticket;
}

List<String> headerIds = candidateHeaderMessageIds(message);

// 1 + 2. Parse canonical Message-IDs out of our own headers.
Expand All @@ -73,19 +98,6 @@ public Optional<Ticket> resolveTicket(InboundMessage message) {
}
}

// 3. Signed Reply-To on the recipient address.
String secret = properties.getEmail() == null ? null : properties.getEmail().getInboundSecret();
if (secret != null && !secret.isBlank() && message.toEmail() != null) {
Optional<Long> verified = MessageIdUtil.verifyReplyTo(message.toEmail(), secret);
if (verified.isPresent()) {
Optional<Ticket> ticket = ticketRepository.findById(verified.get());
if (ticket.isPresent()) {
return ticket;
}
log.debug("[InboundEmailRouter] Reply-To verified but ticket #{} not found", verified.get());
}
}

// 4. Subject line reference tag.
if (message.subject() != null) {
Matcher m = SUBJECT_REF_PATTERN.matcher(message.subject());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package dev.escalated.services.email.inbound;

import dev.escalated.models.Contact;
import dev.escalated.models.Reply;
import dev.escalated.models.Ticket;
import dev.escalated.models.TicketPriority;
import dev.escalated.models.TicketStatus;
import dev.escalated.services.TicketService;
import java.util.ArrayList;
import java.util.List;
Expand Down Expand Up @@ -43,20 +45,31 @@ public InboundEmailService(InboundEmailRouter router, TicketService ticketServic
* Process a parsed inbound message. Returns a {@link ProcessResult}
* carrying the outcome (matched + reply id, created new ticket
* id, or skipped).
*
* <p>A matched thread becomes a reply only when the sender is the
* ticket's requester; the reply is posted as the requester and
* reopens a resolved or closed ticket. Anyone else gets a new
* ticket of their own, and the matched ticket is left untouched.
*/
public ProcessResult process(InboundMessage message) {
Optional<Ticket> ticketMatch = router.resolveTicket(message);

if (ticketMatch.isPresent()) {
// A thread match alone is not enough: the sender must also be the
// ticket's requester, and the author is always taken from the
// ticket, never from the unauthenticated From header.
if (ticketMatch.isPresent() && isFromRequester(ticketMatch.get(), message)) {
Ticket ticket = ticketMatch.get();
Reply reply = ticketService.addReply(
ticket.getId(),
message.body(),
message.fromName(),
message.fromEmail(),
ticket.getRequesterName(),
ticket.getRequesterEmail(),
"inbound_email",
false
);
if (ticket.getStatus() == TicketStatus.RESOLVED || ticket.getStatus() == TicketStatus.CLOSED) {
ticketService.changeStatus(ticket.getId(), TicketStatus.OPEN, ticket.getRequesterEmail());
}
return new ProcessResult(
Outcome.REPLIED_TO_EXISTING,
ticket.getId(),
Expand All @@ -65,6 +78,11 @@ public ProcessResult process(InboundMessage message) {
);
}

if (ticketMatch.isPresent()) {
log.info("[InboundEmailService] Inbound email matched ticket #{} but not its requester; opening a new ticket",
ticketMatch.get().getId());
}

if (isNoiseEmail(message)) {
return new ProcessResult(Outcome.SKIPPED, null, null, List.of());
}
Expand All @@ -87,6 +105,20 @@ public ProcessResult process(InboundMessage message) {
);
}

/**
* Whether the From address is the ticket's requester (compared
* case-insensitively). Staff identity is never derived from From:
* an agent replying by email is not the requester, so the message
* becomes a new ticket instead.
*/
static boolean isFromRequester(Ticket ticket, InboundMessage message) {
String sender = Contact.normalizeEmail(message.fromEmail());
if (sender.isEmpty()) {
return false;
}
return sender.equals(Contact.normalizeEmail(ticket.getRequesterEmail()));
}

/**
* Noise emails: empty body + empty subject, or from common
* bounce/no-reply senders.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ void resolveTicket_rejectsForgedReplyToSignature() {
assertThat(router.resolveTicket(m)).isEmpty();
}

@Test
void resolveTicket_ignoresHeadersAndSubjectOnceSecretConfigured() {
// Message-IDs and references are guessable; with a secret set
// only the signed Reply-To links mail to a ticket.
properties.getEmail().setInboundSecret(SECRET);

InboundMessage m = message(
"<ticket-42@support.example.com>",
"<ticket-42@support.example.com>",
"support@example.com",
"RE: [ESC-00042] help");

assertThat(router.resolveTicket(m)).isEmpty();
}

@Test
void resolveTicket_signedReplyToWinsOverHeadersOnceSecretConfigured() {
properties.getEmail().setInboundSecret(SECRET);
Ticket ticket = mockTicket(7, "ESC-00007");
when(ticketRepository.findById(7L)).thenReturn(Optional.of(ticket));

InboundMessage m = message(
"<ticket-42@support.example.com>", null,
MessageIdUtil.buildReplyTo(7, SECRET, DOMAIN), "RE: [ESC-00042] help");

assertThat(router.resolveTicket(m)).contains(ticket);
}

@Test
void resolveTicket_ignoresSignedReplyToWhenSecretBlank() {
// Even a valid address signed with SOME secret must be
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package dev.escalated.services.email.inbound;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import dev.escalated.config.EscalatedProperties;
import dev.escalated.models.Reply;
import dev.escalated.models.Ticket;
import dev.escalated.models.TicketPriority;
import dev.escalated.models.TicketStatus;
import dev.escalated.repositories.TicketRepository;
import dev.escalated.services.TicketService;
import dev.escalated.services.email.MessageIdUtil;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

/**
* Who an inbound email may post as once it matches a ticket thread.
* A thread match is only a lookup: the reply is accepted only from the
* ticket's requester, and is posted as that requester.
*/
@ExtendWith(MockitoExtension.class)
class InboundEmailServiceTest {

private static final String DOMAIN = "support.example.com";
private static final String SECRET = "test-secret-for-hmac";

@Mock private TicketRepository ticketRepository;
@Mock private TicketService ticketService;

private EscalatedProperties properties;
private InboundEmailService service;

@BeforeEach
void setUp() {
properties = new EscalatedProperties();
properties.getEmail().setDomain(DOMAIN);
service = new InboundEmailService(new InboundEmailRouter(ticketRepository, properties), ticketService);

Ticket created = new Ticket();
created.setId(500L);
lenient().when(ticketService.create(anyString(), anyString(), any(), anyString(), any(), any()))
.thenReturn(created);
Reply reply = new Reply();
reply.setId(900L);
lenient().when(ticketService.addReply(anyLong(), anyString(), any(), any(), anyString(), anyBoolean()))
.thenReturn(reply);
}

private Ticket ticket(long id, String reference, String requesterEmail, TicketStatus status) {
Ticket t = new Ticket();
t.setId(id);
t.setTicketNumber(reference);
t.setRequesterName("Owner");
t.setRequesterEmail(requesterEmail);
t.setStatus(status);
return t;
}

private InboundMessage message(String from, String to, String subject, String inReplyTo) {
return new InboundMessage(from, "Sender", to, subject, "body text", null, null,
inReplyTo, null, Map.of(), List.of());
}

@Test
void strangerQuotingSubjectReference_opensNewTicket() {
Ticket owned = ticket(7001L, "ESC-07001", "owner@example.com", TicketStatus.OPEN);
when(ticketRepository.findByTicketNumber("ESC-07001")).thenReturn(Optional.of(owned));

InboundEmailService.ProcessResult result = service.process(
message("stranger@example.net", "support@example.com", "RE: [ESC-07001] Your order", null));

assertThat(result.outcome()).isEqualTo(InboundEmailService.Outcome.CREATED_NEW);
assertThat(result.ticketId()).isEqualTo(500L);
verify(ticketService, never()).addReply(anyLong(), anyString(), any(), any(), anyString(), anyBoolean());
verify(ticketService).create(eq("RE: [ESC-07001] Your order"), eq("body text"), eq("Sender"),
eq("stranger@example.net"), eq(TicketPriority.MEDIUM), eq(null));
}

@Test
void strangerThreadingOntoClosedTicket_doesNotReopenIt() {
Ticket closed = ticket(42L, "ESC-00042", "owner@example.com", TicketStatus.CLOSED);
when(ticketRepository.findById(42L)).thenReturn(Optional.of(closed));

InboundEmailService.ProcessResult result = service.process(message(
"stranger@example.net", "support@example.com", "RE: [ESC-00042] Closed",
"<ticket-42@support.example.com>"));

assertThat(result.outcome()).isEqualTo(InboundEmailService.Outcome.CREATED_NEW);
verify(ticketService, never()).changeStatus(anyLong(), any(), any());
verify(ticketService, never()).addReply(anyLong(), anyString(), any(), any(), anyString(), anyBoolean());
}

@Test
void spoofedAgentFrom_isNeverPostedAsTheAgent() {
properties.getEmail().setInboundSecret(SECRET);
Ticket owned = ticket(42L, "ESC-00042", "owner@example.com", TicketStatus.OPEN);
when(ticketRepository.findById(42L)).thenReturn(Optional.of(owned));

InboundEmailService.ProcessResult result = service.process(message(
"agent@example.com", MessageIdUtil.buildReplyTo(42L, SECRET, DOMAIN),
"RE: [ESC-00042] Update", "<ticket-42@support.example.com>"));

assertThat(result.outcome()).isEqualTo(InboundEmailService.Outcome.CREATED_NEW);
verify(ticketService, never()).addReply(anyLong(), anyString(), any(), any(), anyString(), anyBoolean());
verify(ticketService, never()).addReply(anyLong(), anyString(), any(), eq("agent@example.com"),
anyString(), anyBoolean());
}

@Test
void requesterReply_isAcceptedCaseInsensitively_postedAsRequester_andReopens() {
properties.getEmail().setInboundSecret(SECRET);
Ticket resolved = ticket(42L, "ESC-00042", "owner@example.com", TicketStatus.RESOLVED);
when(ticketRepository.findById(42L)).thenReturn(Optional.of(resolved));

InboundEmailService.ProcessResult result = service.process(message(
" Owner@Example.COM ", MessageIdUtil.buildReplyTo(42L, SECRET, DOMAIN),
"RE: Question", "<ticket-42@support.example.com>"));

assertThat(result.outcome()).isEqualTo(InboundEmailService.Outcome.REPLIED_TO_EXISTING);
assertThat(result.ticketId()).isEqualTo(42L);
assertThat(result.replyId()).isEqualTo(900L);
verify(ticketService).addReply(42L, "body text", "Owner", "owner@example.com", "inbound_email", false);
verify(ticketService).changeStatus(42L, TicketStatus.OPEN, "owner@example.com");
verify(ticketService, never()).create(anyString(), anyString(), any(), anyString(), any(), any());
}

@Test
void requesterReplyToOpenTicket_doesNotChangeStatus() {
Ticket open = ticket(42L, "ESC-00042", "owner@example.com", TicketStatus.OPEN);
when(ticketRepository.findById(42L)).thenReturn(Optional.of(open));

InboundEmailService.ProcessResult result = service.process(message(
"owner@example.com", "support@example.com", "RE: hi", "<ticket-42@support.example.com>"));

assertThat(result.outcome()).isEqualTo(InboundEmailService.Outcome.REPLIED_TO_EXISTING);
verify(ticketService, never()).changeStatus(anyLong(), any(), any());
}

@Test
void signedReplyToIsRequiredOnceSecretConfigured() {
properties.getEmail().setInboundSecret(SECRET);
Ticket owned = ticket(42L, "ESC-00042", "owner@example.com", TicketStatus.OPEN);
lenient().when(ticketRepository.findById(42L)).thenReturn(Optional.of(owned));
lenient().when(ticketRepository.findByTicketNumber("ESC-00042")).thenReturn(Optional.of(owned));

InboundEmailService.ProcessResult result = service.process(message(
"owner@example.com", "support@support.example.com", "RE: [ESC-00042] Question",
"<ticket-42@support.example.com>"));

assertThat(result.outcome()).isEqualTo(InboundEmailService.Outcome.CREATED_NEW);
verify(ticketService, never()).addReply(anyLong(), anyString(), any(), any(), anyString(), anyBoolean());
}
}
Loading