完善 Debug EML 上传调试链路

This commit is contained in:
andy
2026-07-09 15:25:26 +08:00
parent fcb30d460a
commit 6d2b3e8ccd
13 changed files with 615 additions and 56 deletions

View File

@@ -17,6 +17,9 @@ import java.util.Map;
* @param originalEmlSha256 原始 .eml SHA-256
* @param uploadedMedia 上传后的媒体列表
* @param htmlBodyWithOssUrls 替换 cid 后的 HTML
* @param htmlBodySanitized 后端清洗后的 HTML前端调试展示应优先使用
* @param htmlSanitizeRequired 是否要求前端按安全 HTML 规则展示
* @param htmlRenderMode HTML 渲染建议模式
* @param agentbusLikePayload 发送给 SuperAgent 的结构化 payload
* @param superagentSessionId SuperAgent session ID
* @param superagentRunId SuperAgent run ID
@@ -44,6 +47,12 @@ public record DebugEmlSuperAgentRunResult(
List<DebugEmlUploadedMediaResult> uploadedMedia,
@JsonProperty("html_body_with_oss_urls")
String htmlBodyWithOssUrls,
@JsonProperty("html_body_sanitized")
String htmlBodySanitized,
@JsonProperty("html_sanitize_required")
Boolean htmlSanitizeRequired,
@JsonProperty("html_render_mode")
String htmlRenderMode,
@JsonProperty("agentbus_like_payload")
Map<String, Object> agentbusLikePayload,
@JsonProperty("superagent_session_id")

View File

@@ -25,9 +25,11 @@ import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMed
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.EmlMessageParseService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageHtmlSanitizerService;
import cn.nianxx.thhotel.platform.message.service.impl.EmlMessageParseException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -40,6 +42,8 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -54,12 +58,15 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
private static final String SOURCE_CHANNEL = "EMAIL";
private static final String SCHEMA_VERSION = "debug-eml-upload-v1";
private static final DateTimeFormatter DATE_FOLDER_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE;
private static final Pattern CID_REFERENCE_PATTERN = Pattern.compile(
"(?i)cid:(?:<[^>]+>|%3c[^\\s\"'>]+%3e|[^\\s\"'>]+)");
private final DebugEmlSuperAgentProperties properties;
private final AliyunOssProperties ossProperties;
private final EmlMessageParseService parseService;
private final ObjectStorageService objectStorageService;
private final SourceMessageCaptureService sourceMessageCaptureService;
private final SourceMessageHtmlSanitizerService htmlSanitizerService;
private final SuperAgentOpenApiClient superAgentOpenApiClient;
private final DebugEmlSuperAgentRunRepository runRepository;
private final ObjectMapper objectMapper;
@@ -73,6 +80,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
EmlMessageParseService parseService,
ObjectStorageService objectStorageService,
SourceMessageCaptureService sourceMessageCaptureService,
SourceMessageHtmlSanitizerService htmlSanitizerService,
SuperAgentOpenApiClient superAgentOpenApiClient,
DebugEmlSuperAgentRunRepository runRepository,
ObjectMapper objectMapper) {
@@ -81,6 +89,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
this.parseService = parseService;
this.objectStorageService = objectStorageService;
this.sourceMessageCaptureService = sourceMessageCaptureService;
this.htmlSanitizerService = htmlSanitizerService;
this.superAgentOpenApiClient = superAgentOpenApiClient;
this.runRepository = runRepository;
this.objectMapper = objectMapper;
@@ -158,8 +167,10 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
LocalDateTime createdAt) throws Exception {
String sha256 = sha256(emlBytes);
ParsedEmlMessage parsed = parseService.parse(emlBytes, safeFileName);
String externalMessageId = firstNonBlank(parsed.messageId(), "debug-eml-" + sha256.substring(0, 12));
String externalConversationId = firstNonBlank(parsed.conversationId(), "debug-eml-thread-" + externalMessageId);
String originalMessageId = parsed.messageId();
String originalConversationId = parsed.conversationId();
String externalMessageId = debugExternalMessageId(runId, sha256);
String externalConversationId = firstNonBlank(originalConversationId, "debug-eml-thread-" + externalMessageId);
List<String> warnings = new ArrayList<>();
List<UploadedMedia> uploadedMedia = new ArrayList<>();
@@ -175,11 +186,15 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
}
String htmlWithOssUrls = replaceCidReferences(parsed.htmlBody(), uploadedMedia, warnings);
String htmlBodySanitized = htmlSanitizerService.sanitizeHtml(htmlWithOssUrls);
String htmlRenderMode = htmlSanitizerService.htmlRenderMode(htmlWithOssUrls);
Map<String, Object> payload = buildAgentBusLikePayload(
runId,
runLabel,
externalMessageId,
externalConversationId,
originalMessageId,
originalConversationId,
parsed,
htmlWithOssUrls,
uploadedMedia);
@@ -254,6 +269,9 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
sha256,
uploadedMedia.stream().map(UploadedMedia::result).toList(),
htmlWithOssUrls,
htmlBodySanitized,
true,
htmlRenderMode,
payload,
superAgentResult.sessionId(),
superAgentResult.runId(),
@@ -390,18 +408,28 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
warnings.add("EML 未解析到 HTML 正文。");
return htmlBody;
}
String replaced = htmlBody;
Map<String, String> cidUrlMap = new LinkedHashMap<>();
for (UploadedMedia media : uploadedMedia) {
if (media.contentId() == null || media.contentId().isBlank()) {
continue;
}
replaced = replaced.replace("cid:" + media.contentId(), media.result().externalUrl());
replaced = replaced.replace("cid:<" + media.contentId() + ">", media.result().externalUrl());
cidUrlMap.put(normalizeCidValue(media.contentId()), media.result().externalUrl());
}
if (replaced.contains("cid:")) {
Matcher matcher = CID_REFERENCE_PATTERN.matcher(htmlBody);
StringBuffer replaced = new StringBuffer();
while (matcher.find()) {
String externalUrl = cidUrlMap.get(normalizeCidReference(matcher.group()));
if (externalUrl == null) {
continue;
}
matcher.appendReplacement(replaced, Matcher.quoteReplacement(externalUrl));
}
matcher.appendTail(replaced);
String result = replaced.toString();
if (CID_REFERENCE_PATTERN.matcher(result).find()) {
warnings.add("HTML 正文仍包含未匹配的 cid 图片引用。");
}
return replaced;
return result;
}
/**
@@ -412,6 +440,8 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
String runLabel,
String externalMessageId,
String externalConversationId,
String originalMessageId,
String originalConversationId,
ParsedEmlMessage parsed,
String htmlWithOssUrls,
List<UploadedMedia> uploadedMedia) {
@@ -420,6 +450,8 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
source.put("provider", SOURCE_PROVIDER);
source.put("external_message_id", externalMessageId);
source.put("external_conversation_id", externalConversationId);
source.put("original_message_id", originalMessageId);
source.put("original_conversation_id", originalConversationId);
source.put("sender", parsed.sender());
source.put("subject", parsed.subject());
source.put("sent_at", parsed.sentAt() == null ? null : parsed.sentAt().toString());
@@ -590,6 +622,48 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
+ "/" + fileName;
}
/**
* 生成 Debug 专属外部消息 ID避免重复上传同一封 EML 时命中 SourceMessage 幂等复用。
*/
private String debugExternalMessageId(Long runId, String sha256) {
return "debug-eml-run-" + runId + "-" + sha256.substring(0, 12);
}
/**
* 规范化 HTML 中匹配到的 cid: 引用。
*/
private String normalizeCidReference(String cidReference) {
if (cidReference == null || cidReference.length() <= 4) {
return "";
}
return normalizeCidValue(cidReference.substring(4));
}
/**
* 规范化 Content-ID兼容尖括号、URL 编码和大小写差异。
*/
private String normalizeCidValue(String value) {
if (value == null) {
return "";
}
String normalized = urlDecode(value.trim());
if (normalized.startsWith("<") && normalized.endsWith(">") && normalized.length() > 2) {
normalized = normalized.substring(1, normalized.length() - 1);
}
return normalized.trim().toLowerCase(Locale.ROOT);
}
/**
* URL 解码 cid 内容;保留加号字面值,解码失败时保留原始值继续匹配。
*/
private String urlDecode(String value) {
try {
return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8);
} catch (Exception exception) {
return value;
}
}
/**
* 文件名安全清洗,避免路径穿越和日志污染。
*/

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.platform.message.service;
/**
* SourceMessage HTML 清洗服务。统一邮件会话和 Debug 邮件上传的 HTML 安全展示口径。
*/
public interface SourceMessageHtmlSanitizerService {
/**
* 清洗邮件 HTML移除脚本标签、事件属性、样式属性和危险协议链接。
*/
String sanitizeHtml(String htmlBody);
/**
* 返回前端渲染建议;有 HTML 时优先使用后端清洗后的安全 HTML。
*/
String htmlRenderMode(String htmlBody);
}

