chenkq пре 3 недеља
родитељ
комит
e2c531582d

+ 1 - 1
storlead-mail-api/src/main/resources/application-dev.yml

@@ -147,7 +147,7 @@ logging:
 environment: test
 
 # 站内消息、邮件模板中的前端站点根地址(覆盖 application.yml 默认值)
-domainname: https://email.test.storlead.com/router/rest
+domainname: http://192.168.1.162:10080
 
 file:
   mode: 1

+ 1 - 1
storlead-mail-api/src/main/resources/application.yml

@@ -25,7 +25,7 @@ spring:
         queue-capacity: 50
 
 # 站内消息 / 邮件模板跳转链接前缀(含协议,末尾不要 /)
-domainname: http://localhost:10010/router/rest
+domainname: http://localhost:10080
 
 # 多租户:无 tenant_id 列的表须列入忽略,否则 SQL 会拼 tenant_id 导致 Unknown column
 storlead:

+ 2 - 0
storlead-mail/storlead-mail-common/src/main/resources/sql/mail_scene_and_mkt.sql

@@ -41,6 +41,8 @@ CREATE TABLE IF NOT EXISTS mkt_mail_message (
     opened          tinyint      NOT NULL DEFAULT 0 COMMENT '是否已打开(像素回调):0未打开;1已打开;存在网关预取假阳性,仅为弱信号',
     first_open_time datetime     NULL COMMENT '首次打开时间(首次有效像素回调时间)',
     open_count      int          NOT NULL DEFAULT 0 COMMENT '打开次数累计(每次追踪回调+1,含重复打开/预取)',
+    replied         tinyint      NOT NULL DEFAULT 0 COMMENT '是否已回复:0否;1是',
+    first_reply_time datetime    NULL COMMENT '首次回复时间',
     sent_time       datetime     NULL COMMENT '实际SMTP发送成功时间',
     delay_send_time datetime     NULL COMMENT '定时发送时间;未到点时status=-1',
     owner_by        bigint       NULL COMMENT '数据归属人(操作/归属用户ID)',

+ 4 - 0
storlead-mail/storlead-mail-common/src/main/resources/sql/mkt_mail_message_add_replied.sql

@@ -0,0 +1,4 @@
+-- 营销投递表补充「已回复」字段(与 opened 对称;状态同步仍以 mkt_mail_status 为准)
+ALTER TABLE mkt_mail_message
+    ADD COLUMN replied tinyint NOT NULL DEFAULT 0 COMMENT '是否已回复:0否 1是' AFTER open_count,
+    ADD COLUMN first_reply_time datetime NULL COMMENT '首次回复时间' AFTER replied;

+ 2 - 0
storlead-mail/storlead-mail-common/src/main/resources/sql/mkt_mail_recreate.sql

@@ -39,6 +39,8 @@ CREATE TABLE mkt_mail_message (
     opened          tinyint      NOT NULL DEFAULT 0 COMMENT '是否已打开(像素回调):0未打开;1已打开;存在网关预取假阳性,仅为弱信号',
     first_open_time datetime     NULL COMMENT '首次打开时间(首次有效像素回调时间)',
     open_count      int          NOT NULL DEFAULT 0 COMMENT '打开次数累计(每次追踪回调+1,含重复打开/预取)',
+    replied         tinyint      NOT NULL DEFAULT 0 COMMENT '是否已回复:0否;1是',
+    first_reply_time datetime    NULL COMMENT '首次回复时间',
     sent_time       datetime     NULL COMMENT '实际SMTP发送成功时间',
     delay_send_time datetime     NULL COMMENT '定时发送时间;未到点时status=-1',
     owner_by        bigint       NULL COMMENT '数据归属人(操作/归属用户ID)',

+ 15 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/callback/MktMailReplyCallback.java

@@ -0,0 +1,15 @@
+package com.storlead.sales.mail.mkt.callback;
+
+import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
+
+/**
+ * 营销邮件被回复回调(收信绑定到原信并标记已回复时触发,可注册多个 Spring Bean)。
+ */
+public interface MktMailReplyCallback {
+
+    /**
+     * @param entity     已回写回复态的原信(发出信)
+     * @param firstReply 是否首次回复(本次标记前 replied!=1)
+     */
+    void onReplied(MktMailMessageEntity entity, boolean firstReply);
+}

+ 8 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/entity/MktMailMessageEntity.java

@@ -130,6 +130,14 @@ public class MktMailMessageEntity extends SysBaseField {
     @TableField("open_count")
     private Integer openCount;
 
+    @ApiModelProperty("是否已回复")
+    @TableField("replied")
+    private Integer replied;
+
+    @ApiModelProperty("首次回复时间")
+    @TableField("first_reply_time")
+    private Date firstReplyTime;
+
     @TableField("sent_time")
     private Date sentTime;
 

+ 180 - 19
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/receive/MktMailReceivePersist.java

@@ -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) {

+ 6 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/MktMailMessageService.java

@@ -48,6 +48,12 @@ public interface MktMailMessageService extends MyBaseService<MktMailMessageEntit
      */
     boolean markOpened(String trackToken);
 
+    /**
+     * 收信绑定到原信后:按 mailId 标记已回复(幂等;无效 id 返回 false)。
+     * 同时回写 mkt_mail_message 与 mkt_mail_status。
+     */
+    boolean markReplied(Long mailId);
+
     /**
      * 按业务单号汇总打开情况。
      */

+ 54 - 5
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailMessageServiceImpl.java

@@ -18,6 +18,7 @@ import com.storlead.sales.mail.common.service.MailTempAttachmentService;
 import com.storlead.sales.mail.common.transport.MailTransportSupport;
 import com.storlead.sales.mail.common.util.EmailHelper;
 import com.storlead.sales.mail.mkt.callback.MktMailOpenCallback;
+import com.storlead.sales.mail.mkt.callback.MktMailReplyCallback;
 import com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity;
 import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
 import com.storlead.sales.mail.mkt.enums.MktMailFolderEnum;
@@ -106,6 +107,7 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     private MktMailSender mktMailSender;
 
     private final List<MktMailOpenCallback> openCallbacks;
+    private final List<MktMailReplyCallback> replyCallbacks;
 
     /**
      * 打开追踪像素公网根地址(不含 context-path)。
@@ -118,8 +120,10 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     @Value("${server.servlet.context-path:}")
     private String servletContextPath;
 
-    public MktMailMessageServiceImpl(ObjectProvider<MktMailOpenCallback> openCallbackProvider) {
+    public MktMailMessageServiceImpl(ObjectProvider<MktMailOpenCallback> openCallbackProvider,
+                                     ObjectProvider<MktMailReplyCallback> replyCallbackProvider) {
         this.openCallbacks = openCallbackProvider.orderedStream().collect(Collectors.toList());
+        this.replyCallbacks = replyCallbackProvider.orderedStream().collect(Collectors.toList());
     }
 
     @Override
@@ -413,6 +417,40 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         return true;
     }
 
+    @Override
+    public boolean markReplied(Long mailId) {
+        if (mailId == null) {
+            return false;
+        }
+        MktMailMessageEntity before = this.getOne(new LambdaQueryWrapper<MktMailMessageEntity>()
+                .select(MktMailMessageEntity::getId, MktMailMessageEntity::getReplied,
+                        MktMailMessageEntity::getBizRef, MktMailMessageEntity::getRecipient,
+                        MktMailMessageEntity::getSubject)
+                .eq(MktMailMessageEntity::getId, mailId)
+                .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .last("limit 1"));
+        if (before == null) {
+            return false;
+        }
+        boolean firstReply = !Integer.valueOf(1).equals(before.getReplied());
+        LambdaUpdateWrapper<MktMailMessageEntity> update = new LambdaUpdateWrapper<>();
+        update.eq(MktMailMessageEntity::getId, before.getId());
+        update.set(MktMailMessageEntity::getReplied, 1);
+        update.setSql("first_reply_time = IFNULL(first_reply_time, NOW())");
+        boolean ok = this.update(update);
+        if (!ok) {
+            return false;
+        }
+        MktMailMessageEntity latest = this.getById(before.getId());
+        fireReplyCallbacks(latest == null ? before : latest, firstReply);
+        try {
+            mktMailStatusService.markReplied(before.getId());
+        } catch (Exception e) {
+            log.error("mkt_mail_status markReplied error, mailId={}", before.getId(), e);
+        }
+        return true;
+    }
+
     @Override
     public MktOpenStatsVO openStatsByBizRef(String bizRef) {
         if (!StringUtils.hasText(bizRef)) {
@@ -458,6 +496,19 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         }
     }
 
+    private void fireReplyCallbacks(MktMailMessageEntity entity, boolean firstReply) {
+        if (entity == null || CollectionUtils.isEmpty(replyCallbacks)) {
+            return;
+        }
+        for (MktMailReplyCallback callback : replyCallbacks) {
+            try {
+                callback.onReplied(entity, firstReply);
+            } catch (Exception e) {
+                log.error("mkt reply callback error, mailId={}", entity.getId(), e);
+            }
+        }
+    }
+
     /**
      * @param contentToSend 本次发出的正文;为空则使用库内已有正文(延时任务)
      */
@@ -562,10 +613,8 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         BodyPart htmlPart = new MimeBodyPart();
         List<MimeBodyPart> imageParts = new ArrayList<>();
         String originalHtml = entity.getContent() == null ? "" : entity.getContent();
-        // 无效果 div.class=token,测回复引用是否保留;另注入打开追踪像素
-        String withTokenDiv = MktMailHeaders.injectTokenClassDiv(originalHtml, entity.getTrackToken());
-        String html = injectTrackPixel(withTokenDiv, entity);
-        // 追踪像素写入内存中的 MIME;投递成功后由回调覆盖库内正文,失败不改正文
+        // 追踪像素写入发出的 MIME(客户邮箱可见);本地落库可先带像素,从服务器拉发件箱时再剔除
+        String html = injectTrackPixel(originalHtml, entity);
         entity.setContent(html);
         String updatedHtml = EmailHelper.convertImageUrlsToCidBodyPart(
                 html, imageParts, mailFileProperties.getPath().getJpeg(), multipart);

+ 9 - 35
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/support/MktMailHeaders.java

@@ -11,8 +11,7 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
- * 营销发信埋点:可控 Message-ID 携带 track_token;
- * 另在正文注入无展示效果的 div(class=token),用于测试回复引用是否保留。
+ * 营销发信埋点:可控 Message-ID 携带 track_token。
  * <p>
  * Message-ID 格式:{@code <mkt.{mailId}.{trackToken}@{domain}>}
  */
@@ -23,9 +22,9 @@ public final class MktMailHeaders {
     private static final Pattern MESSAGE_ID_PATTERN = Pattern.compile(
             "(?i)<\\s*mkt\\.(\\d+)\\.([A-Za-z0-9_-]+)@([^>\\s]+)\\s*>");
 
-    /** 正文里 class=token 的空 div,用于回复绑定时从引用正文取 token */
-    private static final Pattern BODY_TOKEN_CLASS_PATTERN = Pattern.compile(
-            "(?i)<div\\s+[^>]*class\\s*=\\s*[\"']([A-Za-z0-9_-]{8,})[\"'][^>]*>");
+    /** 打开追踪像素 img(仅发给客户;本地发件箱正文需去掉) */
+    private static final Pattern TRACK_PIXEL_IMG = Pattern.compile(
+            "(?is)<img\\b[^>]*?/mkt/track/[^>]*?>");
 
     private MktMailHeaders() {
     }
@@ -42,38 +41,13 @@ public final class MktMailHeaders {
     }
 
     /**
-     * 注入无视觉效果的标记 div,class 即为 track_token(测回复是否保留)。
+     * 去掉正文中的打开追踪像素,避免发件箱自打开误计为客户打开。
      */
-    public static String injectTokenClassDiv(String html, String trackToken) {
-        if (!StringUtils.hasText(trackToken)) {
-            return html == null ? "" : html;
-        }
-        String body = html == null ? "" : html;
-        String token = trackToken.trim();
-        if (body.contains("class=\"" + token + "\"") || body.contains("class='" + token + "'")) {
-            return body;
-        }
-        String marker = "<div class=\"" + token
-                + "\" style=\"display:none!important;width:0;height:0;overflow:hidden;font-size:0;line-height:0;\"></div>";
-        int idx = body.toLowerCase(Locale.ROOT).lastIndexOf("</body>");
-        if (idx >= 0) {
-            return body.substring(0, idx) + marker + body.substring(idx);
-        }
-        return body + marker;
-    }
-
-    /**
-     * 从正文(含回复引用)解析 class=token 标记中的 track_token。
-     */
-    public static String parseTrackTokenFromBody(String content) {
-        if (!StringUtils.hasText(content)) {
-            return null;
+    public static String stripTrackPixel(String html) {
+        if (!StringUtils.hasText(html) || !html.contains("/mkt/track/")) {
+            return html;
         }
-        Matcher m = BODY_TOKEN_CLASS_PATTERN.matcher(content);
-        if (m.find()) {
-            return m.group(1);
-        }
-        return null;
+        return TRACK_PIXEL_IMG.matcher(html).replaceAll("");
     }
 
     public static String parseTrackToken(String messageId) {

+ 2 - 0
storlead-mail/storlead-mail-mkt/src/main/resources/mapper/MktMailMessageMapper.xml

@@ -24,6 +24,8 @@
         <result column="opened" property="opened"/>
         <result column="first_open_time" property="firstOpenTime"/>
         <result column="open_count" property="openCount"/>
+        <result column="replied" property="replied"/>
+        <result column="first_reply_time" property="firstReplyTime"/>
         <result column="sent_time" property="sentTime"/>
         <result column="delay_send_time" property="delaySendTime"/>
     </resultMap>