diff --git a/src/backend/main/java/ai/mnemosyne_systems/service/IncomingEmailService.java b/src/backend/main/java/ai/mnemosyne_systems/service/IncomingEmailService.java index 43007fe6..becc7051 100644 --- a/src/backend/main/java/ai/mnemosyne_systems/service/IncomingEmailService.java +++ b/src/backend/main/java/ai/mnemosyne_systems/service/IncomingEmailService.java @@ -67,9 +67,27 @@ public IncomingEmailResult processIncomingEmail(String from, String subject, Str } } if (ticket == null) { - ticket = createTicketForIncoming(sender, subject, body); + List incomingTraces = new ArrayList<>(); + incomingTraces.addAll(StackTraceExtractor.extractStackTraces(body)); + incomingTraces.addAll(StackTraceExtractor.extractStackTracesFromAttachments(attachments)); + + if (!incomingTraces.isEmpty()) { + Ticket duplicateTicket = findExistingTicketWithSameStackTrace(sender, incomingTraces); + if (duplicateTicket != null) { + ticket = duplicateTicket; + escalateTicket(ticket); + if ("Closed".equalsIgnoreCase(ticket.status) || "Resolved".equalsIgnoreCase(ticket.status)) { + ticket.status = "Open"; + ticket.persist(); + } + } + } + if (ticket == null) { - return IncomingEmailResult.ignored(); + ticket = createTicketForIncoming(sender, subject, body); + if (ticket == null) { + return IncomingEmailResult.ignored(); + } } } Message message = new Message(); @@ -192,6 +210,66 @@ private void assignCompanyTams(Ticket ticket) { } } + private Ticket findExistingTicketWithSameStackTrace(User sender, List incomingTraces) { + if (incomingTraces.isEmpty()) { + return null; + } + Company company = companyForSender(sender); + if (company == null) { + return null; + } + + Set normalizedIncoming = new HashSet<>(); + for (String trace : incomingTraces) { + normalizedIncoming.add(StackTraceExtractor.normalizeStackTrace(trace)); + } + + List companyMessages = Message + .list("select m from Message m join m.ticket t where t.company = ?1 order by t.id desc", company); + for (Message m : companyMessages) { + if (m.body != null) { + List traces = StackTraceExtractor.extractStackTraces(m.body); + for (String trace : traces) { + if (normalizedIncoming.contains(StackTraceExtractor.normalizeStackTrace(trace))) { + return m.ticket; + } + } + } + } + + List companyAttachments = Attachment.list( + "select a from Attachment a join a.message m join m.ticket t where t.company = ?1 order by t.id desc", + company); + for (Attachment a : companyAttachments) { + if (a.data != null) { + String text = new String(a.data, java.nio.charset.StandardCharsets.UTF_8); + List traces = StackTraceExtractor.extractStackTraces(text); + for (String trace : traces) { + if (normalizedIncoming.contains(StackTraceExtractor.normalizeStackTrace(trace))) { + return a.message.ticket; + } + } + } + } + + return null; + } + + private void escalateTicket(Ticket ticket) { + if (ticket == null || ticket.companyEntitlement == null) { + return; + } + CompanyEntitlement escalatedEntitlement = CompanyEntitlement + .find("company = ?1 and entitlement = ?2 and supportLevel.name = ?3", ticket.company, + ticket.companyEntitlement.entitlement, "Escalate") + .firstResult(); + if (escalatedEntitlement != null) { + ticket.companyEntitlement = escalatedEntitlement; + ticket.persist(); + LOGGER.infof("Ticket '%s' escalated to Escalate level due to duplicate stack trace", ticket.name); + } + } + public record IncomingEmailResult(boolean processed, String ticketName) { static IncomingEmailResult processed(String ticketName) { diff --git a/src/backend/main/java/ai/mnemosyne_systems/service/StackTraceExtractor.java b/src/backend/main/java/ai/mnemosyne_systems/service/StackTraceExtractor.java new file mode 100644 index 00000000..605254c9 --- /dev/null +++ b/src/backend/main/java/ai/mnemosyne_systems/service/StackTraceExtractor.java @@ -0,0 +1,93 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne_systems.service; + +import ai.mnemosyne_systems.model.Attachment; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public class StackTraceExtractor { + + public static List extractStackTraces(String text) { + List traces = new ArrayList<>(); + if (text == null || text.isBlank()) { + return traces; + } + String[] lines = text.split("\\R"); + int i = 0; + while (i < lines.length) { + String line = lines[i].trim(); + if (isFrameLine(line)) { + List block = new ArrayList<>(); + // Look backward for exception/header line + String header = null; + if (i > 0) { + String prev = lines[i - 1].trim(); + if (!isFrameLine(prev) && (prev.contains("Exception") || prev.contains("Error") + || prev.contains("Throwable") || prev.contains("at "))) { + header = prev; + } + } + if (header != null) { + block.add(header); + } + + int frameCount = 0; + while (i < lines.length) { + String curr = lines[i].trim(); + if (isFrameLine(curr) || curr.startsWith("Caused by:") + || curr.matches("^\\s*\\.\\.\\.\\s+\\d+\\s+more\\s*$")) { + block.add(curr); + if (isFrameLine(curr)) { + frameCount++; + } + i++; + } else { + break; + } + } + if (frameCount >= 2) { + traces.add(String.join("\n", block)); + } + } else { + i++; + } + } + return traces; + } + + public static List extractStackTracesFromAttachments(List attachments) { + List traces = new ArrayList<>(); + if (attachments == null) { + return traces; + } + for (Attachment attachment : attachments) { + if (attachment.data != null) { + String text = new String(attachment.data, StandardCharsets.UTF_8); + traces.addAll(extractStackTraces(text)); + } + } + return traces; + } + + public static String normalizeStackTrace(String trace) { + if (trace == null) { + return ""; + } + // Remove line numbers like :123) with ) + String withoutLineNumbers = trace.replaceAll(":\\d+\\)", ")"); + // Replace all whitespace with a single space to ignore formatting differences + return withoutLineNumbers.replaceAll("\\s+", " ").trim(); + } + + private static boolean isFrameLine(String line) { + return line.startsWith("at ") && line.contains("(") && line.contains(")"); + } +} diff --git a/src/backend/test/java/ai/mnemosyne_systems/resource/SupportAccessTest.java b/src/backend/test/java/ai/mnemosyne_systems/resource/SupportAccessTest.java index 49c6d226..9692f692 100644 --- a/src/backend/test/java/ai/mnemosyne_systems/resource/SupportAccessTest.java +++ b/src/backend/test/java/ai/mnemosyne_systems/resource/SupportAccessTest.java @@ -730,6 +730,58 @@ void incomingEmailWithoutTicketSubjectCreatesTicket() { Assertions.assertTrue(saved.ticket.name.contains("-")); } + @Test + void incomingEmailWithDuplicateStackTraceUpdatesExistingTicketAndEscalates() { + mailbox.clear(); + ensureUser("user", "user@mnemosyne-systems.ai", User.TYPE_USER, "user"); + Long companyId = ensureCompany("Incoming Deduplication Co"); + ensureCompanyUsers(companyId, "user@mnemosyne-systems.ai"); + + ensureEscalateEntitlementSupport(companyId); + + String stackTrace = "java.lang.NullPointerException: test exception\n" + + " at com.example.TestService.execute(TestService.java:23)\n" + + " at com.example.TestService.run(TestService.java:10)"; + + String body1 = "We hit this error:\n" + stackTrace; + + RestAssured.given().contentType("multipart/form-data").multiPart("from", "user@mnemosyne-systems.ai") + .multiPart("subject", "Crash report").multiPart("body", body1).post("/mail/incoming").then() + .statusCode(200); + + Message saved1 = findMessageByBody(body1); + Assertions.assertNotNull(saved1); + Ticket ticket = saved1.ticket; + Assertions.assertNotNull(ticket); + + Assertions.assertEquals("Normal", ticket.companyEntitlement.supportLevel.name); + + setTicketStatus(ticket.id, "Closed"); + + String body2 = "Help, it happened again!\n" + stackTrace; + RestAssured.given().contentType("multipart/form-data").multiPart("from", "user@mnemosyne-systems.ai") + .multiPart("subject", "Second crash report").multiPart("body", body2).post("/mail/incoming").then() + .statusCode(200); + + Message saved2 = findMessageByBody(body2); + Assertions.assertNotNull(saved2); + Assertions.assertEquals(ticket.id, saved2.ticket.id); + + Ticket refreshed = refreshedTicket(ticket.id); + Assertions.assertEquals("Open", refreshed.status); + Assertions.assertEquals("Escalate", refreshed.companyEntitlement.supportLevel.name); + } + + @Transactional + void ensureEscalateEntitlementSupport(Long companyId) { + Company company = Company.findById(companyId); + Entitlement entitlement = ensureEntitlement("Starter", "Email support"); + Level normal = ensureLevel("Normal", "Normal response", 1440, "White"); + Level escalate = ensureLevel("Escalate", "Escalate response", 120, "Yellow"); + ensureCompanyEntitlement(company, entitlement, normal); + ensureCompanyEntitlement(company, entitlement, escalate); + } + @Test void incomingEmailIgnoresUnknownOrMismatchedFrom() { mailbox.clear(); diff --git a/src/backend/test/java/ai/mnemosyne_systems/service/StackTraceExtractorTest.java b/src/backend/test/java/ai/mnemosyne_systems/service/StackTraceExtractorTest.java new file mode 100644 index 00000000..694b51b9 --- /dev/null +++ b/src/backend/test/java/ai/mnemosyne_systems/service/StackTraceExtractorTest.java @@ -0,0 +1,83 @@ +/* + * Eclipse Public License - v 2.0 + * + * THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + * PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + * OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + */ + +package ai.mnemosyne_systems.service; + +import ai.mnemosyne_systems.model.Attachment; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class StackTraceExtractorTest { + + @Test + public void testExtractStackTracesFromText() { + String body = "Hey developer,\n" + "I encountered the following issue in production:\n\n" + + "java.lang.NullPointerException: Cannot invoke \"String.trim()\" because \"body\" is null\n" + + " at ai.mnemosyne_systems.service.IncomingEmailService.processIncomingEmail(IncomingEmailService.java:44)\n" + + " at ai.mnemosyne_systems.resource.IncomingEmailResource.process(IncomingEmailResource.java:52)\n" + + "\n" + "Please fix this ASAP!"; + + List traces = StackTraceExtractor.extractStackTraces(body); + Assertions.assertEquals(1, traces.size()); + + String trace = traces.get(0); + Assertions.assertTrue(trace.contains("java.lang.NullPointerException")); + Assertions.assertTrue(trace.contains("processIncomingEmail")); + Assertions.assertTrue(trace.contains("process")); + } + + @Test + public void testExtractStackTracesFromAttachments() { + Attachment attachment = new Attachment(); + attachment.name = "error.log"; + attachment.mimeType = "text/plain"; + attachment.data = ("Uncaught exception:\n" + "java.lang.RuntimeException: Something went wrong\n" + + " at ai.mnemosyne_systems.service.SomeService.doWork(SomeService.java:12)\n" + + " at ai.mnemosyne_systems.service.SomeService.start(SomeService.java:8)\n" + + "Caused by: java.io.IOException: Disk full\n" + + " at java.base/java.io.FileOutputStream.writeBytes(Native Method)\n" + " ... 3 more") + .getBytes(StandardCharsets.UTF_8); + + List traces = StackTraceExtractor + .extractStackTracesFromAttachments(Collections.singletonList(attachment)); + Assertions.assertEquals(1, traces.size()); + + String trace = traces.get(0); + Assertions.assertTrue(trace.contains("java.lang.RuntimeException")); + Assertions.assertTrue(trace.contains("doWork")); + Assertions.assertTrue(trace.contains("Disk full")); + } + + @Test + public void testNormalizeStackTrace() { + String trace1 = "java.lang.NullPointerException\n" + + " at ai.mnemosyne_systems.service.IncomingEmailService.processIncomingEmail(IncomingEmailService.java:44)\n" + + " at ai.mnemosyne_systems.resource.IncomingEmailResource.process(IncomingEmailResource.java:52)"; + + String trace2 = "java.lang.NullPointerException\n" + + " at ai.mnemosyne_systems.service.IncomingEmailService.processIncomingEmail(IncomingEmailService.java:46)\n" + + " at ai.mnemosyne_systems.resource.IncomingEmailResource.process(IncomingEmailResource.java:53)"; + + String normalized1 = StackTraceExtractor.normalizeStackTrace(trace1); + String normalized2 = StackTraceExtractor.normalizeStackTrace(trace2); + + Assertions.assertEquals(normalized1, normalized2); + Assertions.assertFalse(normalized1.contains("44")); + Assertions.assertFalse(normalized2.contains("46")); + } + + @Test + public void testNoStackTrace() { + String body = "Hello, I have a question about how to set up SSO on the business plan."; + List traces = StackTraceExtractor.extractStackTraces(body); + Assertions.assertTrue(traces.isEmpty()); + } +}