View File

@@ -150,7 +150,7 @@ public class EmlMessageParseServiceImpl implements EmlMessageParseService {
}
/**
* 解析会话 ID优先使用 References / In-Reply-To 的第一项,缺失时使用 Message-ID。
* 解析会话 ID优先使用 References / In-Reply-To 的第一项,其次使用 Thread-Index最后回退 Message-ID。
*/
private String resolveConversationId(MimeMessage message, String messageId) throws MessagingException {
String references = message.getHeader("References", null);
@@ -159,7 +159,11 @@ public class EmlMessageParseServiceImpl implements EmlMessageParseService {
return firstReference;
}
String inReplyTo = firstMessageId(message.getHeader("In-Reply-To", null));
return inReplyTo == null ? messageId : inReplyTo;
if (inReplyTo != null) {
return inReplyTo;
}
String threadIndex = trimToNull(message.getHeader("Thread-Index", null));
return threadIndex == null ? messageId : threadIndex;
}
/**
@@ -197,6 +201,17 @@ public class EmlMessageParseServiceImpl implements EmlMessageParseService {
return normalizeMessageId(value);
}
/**
* 空白字符串转 null。
*/
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
/**
* 读取 MIME part 的首个头字段值,缺失时返回 null。
*/

View File

@@ -13,12 +13,12 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalMed
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageRelatedContextResult;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
import cn.nianxx.thhotel.platform.message.service.SourceMessageConversationService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageHtmlSanitizerService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageRelatedContextProvider;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -32,32 +32,21 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
private static final String ACCESS_SCENE = "source-message-conversation";
private static final String MEDIA_TYPE_INLINE_IMAGE = "INLINE_IMAGE";
private static final String MEDIA_TYPE_ATTACHMENT = "ATTACHMENT";
private static final String HTML_RENDER_MODE_SANITIZED = "SANITIZED_HTML";
private static final String HTML_RENDER_MODE_TEXT_ONLY = "TEXT_ONLY";
private static final Pattern DANGEROUS_TAG_PATTERN = Pattern.compile(
"(?is)<\\s*(script|style|iframe|object|embed|meta|link|base|form)[^>]*>.*?<\\s*/\\s*\\1\\s*>"
+ "|<\\s*(script|style|iframe|object|embed|meta|link|base|form)[^>]*/?\\s*>");
private static final Pattern EVENT_ATTRIBUTE_PATTERN = Pattern.compile(
"(?i)\\s+on[a-z0-9_-]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
private static final Pattern STYLE_ATTRIBUTE_PATTERN = Pattern.compile(
"(?i)\\s+style\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
private static final Pattern DANGEROUS_URL_ATTRIBUTE_PATTERN = Pattern.compile(
"(?i)\\s+(href|src|xlink:href|formaction)\\s*=\\s*"
+ "(\"\\s*(?:javascript|data|vbscript):[^\"]*\""
+ "|'\\s*(?:javascript|data|vbscript):[^']*'"
+ "|\\s*(?:javascript|data|vbscript):[^\\s>]+)");
private final SourceMessageInboxRepository inboxRepository;
private final List<SourceMessageRelatedContextProvider> relatedContextProviders;
private final SourceMessageHtmlSanitizerService htmlSanitizerService;
/**
* 注入 SourceMessage 持久化端口和业务关联上下文 Provider 列表。
*/
public SourceMessageConversationServiceImpl(
SourceMessageInboxRepository inboxRepository,
List<SourceMessageRelatedContextProvider> relatedContextProviders) {
List<SourceMessageRelatedContextProvider> relatedContextProviders,
SourceMessageHtmlSanitizerService htmlSanitizerService) {
this.inboxRepository = inboxRepository;
this.relatedContextProviders = relatedContextProviders == null ? List.of() : relatedContextProviders;
this.htmlSanitizerService = htmlSanitizerService;
}
/**
@@ -127,9 +116,9 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
UtcTimeFormatter.toUtcOffsetDateTime(message.sourceSentAt()),
originalContent.textBody(),
htmlBody,
sanitizeHtml(htmlBody),
htmlSanitizerService.sanitizeHtml(htmlBody),
true,
htmlRenderMode(htmlBody),
htmlSanitizerService.htmlRenderMode(htmlBody),
filterMedia(originalContent.mediaItems(), MEDIA_TYPE_INLINE_IMAGE),
filterMedia(originalContent.mediaItems(), MEDIA_TYPE_ATTACHMENT),
relatedContext.relatedOrders(),
@@ -192,26 +181,4 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
item.externalMediaId());
}
/**
* 第一版 HTML 清洗:保留邮件正文基本结构,移除脚本标签、事件属性和危险协议链接。
*/
private String sanitizeHtml(String htmlBody) {
if (htmlBody == null) {
return null;
}
String sanitized = DANGEROUS_TAG_PATTERN.matcher(htmlBody).replaceAll("");
sanitized = EVENT_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll("");
sanitized = STYLE_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll("");
return DANGEROUS_URL_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll("");
}
/**
* 返回前端渲染建议;有 HTML 时优先使用后端清洗后的安全 HTML。
*/
private String htmlRenderMode(String htmlBody) {
if (htmlBody == null || htmlBody.isBlank()) {
return HTML_RENDER_MODE_TEXT_ONLY;
}
return HTML_RENDER_MODE_SANITIZED;
}
}

View File

@@ -0,0 +1,52 @@
package cn.nianxx.thhotel.platform.message.service.impl;
import cn.nianxx.thhotel.platform.message.service.SourceMessageHtmlSanitizerService;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/**
* SourceMessage HTML 清洗服务实现。第一版使用白盒正则策略,后续可替换为专用 HTML sanitizer。
*/
@Service
public class SourceMessageHtmlSanitizerServiceImpl implements SourceMessageHtmlSanitizerService {
private static final String HTML_RENDER_MODE_SANITIZED = "SANITIZED_HTML";
private static final String HTML_RENDER_MODE_TEXT_ONLY = "TEXT_ONLY";
private static final Pattern DANGEROUS_TAG_PATTERN = Pattern.compile(
"(?is)<\\s*(script|style|iframe|object|embed|meta|link|base|form)[^>]*>.*?<\\s*/\\s*\\1\\s*>"
+ "|<\\s*(script|style|iframe|object|embed|meta|link|base|form)[^>]*/?\\s*>");
private static final Pattern EVENT_ATTRIBUTE_PATTERN = Pattern.compile(
"(?i)\\s+on[a-z0-9_-]+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
private static final Pattern STYLE_ATTRIBUTE_PATTERN = Pattern.compile(
"(?i)\\s+style\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
private static final Pattern DANGEROUS_URL_ATTRIBUTE_PATTERN = Pattern.compile(
"(?i)\\s+(href|src|xlink:href|formaction)\\s*=\\s*"
+ "(\"\\s*(?:javascript|data|vbscript):[^\"]*\""
+ "|'\\s*(?:javascript|data|vbscript):[^']*'"
+ "|\\s*(?:javascript|data|vbscript):[^\\s>]+)");
/**
* 清洗邮件 HTML保留正文基本结构并移除高风险内容。
*/
@Override
public String sanitizeHtml(String htmlBody) {
if (htmlBody == null) {
return null;
}
String sanitized = DANGEROUS_TAG_PATTERN.matcher(htmlBody).replaceAll("");
sanitized = EVENT_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll("");
sanitized = STYLE_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll("");
return DANGEROUS_URL_ATTRIBUTE_PATTERN.matcher(sanitized).replaceAll("");
}
/**
* 根据 HTML 是否存在返回前端渲染模式。
*/
@Override
public String htmlRenderMode(String htmlBody) {
if (htmlBody == null || htmlBody.isBlank()) {
return HTML_RENDER_MODE_TEXT_ONLY;
}
return HTML_RENDER_MODE_SANITIZED;
}
}

View File

@@ -5,6 +5,7 @@ import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
@@ -99,13 +100,18 @@ class DebugEmlSuperAgentControllerTest {
.andExpect(jsonPath("$.debug_run_id").isNotEmpty())
.andExpect(jsonPath("$.source_message_id").isNotEmpty())
.andExpect(jsonPath("$.source_provider").value("DEBUG_EML_UPLOAD"))
.andExpect(jsonPath("$.external_message_id").value("debug-controller-message-001@example.test"))
.andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")))
.andExpect(jsonPath("$.original_eml_oss_url", containsString("/raw/debug-booking.eml")))
.andExpect(jsonPath("$.uploaded_media", hasSize(greaterThanOrEqualTo(3))))
.andExpect(jsonPath("$.html_body_with_oss_urls", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:inline-001"))))
.andExpect(jsonPath("$.html_body_sanitized", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.html_sanitize_required").value(true))
.andExpect(jsonPath("$.html_render_mode").value("SANITIZED_HTML"))
.andExpect(jsonPath("$.agentbus_like_payload.schema_version").value("debug-eml-upload-v1"))
.andExpect(jsonPath("$.agentbus_like_payload.source.provider").value("DEBUG_EML_UPLOAD"))
.andExpect(jsonPath("$.agentbus_like_payload.source.original_message_id")
.value("debug-controller-message-001@example.test"))
.andExpect(jsonPath("$.agentbus_like_payload.reply_policy.mode").value("debug_only"))
.andExpect(jsonPath("$.superagent_session_id").value("session-debug-001"))
.andExpect(jsonPath("$.superagent_run_id").value("run-debug-001"))
@@ -122,6 +128,7 @@ class DebugEmlSuperAgentControllerTest {
AND inbox.provider = 'DEBUG_EML_UPLOAD'
AND inbox.channel = 'EMAIL'
AND payload.schema_version = 'debug-eml-upload-v1'
AND payload.payload_json LIKE '%controller-test%'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(1L);
@@ -129,8 +136,10 @@ class DebugEmlSuperAgentControllerTest {
SELECT COUNT(*)
FROM platform_source_message_media media
JOIN platform_source_message_inbox inbox ON inbox.id = media.inbox_id
JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
WHERE inbox.provider = 'DEBUG_EML_UPLOAD'
AND media.media_type = 'ORIGINAL_EMAIL'
AND payload.payload_json LIKE '%controller-test%'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(originalEmailMediaCount).isEqualTo(1L);
@@ -141,10 +150,68 @@ class DebugEmlSuperAgentControllerTest {
AND run_status = 'SUPERAGENT_SUCCEEDED'
AND superagent_session_id = 'session-debug-001'
AND superagent_run_id = 'run-debug-001'
AND run_label = 'controller-test'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(debugRunCount).isEqualTo(1L);
}
@Test
void shouldCreateIndependentSourceMessageForRepeatedDebugUpload() throws Exception {
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(emlFile())
.param("hotel_id", "HOTEL-TEST")
.param("run_label", "repeat-debug-upload")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")));
reset(objectStorageService, superAgentOpenApiClient);
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(emlFile())
.param("hotel_id", "HOTEL-TEST")
.param("run_label", "repeat-debug-upload")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.external_message_id", containsString("debug-eml-run-")));
Long sourceCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM platform_source_message_inbox inbox
JOIN platform_source_message_payload payload ON payload.inbox_id = inbox.id
WHERE inbox.hotel_id = 'HOTEL-TEST'
AND inbox.provider = 'DEBUG_EML_UPLOAD'
AND inbox.channel = 'EMAIL'
AND inbox.external_message_id LIKE 'debug-eml-run-%'
AND payload.payload_json LIKE '%repeat-debug-upload%'
""", Long.class);
org.assertj.core.api.Assertions.assertThat(sourceCount).isEqualTo(2L);
}
@Test
void shouldSanitizeDebugHtmlAndReplaceUpperCaseCidReferences() throws Exception {
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(unsafeHtmlEmlFile())
.param("hotel_id", "HOTEL-TEST")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.html_body_with_oss_urls", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("CID:inline-001"))))
.andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:%3Cinline-001%3E"))))
.andExpect(jsonPath("$.html_body_with_oss_urls", not(containsString("cid:%3Cinline%2Bplus%3E"))))
.andExpect(jsonPath("$.html_body_sanitized", not(containsString("<script"))))
.andExpect(jsonPath("$.html_body_sanitized", not(containsString("onerror"))))
.andExpect(jsonPath("$.html_body_sanitized", not(containsString("javascript:"))))
.andExpect(jsonPath("$.html_body_sanitized", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.html_sanitize_required").value(true))
.andExpect(jsonPath("$.html_render_mode").value("SANITIZED_HTML"));
}
@Test
void shouldKeepCapturedSourceMessageWhenSuperAgentFails() throws Exception {
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
@@ -179,6 +246,28 @@ class DebugEmlSuperAgentControllerTest {
org.assertj.core.api.Assertions.assertThat(linkedFailedRunCount).isEqualTo(1L);
}
private void mockStorageAndSuperAgentSuccess() {
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
ObjectStoragePutRequest request = invocation.getArgument(0);
return new ObjectStoragePutResult(
request.objectKey(),
"https://oss.example.test/" + request.objectKey(),
request.contentType(),
request.sizeBytes());
});
when(superAgentOpenApiClient.invokeMailDebug(any())).thenReturn(new SuperAgentOpenApiResult(
"session-debug-001",
"run-debug-001",
"profile-debug",
"profile-version-debug",
"debug-model",
"{\"ai_task_results\":[{\"task_type\":\"New Booking\"}]}",
11,
7,
18,
List.of("metadata", "values", "end")));
}
private MockMultipartFile emlFile() {
return new MockMultipartFile(
"file",
@@ -187,6 +276,14 @@ class DebugEmlSuperAgentControllerTest {
emlBytes());
}
private MockMultipartFile unsafeHtmlEmlFile() {
return new MockMultipartFile(
"file",
"unsafe-debug-booking.eml",
MediaType.TEXT_PLAIN_VALUE,
unsafeHtmlEmlBytes());
}
private byte[] emlBytes() {
return """
From: Guest <guest@example.test>
@@ -227,4 +324,37 @@ class DebugEmlSuperAgentControllerTest {
--rel-boundary--
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
private byte[] unsafeHtmlEmlBytes() {
return """
From: Guest <guest@example.test>
To: Reservations <reservations@example.test>
Subject: Unsafe Debug Booking
Date: Thu, 09 Jul 2026 01:30:00 +0000
Message-ID: <unsafe-debug-controller-message-001@example.test>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel-boundary"
--rel-boundary
Content-Type: text/html; charset=UTF-8
<html><body><img src="CID:inline-001" onerror="alert(1)"><img src="cid:%3Cinline-001%3E"><img src="cid:%3Cinline%2Bplus%3E"><a href="javascript:alert(2)">bad</a><script>alert(3)</script></body></html>
--rel-boundary
Content-Type: image/png; name="inline.png"
Content-Transfer-Encoding: base64
Content-ID: <inline-001>
Content-Disposition: inline; filename="inline.png"
aW5saW5lLWltYWdl
--rel-boundary
Content-Type: image/png; name="inline-plus.png"
Content-Transfer-Encoding: base64
Content-ID: <inline+plus>
Content-Disposition: inline; filename="inline-plus.png"
aW5saW5lLXBsdXM=
--rel-boundary--
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
}

View File

@@ -40,6 +40,15 @@ class EmlMessageParseServiceImplTest {
assertThat(attachment.bytes()).isEqualTo("pdf-content".getBytes(StandardCharsets.UTF_8));
}
@Test
void shouldUseThreadIndexAsConversationIdWhenReplyHeadersMissing() {
ParsedEmlMessage message = parseService.parse(threadIndexEmlBytes(), "thread-index.eml");
assertThat(message.messageId()).isEqualTo("thread-index-message-001@example.test");
assertThat(message.conversationId()).isEqualTo("AcvThreadIndexDebug001");
assertThat(message.textBody()).contains("Thread index body");
}
private byte[] emlBytes() {
return """
From: Guest <guest@example.test>
@@ -80,4 +89,19 @@ class EmlMessageParseServiceImplTest {
--rel-boundary--
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
private byte[] threadIndexEmlBytes() {
return """
From: Guest <guest@example.test>
To: Reservations <reservations@example.test>
Subject: Thread Index Booking Request
Date: Thu, 09 Jul 2026 01:30:00 +0000
Message-ID: <thread-index-message-001@example.test>
Thread-Index: AcvThreadIndexDebug001
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Thread index body
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
}