|
|
@@ -23,7 +23,6 @@ import com.storlead.sales.mail.mkt.enums.MktMailFolderEnum;
|
|
|
import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
|
|
|
import com.storlead.sales.mail.mkt.service.MktMailBlacklistService;
|
|
|
import com.storlead.sales.mail.mkt.service.MktMailMessageService;
|
|
|
-import com.storlead.sales.mail.mkt.service.MktMailStatusService;
|
|
|
import com.storlead.sales.mail.mkt.support.MktBounceDetector;
|
|
|
import com.storlead.sales.mail.mkt.support.MktMailHeaders;
|
|
|
import lombok.extern.log4j.Log4j2;
|
|
|
@@ -43,6 +42,9 @@ import java.io.FileOutputStream;
|
|
|
import java.util.ArrayList;
|
|
|
import java.util.Date;
|
|
|
import java.util.List;
|
|
|
+import java.util.Locale;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
|
|
|
/**
|
|
|
* MKT 收信落库:头+正文一次写入;附件异步;退信入黑名单。
|
|
|
@@ -53,15 +55,16 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
|
|
|
|
|
|
private static final int STATUS_RECEIVED = 1;
|
|
|
|
|
|
+ /** 主题回复/转发前缀:Re: / Fwd: / 回复: / 转发: */
|
|
|
+ private static final Pattern REPLY_PREFIX = Pattern.compile(
|
|
|
+ "(?i)^(re|fw|fwd|回复|转发)\\s*[::]\\s*");
|
|
|
+
|
|
|
@Resource
|
|
|
private MktMailMessageService mktMailMessageService;
|
|
|
|
|
|
@Resource
|
|
|
private MktMailAttachmentService mktMailAttachmentService;
|
|
|
|
|
|
- @Resource
|
|
|
- private MktMailStatusService mktMailStatusService;
|
|
|
-
|
|
|
@Resource
|
|
|
private MktMailBlacklistService mktMailBlacklistService;
|
|
|
|
|
|
@@ -154,7 +157,12 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
|
|
|
entity.setEmailSize(draft.getEmailSize());
|
|
|
entity.setIsRead(draft.getIsRead());
|
|
|
entity.setIsOnlyHead(0);
|
|
|
- entity.setContent(body);
|
|
|
+ // 发件箱正文去掉追踪像素,避免自己打开误计为客户打开
|
|
|
+ if (MktMailFolderEnum.SENT.code.equals(folder)) {
|
|
|
+ entity.setContent(MktMailHeaders.stripTrackPixel(body));
|
|
|
+ } else {
|
|
|
+ entity.setContent(body);
|
|
|
+ }
|
|
|
entity.setMsgUid(MailMessageParser.resolveMsgUid(message, account.getProtocolType()));
|
|
|
entity.setStatus(STATUS_RECEIVED);
|
|
|
entity.setIsDelete(CommonConstant.DEL_FLAG_0);
|
|
|
@@ -257,6 +265,13 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
|
|
|
if (!StringUtils.hasText(existing.getRecipient()) && StringUtils.hasText(draft.getRecipient())) {
|
|
|
update.set(MktMailMessageEntity::getRecipient, draft.getRecipient());
|
|
|
}
|
|
|
+ // 从服务器拉发件箱时去掉追踪像素,避免自己打开误计为客户打开(客户邮件里仍保留)
|
|
|
+ String serverBody = MailMessageParser.extractBody(message);
|
|
|
+ if (StringUtils.hasText(serverBody)) {
|
|
|
+ update.set(MktMailMessageEntity::getContent, MktMailHeaders.stripTrackPixel(serverBody));
|
|
|
+ } else if (StringUtils.hasText(existing.getContent())) {
|
|
|
+ update.set(MktMailMessageEntity::getContent, MktMailHeaders.stripTrackPixel(existing.getContent()));
|
|
|
+ }
|
|
|
mktMailMessageService.update(update);
|
|
|
log.info("mkt merge sent mail keep primary id={}, messageId={}", existing.getId(), messageId);
|
|
|
}
|
|
|
@@ -320,9 +335,14 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
|
|
|
|
|
|
@Override
|
|
|
public void updateBody(Long mailId, String content) {
|
|
|
+ String toSave = content;
|
|
|
+ MktMailMessageEntity mail = mktMailMessageService.getById(mailId);
|
|
|
+ if (mail != null && MktMailFolderEnum.SENT.code.equals(mail.getFolder())) {
|
|
|
+ toSave = MktMailHeaders.stripTrackPixel(content);
|
|
|
+ }
|
|
|
LambdaUpdateWrapper<MktMailMessageEntity> update = new LambdaUpdateWrapper<>();
|
|
|
update.eq(MktMailMessageEntity::getId, mailId);
|
|
|
- update.set(MktMailMessageEntity::getContent, content);
|
|
|
+ update.set(MktMailMessageEntity::getContent, toSave);
|
|
|
update.set(MktMailMessageEntity::getIsOnlyHead, 0);
|
|
|
mktMailMessageService.update(update);
|
|
|
}
|
|
|
@@ -412,23 +432,20 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
|
|
|
entity.setInReplyTo(MailMessageParser.normalizeMessageId(draft.getInReplyTo()));
|
|
|
}
|
|
|
|
|
|
+ // 仅对收件箱入站邮件做回复绑定
|
|
|
+ if (!MktMailFolderEnum.INBOX.code.equals(entity.getFolder())) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
MktMailMessageEntity parent = null;
|
|
|
|
|
|
- // 1) 从 In-Reply-To/References 解析 track_token,按 token 查原信
|
|
|
+ // —— 强信号(有则优先)——
|
|
|
+ // 1) In-Reply-To / References 里可控 Message-ID → track_token
|
|
|
String trackToken = MktMailHeaders.parseTrackTokenFromCandidates(replyIds);
|
|
|
if (StringUtils.hasText(trackToken)) {
|
|
|
parent = findByTrackToken(accountId, trackToken);
|
|
|
}
|
|
|
-
|
|
|
- // 2) 正文里 class=token 的标记 div(测回复是否保留)
|
|
|
- if (parent == null) {
|
|
|
- String bodyToken = MktMailHeaders.parseTrackTokenFromBody(entity.getContent());
|
|
|
- if (StringUtils.hasText(bodyToken)) {
|
|
|
- parent = findByTrackToken(accountId, bodyToken);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 3) 兼容:完整 Message-ID 精确匹配
|
|
|
+ // 2) 完整 Message-ID 精确匹配
|
|
|
if (parent == null && !CollectionUtils.isEmpty(replyIds)) {
|
|
|
for (String mid : replyIds) {
|
|
|
parent = findByMessageId(accountId, mid);
|
|
|
@@ -438,6 +455,13 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // —— 启发式(头缺失时):A 发给 B,B 回信 From=B ——
|
|
|
+ // 3) 主题高度疑似回复 + 对方邮箱=原信收件人 → 绑到该发出信
|
|
|
+ // 4) 主题无法判定 → 绑到 A 发给该邮箱的最新一封发出信
|
|
|
+ if (parent == null) {
|
|
|
+ parent = findParentBySubjectOrLatest(accountId, entity);
|
|
|
+ }
|
|
|
+
|
|
|
if (parent == null) {
|
|
|
return;
|
|
|
}
|
|
|
@@ -449,10 +473,147 @@ public class MktMailReceivePersist implements MailReceivePersistSpi {
|
|
|
entity.setBizBatchNo(parent.getBizBatchNo());
|
|
|
}
|
|
|
try {
|
|
|
- mktMailStatusService.markReplied(parent.getId());
|
|
|
+ mktMailMessageService.markReplied(parent.getId());
|
|
|
} catch (Exception e) {
|
|
|
- log.error("mkt_mail_status markReplied error, parentMailId={}", parent.getId(), e);
|
|
|
+ log.error("mkt markReplied error, parentMailId={}", parent.getId(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 启发式绑定:
|
|
|
+ * <ol>
|
|
|
+ * <li>列出本账户发给「回复人邮箱」的已发出信</li>
|
|
|
+ * <li>主题规范化后高度疑似回复(Re:/回复 等前缀 + 主题一致/包含)→ 取其中最新一封</li>
|
|
|
+ * <li>否则取发给该邮箱的最新一封发出信</li>
|
|
|
+ * </ol>
|
|
|
+ */
|
|
|
+ private MktMailMessageEntity findParentBySubjectOrLatest(Long accountId, MktMailMessageEntity reply) {
|
|
|
+ String fromEmail = normalizeEmail(reply.getFromAddr());
|
|
|
+ if (accountId == null || !StringUtils.hasText(fromEmail)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ List<MktMailMessageEntity> outbounds = listOutboundToRecipient(accountId, fromEmail);
|
|
|
+ if (CollectionUtils.isEmpty(outbounds)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ String replySubject = reply.getSubject();
|
|
|
+ MktMailMessageEntity bySubject = null;
|
|
|
+ for (MktMailMessageEntity out : outbounds) {
|
|
|
+ if (isLikelyReplySubject(replySubject, out.getSubject())) {
|
|
|
+ // outbounds 已按时间倒序,首个命中即最新
|
|
|
+ bySubject = out;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (bySubject != null) {
|
|
|
+ log.info("mkt bindReply by subject, replyId候选 parentId={}, from={}, subject={}",
|
|
|
+ bySubject.getId(), fromEmail, replySubject);
|
|
|
+ return bySubject;
|
|
|
+ }
|
|
|
+
|
|
|
+ MktMailMessageEntity latest = outbounds.get(0);
|
|
|
+ log.info("mkt bindReply by latest outbound, parentId={}, from={}", latest.getId(), fromEmail);
|
|
|
+ return latest;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 本账户发给指定邮箱的发出信(SENT/SENTING),按发送时间/id 倒序。
|
|
|
+ */
|
|
|
+ private List<MktMailMessageEntity> listOutboundToRecipient(Long accountId, String recipientEmail) {
|
|
|
+ List<MktMailMessageEntity> list = mktMailMessageService.list(new LambdaQueryWrapper<MktMailMessageEntity>()
|
|
|
+ .eq(MktMailMessageEntity::getAccountId, accountId)
|
|
|
+ .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
|
|
|
+ .in(MktMailMessageEntity::getFolder, MktMailFolderEnum.SENT.code, MktMailFolderEnum.SENTING.code)
|
|
|
+ .and(w -> w.eq(MktMailMessageEntity::getRecipient, recipientEmail)
|
|
|
+ .or().like(MktMailMessageEntity::getRecipient, recipientEmail))
|
|
|
+ .orderByDesc(MktMailMessageEntity::getSentTime)
|
|
|
+ .orderByDesc(MktMailMessageEntity::getId)
|
|
|
+ .last("limit 50"));
|
|
|
+ if (CollectionUtils.isEmpty(list)) {
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+ // like 可能误伤,再精确过滤:主收件人或逗号列表中含该邮箱
|
|
|
+ List<MktMailMessageEntity> exact = new ArrayList<>();
|
|
|
+ for (MktMailMessageEntity item : list) {
|
|
|
+ if (recipientMatches(item.getRecipient(), recipientEmail)) {
|
|
|
+ exact.add(item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return exact;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 主题高度疑似回复(须带 Re:/回复:/Fwd: 等前缀,避免无前缀同主题误绑):
|
|
|
+ * 去掉前缀后与原主题相等,或回复主题包含原主题。
|
|
|
+ */
|
|
|
+ static boolean isLikelyReplySubject(String replySubject, String originalSubject) {
|
|
|
+ if (!StringUtils.hasText(replySubject) || !StringUtils.hasText(originalSubject)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // 无回复前缀 → 主题无法判定,走「最新发出信」兜底
|
|
|
+ if (!hasReplyPrefix(replySubject)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ String replyNorm = normalizeSubject(replySubject);
|
|
|
+ String origNorm = normalizeSubject(originalSubject);
|
|
|
+ if (!StringUtils.hasText(replyNorm) || !StringUtils.hasText(origNorm)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (replyNorm.equalsIgnoreCase(origNorm)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return replyNorm.toLowerCase(Locale.ROOT).contains(origNorm.toLowerCase(Locale.ROOT));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean hasReplyPrefix(String subject) {
|
|
|
+ if (!StringUtils.hasText(subject)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ String s = subject.trim();
|
|
|
+ return REPLY_PREFIX.matcher(s).lookingAt();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 反复去掉 Re: / Fwd: / 回复: 等前缀 */
|
|
|
+ static String normalizeSubject(String subject) {
|
|
|
+ if (!StringUtils.hasText(subject)) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ String s = subject.trim().replaceAll("\\s+", " ");
|
|
|
+ while (true) {
|
|
|
+ Matcher m = REPLY_PREFIX.matcher(s);
|
|
|
+ if (!m.find() || m.start() != 0) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ s = s.substring(m.end()).trim();
|
|
|
+ }
|
|
|
+ return s;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean recipientMatches(String recipientField, String email) {
|
|
|
+ if (!StringUtils.hasText(recipientField) || !StringUtils.hasText(email)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ for (String part : recipientField.split("[,;]")) {
|
|
|
+ if (email.equalsIgnoreCase(normalizeEmail(part))) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String normalizeEmail(String raw) {
|
|
|
+ if (!StringUtils.hasText(raw)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String v = raw.trim();
|
|
|
+ // 兼容 "Name <a@b.com>"
|
|
|
+ int lt = v.indexOf('<');
|
|
|
+ int gt = v.indexOf('>');
|
|
|
+ if (lt >= 0 && gt > lt) {
|
|
|
+ v = v.substring(lt + 1, gt).trim();
|
|
|
}
|
|
|
+ return v.toLowerCase(Locale.ROOT);
|
|
|
}
|
|
|
|
|
|
private MktMailMessageEntity findByTrackToken(Long accountId, String trackToken) {
|