完善 Debug EML 上传调试链路
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件名安全清洗,避免路径穿越和日志污染。
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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。
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user