diff --git a/src/main/java/dev/escalated/controllers/InboundEmailController.java b/src/main/java/dev/escalated/controllers/InboundEmailController.java index 0ad3386..52c1c92 100644 --- a/src/main/java/dev/escalated/controllers/InboundEmailController.java +++ b/src/main/java/dev/escalated/controllers/InboundEmailController.java @@ -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; @@ -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 parsersByName; public InboundEmailController( EscalatedProperties properties, - InboundEmailRouter router, + InboundEmailService inboundService, List parsers) { this.properties = properties; - this.router = router; + this.inboundService = inboundService; Map byName = new HashMap<>(); for (InboundEmailParser p : parsers) { byName.put(p.name().toLowerCase(), p); @@ -88,11 +86,19 @@ public ResponseEntity> inbound( return ResponseEntity.badRequest().body(Map.of("error", "invalid payload")); } - Optional 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 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); } diff --git a/src/main/java/dev/escalated/services/email/inbound/InboundEmailService.java b/src/main/java/dev/escalated/services/email/inbound/InboundEmailService.java new file mode 100644 index 0000000..0c38df9 --- /dev/null +++ b/src/main/java/dev/escalated/services/email/inbound/InboundEmailService.java @@ -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: + *
parser output → router resolution → reply-on-existing or
+ * create-new-ticket
+ * + *

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}. + * + *

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 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 pendingDownloads(InboundMessage message) { + List 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 pendingAttachmentDownloads + ) { + } + + public record PendingAttachment( + String name, + String contentType, + Long sizeBytes, + String downloadUrl + ) { + } +}