Skip to content
Open
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
26 changes: 16 additions & 10 deletions src/main/java/dev/escalated/controllers/InboundEmailController.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
package dev.escalated.controllers;

import dev.escalated.config.EscalatedProperties;
import dev.escalated.models.Ticket;
import dev.escalated.services.email.inbound.InboundEmailParser;
import dev.escalated.services.email.inbound.InboundEmailRouter;
import dev.escalated.services.email.inbound.InboundEmailService;
import dev.escalated.services.email.inbound.InboundMessage;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -44,15 +42,15 @@ public class InboundEmailController {
private static final Logger log = LoggerFactory.getLogger(InboundEmailController.class);

private final EscalatedProperties properties;
private final InboundEmailRouter router;
private final InboundEmailService inboundService;
private final Map<String, InboundEmailParser> parsersByName;

public InboundEmailController(
EscalatedProperties properties,
InboundEmailRouter router,
InboundEmailService inboundService,
List<InboundEmailParser> parsers) {
this.properties = properties;
this.router = router;
this.inboundService = inboundService;
Map<String, InboundEmailParser> byName = new HashMap<>();
for (InboundEmailParser p : parsers) {
byName.put(p.name().toLowerCase(), p);
Expand Down Expand Up @@ -88,11 +86,19 @@ public ResponseEntity<Map<String, Object>> inbound(
return ResponseEntity.badRequest().body(Map.of("error", "invalid payload"));
}

Optional<Ticket> resolved = router.resolveTicket(message);
String status = resolved.isPresent() ? "matched" : "unmatched";
InboundEmailService.ProcessResult result;
try {
result = inboundService.process(message);
} catch (RuntimeException ex) {
log.error("[InboundEmailController] process failed: {}", ex.getMessage());
return ResponseEntity.status(500).body(Map.of("error", "processing failed"));
}

Map<String, Object> response = new HashMap<>();
response.put("status", status);
response.put("ticketId", resolved.map(Ticket::getId).orElse(null));
response.put("outcome", result.outcome().name().toLowerCase());
response.put("ticketId", result.ticketId());
response.put("replyId", result.replyId());
response.put("pendingAttachmentDownloads", result.pendingAttachmentDownloads());
return ResponseEntity.accepted().body(response);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package dev.escalated.services.email.inbound;

import dev.escalated.models.Reply;
import dev.escalated.models.Ticket;
import dev.escalated.models.TicketPriority;
import dev.escalated.services.TicketService;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

/**
* Orchestrates the full inbound email pipeline:
* <pre>parser output → router resolution → reply-on-existing or
* create-new-ticket</pre>
*
* <p>Called from {@link dev.escalated.controllers.InboundEmailController}
* after the parser normalizes the provider payload. Mirrors the
* NestJS reference {@code InboundRouterService} and the .NET
* {@code InboundEmailService}.
*
* <p>Attachment persistence is out of scope here: provider-hosted
* attachments (Mailgun) carry their {@code downloadUrl} through to
* {@link ProcessResult#pendingAttachmentDownloads()} so a follow-up
* worker can fetch + persist out-of-band.
*/
@Service
public class InboundEmailService {

private static final Logger log = LoggerFactory.getLogger(InboundEmailService.class);

private final InboundEmailRouter router;
private final TicketService ticketService;

public InboundEmailService(InboundEmailRouter router, TicketService ticketService) {
this.router = router;
this.ticketService = ticketService;
}

/**
* Process a parsed inbound message. Returns a {@link ProcessResult}
* carrying the outcome (matched + reply id, created new ticket
* id, or skipped).
*/
public ProcessResult process(InboundMessage message) {
Optional<Ticket> ticketMatch = router.resolveTicket(message);

if (ticketMatch.isPresent()) {
Ticket ticket = ticketMatch.get();
Reply reply = ticketService.addReply(
ticket.getId(),
message.body(),
message.fromName(),
message.fromEmail(),
"inbound_email",
false
);
return new ProcessResult(
Outcome.REPLIED_TO_EXISTING,
ticket.getId(),
reply.getId(),
pendingDownloads(message)
);
}

if (isNoiseEmail(message)) {
return new ProcessResult(Outcome.SKIPPED, null, null, List.of());
}

Ticket newTicket = ticketService.create(
nonBlankOr(message.subject(), "(no subject)"),
message.body(),
message.fromName(),
message.fromEmail(),
TicketPriority.MEDIUM,
null
);
log.info("[InboundEmailService] Created ticket #{} from inbound email", newTicket.getId());

return new ProcessResult(
Outcome.CREATED_NEW,
newTicket.getId(),
null,
pendingDownloads(message)
);
}

/**
* Noise emails: empty body + empty subject, or from common
* bounce/no-reply senders.
*/
static boolean isNoiseEmail(InboundMessage message) {
if ("no-reply@sns.amazonaws.com".equalsIgnoreCase(message.fromEmail())) {
return true;
}
boolean bodyEmpty = message.body() == null || message.body().isBlank();
boolean subjectEmpty = message.subject() == null || message.subject().isBlank();
return bodyEmpty && subjectEmpty;
}

private static String nonBlankOr(String value, String fallback) {
return (value == null || value.isBlank()) ? fallback : value;
}

private static List<PendingAttachment> pendingDownloads(InboundMessage message) {
List<PendingAttachment> list = new ArrayList<>();
if (message.attachments() == null) {
return list;
}
for (InboundAttachment a : message.attachments()) {
if (a.downloadUrl() != null && a.content() == null) {
list.add(new PendingAttachment(
a.name(),
a.contentType(),
a.sizeBytes(),
a.downloadUrl()
));
}
}
return list;
}

public enum Outcome {
REPLIED_TO_EXISTING,
CREATED_NEW,
SKIPPED
}

public record ProcessResult(
Outcome outcome,
Long ticketId,
Long replyId,
List<PendingAttachment> pendingAttachmentDownloads
) {
}

public record PendingAttachment(
String name,
String contentType,
Long sizeBytes,
String downloadUrl
) {
}
}