ソースを参照

测试邮件定时发送和和保存前后message-id绑定问题,增加追踪像素逻辑

chenkq 2 週間 前
コミット
1be707fb31

+ 1 - 0
storlead-framework/storlead-common/src/main/java/com/storlead/framework/common/properties/UrlChainDefinitionPorperties.java

@@ -83,6 +83,7 @@ public class UrlChainDefinitionPorperties {
 			"/sys/permission/getUserAccessResourceTest",
 			"/mail/draft_download_url",
 			"/mail/download_url",
+			"/mail/mkt/track/**",
 			"/wxapi/wxclientmenu/**",
 			"/wxapi/wxclientmenu/*",
 			"/wxapi/getauth",

+ 26 - 3
storlead-mail-api/src/main/java/com/storlead/mail/mkt/MktMailApiController.java

@@ -16,6 +16,7 @@ import com.storlead.sales.mail.common.util.SafeFilePathResolver;
 import com.storlead.sales.mail.mkt.entity.MktMailAttachmentEntity;
 import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
 import com.storlead.sales.mail.mkt.pojo.MktMailQueryDTO;
+import com.storlead.sales.mail.mkt.pojo.MktOpenStatsVO;
 import com.storlead.sales.mail.mkt.pojo.MktSendMailDTO;
 import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
 import com.storlead.sales.mail.mkt.service.MktMailMessageService;
@@ -24,6 +25,8 @@ import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import lombok.extern.log4j.Log4j2;
 import org.apache.commons.io.FilenameUtils;
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.util.CollectionUtils;
@@ -224,11 +227,31 @@ public class MktMailApiController {
         return Result.result(mktMailMessageService.getDetail(id, userId));
     }
 
+    @PostMapping("/open_stats")
+    @ApiOperation("按业务单号汇总打开追踪(opened / open_count)")
+    public Result<?> openStats(@RequestParam("bizRef") String bizRef) {
+        Long userId = LoginUserUtil.getCurrentUserId();
+        if (Objects.isNull(userId)) {
+            return Result.error("未登录");
+        }
+        MktOpenStatsVO stats = mktMailMessageService.openStatsByBizRef(bizRef);
+        return Result.result(stats);
+    }
+
     @GetMapping(value = "/track/{token}", produces = MediaType.IMAGE_GIF_VALUE)
-    @ApiOperation("打开追踪像素")
+    @ApiOperation("打开追踪像素(免登录;始终返回 1x1 GIF)")
     public ResponseEntity<byte[]> track(@PathVariable("token") String token) {
-        mktMailMessageService.markOpened(token);
-        return ResponseEntity.ok().contentType(MediaType.IMAGE_GIF).body(PIXEL_GIF);
+        try {
+            mktMailMessageService.markOpened(token);
+        } catch (Exception e) {
+            log.warn("mkt track markOpened error, token={}", token, e);
+        }
+        return ResponseEntity.ok()
+                .contentType(MediaType.IMAGE_GIF)
+                .cacheControl(CacheControl.noStore().mustRevalidate())
+                .header(HttpHeaders.PRAGMA, "no-cache")
+                .header("Expires", "0")
+                .body(PIXEL_GIF);
     }
 
     private void writeFileResponse(String relativePath, String fileName, String fileExt, HttpServletResponse response) {

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

@@ -43,7 +43,9 @@ storlead:
       - sys_app
       - mail_integration_task
   mail:
-    # 营销打开追踪像素公网根地址(含 context-path),例:https://xxx/router/mail
+    # 营销打开追踪像素公网根地址(含 context-path,末尾不要 /)
+    # 最终像素:{track-base-url}/mail/mkt/track/{token}
+    # 例 test:https://test1.storlead.com/router/mail
     track-base-url: ${domainname:}
     integration:
       enabled: true

+ 11 - 2
storlead-mail/README.md

@@ -40,9 +40,18 @@ API:
   - `POST /mail/mkt/file/download` — 下载正式附件
   - `POST /mail/mkt/file/download_temp` — 下载临时附件
   - `POST /mail/mkt/save_draft` — 保存草稿(`status=-2`,绑定 `fileIds`/`oldFileIds`)
-  - `POST /mail/mkt/send` — 发送(CID 内嵌图、附件、延时、追踪像素)
+  - `POST /mail/mkt/send` — 发送(CID 内嵌图、附件、延时、追踪像素;`isTrack=1` 需配置 `storlead.mail.track-base-url`)
+  - `POST /mail/mkt/page_list` — 分页(支持 `opened` / `isTrack` / `bizBatchNo`)
   - `POST /mail/mkt/detail` — 详情(草稿带临时附件,成功带正式附件)
-  - `GET /mail/mkt/track/{token}` — 打开追踪像素
+  - `POST /mail/mkt/open_stats?bizRef=` — 按业务单号汇总打开情况
+  - `GET /mail/mkt/track/{token}` — 打开追踪像素(免登录,白名单)
+
+### 打开追踪说明
+
+1. 发信 `isTrack=1` → 生成 `track_token`,HTML 注入 1x1 像素  
+2. 收件端加载图片 → `opened=1`,`first_open_time`,`open_count++`  
+3. 可实现 `MktMailOpenCallback` 做业务侧「客户已打开」通知  
+4. 像素仅为弱信号(网关预取/禁图会导致偏差)
 
 临时附件共用 `mail_temp_attachment`(`scene` 默认 `CRM`);正式附件 CRM/MKT 分表。
 

+ 35 - 31
storlead-mail/storlead-mail-common/src/main/java/com/storlead/sales/mail/common/receive/MailMessageParser.java

@@ -6,13 +6,10 @@ import com.sun.mail.pop3.POP3Folder;
 import lombok.extern.log4j.Log4j2;
 import org.springframework.util.StringUtils;
 
-import javax.mail.Address;
-import javax.mail.Flags;
-import javax.mail.Folder;
-import javax.mail.Message;
-import javax.mail.Multipart;
+import javax.mail.*;
 import javax.mail.internet.InternetAddress;
 import javax.mail.internet.MimeUtility;
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -34,17 +31,13 @@ public final class MailMessageParser {
             draft.setFolder(folderCode);
             draft.setInOutMark("INBOX".equalsIgnoreCase(folderCode) ? 1 : 2);
 
-            Map<String, String> fromMap = decodeAddresses(message.getFrom());
-            draft.setFromAddr(joinKeys(fromMap));
-            draft.setFromName(joinValues(fromMap));
+            Map<String,String>  fromMap = decodeAndPrintAddresses(message.getFrom());
+            draft.setFromAddr(mapKeyToString(fromMap));
+            draft.setFromName(mapValueToString(fromMap));
 
-            Map<String, String> toMap = decodeAddresses(message.getRecipients(Message.RecipientType.TO));
-            draft.setRecipient(joinKeys(toMap));
-            draft.setRecipientName(joinValues(toMap));
-
-            Map<String, String> ccMap = decodeAddresses(message.getRecipients(Message.RecipientType.CC));
-            draft.setRecipientCc(joinKeys(ccMap));
-            draft.setRecipientCcName(joinValues(ccMap));
+            Map<String,String>  toMap = decodeAndPrintAddresses(message.getRecipients(Message.RecipientType.TO));
+            draft.setRecipient(mapKeyToString(toMap));
+            draft.setRecipientName(mapValueToString(toMap));
 
             draft.setEmailSize((long) Math.max(message.getSize(), 0));
             if (message.getSentDate() != null) {
@@ -181,35 +174,46 @@ public final class MailMessageParser {
         return html.length() > 0 ? html.toString() : plain.toString();
     }
 
-    private static Map<String, String> decodeAddresses(Address[] addresses) throws Exception {
-        Map<String, String> map = new HashMap<>();
-        if (addresses == null) {
-            return map;
-        }
+
+
+    public static Map<String,String> decodeAndPrintAddresses(Address[] addresses) throws MessagingException, IOException {
+
+        Map<String,String> map = new HashMap<>();
+        if (addresses == null) return new HashMap<>();
+        StringBuilder decodedAddresses = new StringBuilder();
         for (Address address : addresses) {
             InternetAddress internetAddress = (InternetAddress) address;
             String personal = internetAddress.getPersonal();
+            String email = internetAddress.getAddress();
             if (personal != null) {
                 personal = MimeUtility.decodeText(personal);
             }
-            map.put(internetAddress.getAddress(), personal != null ? personal : "N/A");
+            map.put(email,(personal != null ? personal : "N/A"));
+//            decodedAddresses.append("[Name: ").append(personal != null ? personal : "N/A").append(", Email: ").append(email).append("] ");
         }
         return map;
     }
 
-    private static String joinKeys(Map<String, String> map) {
-        if (map == null || map.isEmpty()) {
-            return "";
+    public static String mapKeyToString(Map<String,String> map) {
+        if (map == null) return "";
+        String mapKey = map.entrySet().stream()
+                .map(entry -> entry.getKey())
+                .collect(Collectors.joining(","));
+        if (("N/A").equals(mapKey)) {
+            mapKey = "";
         }
-        return String.join(",", map.keySet());
+        return mapKey;
     }
 
-    private static String joinValues(Map<String, String> map) {
-        if (map == null || map.isEmpty()) {
-            return "";
-        }
-        return map.values().stream()
-                .filter(v -> v != null && !"N/A".equals(v))
+    public static String mapValueToString(Map<String,String> map) {
+        if (map == null) return "";
+        String mapKey = map.entrySet().stream()
+                .map(entry -> entry.getValue())
                 .collect(Collectors.joining(","));
+
+        if (("N/A").equals(mapKey)) {
+            mapKey = "";
+        }
+        return mapKey;
     }
 }

+ 6 - 22
storlead-mail/storlead-mail-crm/src/main/java/com/storlead/sales/mail/crm/service/impl/EmailsServiceImpl.java

@@ -32,6 +32,7 @@ import com.storlead.sales.mail.crm.pojo.MailListDTO;
 import com.storlead.sales.mail.crm.pojo.SendMailDTO;
 import com.storlead.sales.mail.crm.pojo.vo.*;
 import com.storlead.sales.mail.common.properties.MailFileProperties;
+import com.storlead.sales.mail.common.receive.MailMessageParser;
 import com.storlead.sales.mail.crm.service.*;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.sales.mail.crm.integration.external.IntegrationExternalInvoker;
@@ -986,24 +987,6 @@ public class EmailsServiceImpl extends MyBaseServiceImpl<EmailsMapper, EmailsEnt
     }
 
 
-    private static Map<String,String> decodeAndPrintAddresses(Address[] addresses) throws MessagingException, IOException {
-
-        Map<String,String> map = new HashMap<>();
-        if (addresses == null) return new HashMap<>();
-        StringBuilder decodedAddresses = new StringBuilder();
-        for (Address address : addresses) {
-            InternetAddress internetAddress = (InternetAddress) address;
-            String personal = internetAddress.getPersonal();
-            String email = internetAddress.getAddress();
-            if (personal != null) {
-                personal = MimeUtility.decodeText(personal);
-            }
-            map.put(email,(personal != null ? personal : "N/A"));
-//            decodedAddresses.append("[Name: ").append(personal != null ? personal : "N/A").append(", Email: ").append(email).append("] ");
-        }
-        return map;
-    }
-
     private static String mapKeyToString(Map<String,String> map) {
         if (map == null) return "";
         String mapKey = map.entrySet().stream()
@@ -1137,16 +1120,17 @@ public class EmailsServiceImpl extends MyBaseServiceImpl<EmailsMapper, EmailsEnt
                 }
             }
 
-            Map<String,String>  fromMap = decodeAndPrintAddresses(message.getFrom());
+            Map<String,String>  fromMap = MailMessageParser.decodeAndPrintAddresses(message.getFrom());
 
             entity.setFrom(mapKeyToString(fromMap));
             entity.setFromName(mapValueToString(fromMap));
 
-            Map<String,String>  toMap = decodeAndPrintAddresses(message.getRecipients(Message.RecipientType.TO));
+            Map<String,String>  toMap = MailMessageParser.decodeAndPrintAddresses(message.getRecipients(Message.RecipientType.TO));
+
             entity.setRecipient(mapKeyToString(toMap));
             entity.setRecipientName(mapValueToString(toMap));
 
-            Map<String,String>  toMapCC = decodeAndPrintAddresses(message.getRecipients(Message.RecipientType.CC));
+            Map<String,String>  toMapCC = MailMessageParser.decodeAndPrintAddresses(message.getRecipients(Message.RecipientType.CC));
             if (Objects.nonNull(toMapCC)) {
                 entity.setRecipientCc(mapKeyToString(toMapCC));
                 entity.setRecipientCcName(mapKeyToString(toMapCC));
@@ -1598,7 +1582,7 @@ public class EmailsServiceImpl extends MyBaseServiceImpl<EmailsMapper, EmailsEnt
                     continue;
                 }
                 String messageId = message.getHeader("Message-ID")[0];
-                Map<String, String> fromMap = decodeAndPrintAddresses(message.getFrom());
+                Map<String, String> fromMap = MailMessageParser.decodeAndPrintAddresses(message.getFrom());
                 String fromAddress = mapKeyToString(fromMap);
                 Integer count = this.getEmailsByMessageId(messageId, fromAddress);
                 if (!Integer.valueOf(0).equals(count)) {

+ 7 - 6
storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/DelaySendEmailTaskJob.java

@@ -13,6 +13,7 @@ import com.storlead.sales.mail.mkt.service.MktMailMessageService;
 import lombok.NoArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.core.env.Environment;
+import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 import org.springframework.util.CollectionUtils;
 
@@ -41,13 +42,13 @@ public class DelaySendEmailTaskJob {
 
     @Resource
     private SmtpPopSettingsService smtpPopSettingsService;
-
-    // @Scheduled(cron = "${storlead.mail.scheduler.delay-send-cron:0 * * * * ?}")
+//
+    @Scheduled(cron = "0 */1 * * * ?")
     public void sendDelayEmailTask() {
-        String active = environment.getProperty("spring.profiles.active");
-        if ("dev".equals(active)) {
-            return;
-        }
+//        String active = environment.getProperty("spring.profiles.active");
+//        if ("dev".equals(active)) {
+//            return;
+//        }
         log.info("定时发送邮件任务开始(CRM + MKT)");
         sendCrmDelayEmails();
         sendMktDelayEmails();

+ 1 - 1
storlead-mail/storlead-mail-dispatch/src/main/java/com/storlead/sales/mail/dispatch/MktEmailPullTaskJob.java

@@ -28,7 +28,7 @@ public class MktEmailPullTaskJob {
     @Resource
     private MktMailReceiveService mktMailReceiveService;
 
-    @Scheduled(cron = "0 */5 * * * ?")
+   @Scheduled(cron = "0 */1 * * * ?")
     public void pullMktMailTask() {
         LambdaQueryWrapper<SmtpPopSettingsEntity> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(SmtpPopSettingsEntity::getIsDelete, 0);

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

@@ -0,0 +1,15 @@
+package com.storlead.sales.mail.mkt.callback;
+
+import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
+
+/**
+ * 营销邮件打开追踪回调(像素被请求时触发,可注册多个 Spring Bean)。
+ */
+public interface MktMailOpenCallback {
+
+    /**
+     * @param entity    已回写打开态的邮件
+     * @param firstOpen 是否首次打开(本次回调前 opened!=1)
+     */
+    void onOpened(MktMailMessageEntity entity, boolean firstOpen);
+}

+ 9 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/pojo/MktMailQueryDTO.java

@@ -25,4 +25,13 @@ public class MktMailQueryDTO extends PageQuery {
 
     @ApiModelProperty("文件夹:INBOX/SENT/SENTING/DRAFT")
     private String folder;
+
+    @ApiModelProperty("是否已打开:0未打开 1已打开")
+    private Integer opened;
+
+    @ApiModelProperty("是否开启追踪:0否 1是")
+    private Integer isTrack;
+
+    @ApiModelProperty("批次号")
+    private String bizBatchNo;
 }

+ 28 - 0
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/pojo/MktOpenStatsVO.java

@@ -0,0 +1,28 @@
+package com.storlead.sales.mail.mkt.pojo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel("营销邮件打开统计")
+public class MktOpenStatsVO {
+
+    @ApiModelProperty("业务单号")
+    private String bizRef;
+
+    @ApiModelProperty("总封数(未删除)")
+    private Integer total;
+
+    @ApiModelProperty("开启追踪的封数")
+    private Integer trackEnabled;
+
+    @ApiModelProperty("已打开封数(opened=1)")
+    private Integer opened;
+
+    @ApiModelProperty("未打开封数(开启追踪且 opened!=1)")
+    private Integer unopened;
+
+    @ApiModelProperty("打开次数合计")
+    private Integer openCountSum;
+}

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

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.storlead.framework.mybatis.service.MyBaseService;
 import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
 import com.storlead.sales.mail.mkt.pojo.MktMailQueryDTO;
+import com.storlead.sales.mail.mkt.pojo.MktOpenStatsVO;
 import com.storlead.sales.mail.mkt.pojo.MktSendMailDTO;
 
 /**
@@ -34,5 +35,13 @@ public interface MktMailMessageService extends MyBaseService<MktMailMessageEntit
      */
     MktMailMessageEntity getDetail(Long id, Long operatorId);
 
+    /**
+     * 像素回调:按 track_token 标记打开(始终可安全调用,无效 token 返回 false)。
+     */
     boolean markOpened(String trackToken);
+
+    /**
+     * 按业务单号汇总打开情况。
+     */
+    MktOpenStatsVO openStatsByBizRef(String bizRef);
 }

+ 104 - 12
storlead-mail/storlead-mail-mkt/src/main/java/com/storlead/sales/mail/mkt/service/impl/MktMailMessageServiceImpl.java

@@ -17,17 +17,20 @@ import com.storlead.sales.mail.common.service.MailAccountBizService;
 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.entity.MktMailAttachmentEntity;
 import com.storlead.sales.mail.mkt.entity.MktMailMessageEntity;
+import com.storlead.sales.mail.mkt.enums.MktMailFolderEnum;
 import com.storlead.sales.mail.mkt.mapper.MktMailMessageMapper;
 import com.storlead.sales.mail.mkt.pojo.MktMailQueryDTO;
+import com.storlead.sales.mail.mkt.pojo.MktOpenStatsVO;
 import com.storlead.sales.mail.mkt.pojo.MktSendMailDTO;
 import com.storlead.sales.mail.mkt.service.MktMailAttachmentService;
 import com.storlead.sales.mail.mkt.service.MktMailMessageService;
-import com.storlead.sales.mail.mkt.enums.MktMailFolderEnum;
 import com.storlead.sales.mail.mkt.support.MktMailHeaders;
 import com.storlead.sales.mail.mkt.util.MktMailSender;
 import lombok.extern.log4j.Log4j2;
+import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -91,12 +94,19 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
     @Resource
     private MktMailSender mktMailSender;
 
+    private final List<MktMailOpenCallback> openCallbacks;
+
     /**
-     * 打开追踪像素根地址,需可被收件方公网访问,例如 https://host/router/mail
+     * 打开追踪像素根地址,需可被收件方公网访问,且含 context-path。
+     * 例:https://host/router/mail (像素最终为 {base}/mail/mkt/track/{token})
      */
     @Value("${storlead.mail.track-base-url:${domainname:}}")
     private String trackBaseUrl;
 
+    public MktMailMessageServiceImpl(ObjectProvider<MktMailOpenCallback> openCallbackProvider) {
+        this.openCallbacks = openCallbackProvider.orderedStream().collect(Collectors.toList());
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public MktMailMessageEntity saveDraft(MktSendMailDTO dto, Long operatorId) {
@@ -134,6 +144,7 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         MktMailMessageEntity entity = resolveWritableEntity(dto.getId(), operatorId);
         fillMessageFields(entity, dto, account, operatorId);
         ensureTrackToken(entity, dto.getIsTrack());
+        assertTrackReady(entity);
 
         boolean delayed = dto.getDelaySendTime() != null && dto.getDelaySendTime().after(new Date());
         entity.setFolder(MktMailFolderEnum.SENTING.code);
@@ -236,6 +247,15 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         if (StringUtils.hasText(query.getFolder())) {
             wrapper.eq(MktMailMessageEntity::getFolder, query.getFolder());
         }
+        if (query.getOpened() != null) {
+            wrapper.eq(MktMailMessageEntity::getOpened, query.getOpened());
+        }
+        if (query.getIsTrack() != null) {
+            wrapper.eq(MktMailMessageEntity::getIsTrack, query.getIsTrack());
+        }
+        if (StringUtils.hasText(query.getBizBatchNo())) {
+            wrapper.eq(MktMailMessageEntity::getBizBatchNo, query.getBizBatchNo());
+        }
         wrapper.orderByDesc(MktMailMessageEntity::getId);
         return this.page(page, wrapper);
     }
@@ -271,20 +291,75 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         if (!StringUtils.hasText(trackToken)) {
             return false;
         }
-        MktMailMessageEntity entity = this.getOne(new LambdaQueryWrapper<MktMailMessageEntity>()
+        MktMailMessageEntity before = this.getOne(new LambdaQueryWrapper<MktMailMessageEntity>()
+                .select(MktMailMessageEntity::getId, MktMailMessageEntity::getOpened,
+                        MktMailMessageEntity::getTrackToken, MktMailMessageEntity::getBizRef,
+                        MktMailMessageEntity::getRecipient, MktMailMessageEntity::getIsTrack)
                 .eq(MktMailMessageEntity::getTrackToken, trackToken)
                 .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                .eq(MktMailMessageEntity::getIsTrack, 1)
                 .last("limit 1"));
-        if (entity == null) {
+        if (before == null) {
             return false;
         }
-        Date now = new Date();
-        if (!Integer.valueOf(1).equals(entity.getOpened())) {
-            entity.setOpened(1);
-            entity.setFirstOpenTime(now);
+        boolean firstOpen = !Integer.valueOf(1).equals(before.getOpened());
+        LambdaUpdateWrapper<MktMailMessageEntity> update = new LambdaUpdateWrapper<>();
+        update.eq(MktMailMessageEntity::getId, before.getId());
+        update.set(MktMailMessageEntity::getOpened, 1);
+        update.setSql("open_count = IFNULL(open_count, 0) + 1");
+        update.setSql("first_open_time = IFNULL(first_open_time, NOW())");
+        boolean ok = this.update(update);
+        if (!ok) {
+            return false;
+        }
+        MktMailMessageEntity latest = this.getById(before.getId());
+        fireOpenCallbacks(latest == null ? before : latest, firstOpen);
+        return true;
+    }
+
+    @Override
+    public MktOpenStatsVO openStatsByBizRef(String bizRef) {
+        if (!StringUtils.hasText(bizRef)) {
+            throw new WebException("业务单号不能为空");
+        }
+        List<MktMailMessageEntity> list = this.list(new LambdaQueryWrapper<MktMailMessageEntity>()
+                .select(MktMailMessageEntity::getId, MktMailMessageEntity::getIsTrack,
+                        MktMailMessageEntity::getOpened, MktMailMessageEntity::getOpenCount)
+                .eq(MktMailMessageEntity::getBizRef, bizRef)
+                .eq(MktMailMessageEntity::getIsDelete, CommonConstant.DEL_FLAG_0));
+        MktOpenStatsVO vo = new MktOpenStatsVO();
+        vo.setBizRef(bizRef);
+        vo.setTotal(list.size());
+        int trackEnabled = 0;
+        int opened = 0;
+        int openCountSum = 0;
+        for (MktMailMessageEntity item : list) {
+            if (Integer.valueOf(1).equals(item.getIsTrack())) {
+                trackEnabled++;
+                if (Integer.valueOf(1).equals(item.getOpened())) {
+                    opened++;
+                }
+            }
+            openCountSum += item.getOpenCount() == null ? 0 : item.getOpenCount();
+        }
+        vo.setTrackEnabled(trackEnabled);
+        vo.setOpened(opened);
+        vo.setUnopened(Math.max(trackEnabled - opened, 0));
+        vo.setOpenCountSum(openCountSum);
+        return vo;
+    }
+
+    private void fireOpenCallbacks(MktMailMessageEntity entity, boolean firstOpen) {
+        if (entity == null || CollectionUtils.isEmpty(openCallbacks)) {
+            return;
+        }
+        for (MktMailOpenCallback callback : openCallbacks) {
+            try {
+                callback.onOpened(entity, firstOpen);
+            } catch (Exception e) {
+                log.error("mkt open callback error, mailId={}", entity.getId(), e);
+            }
         }
-        entity.setOpenCount((entity.getOpenCount() == null ? 0 : entity.getOpenCount()) + 1);
-        return this.updateById(entity);
     }
 
     /**
@@ -421,8 +496,25 @@ public class MktMailMessageServiceImpl extends MyBaseServiceImpl<MktMailMessageM
         }
         String base = trackBaseUrl.endsWith("/") ? trackBaseUrl.substring(0, trackBaseUrl.length() - 1) : trackBaseUrl;
         String pixel = "<img src=\"" + base + "/mail/mkt/track/" + entity.getTrackToken()
-                + "\" width=\"1\" height=\"1\" style=\"display:none\" alt=\"\"/>";
-        return html + pixel;
+                + "\" width=\"1\" height=\"1\" style=\"display:none!important;width:1px;height:1px;border:0;\" alt=\"\"/>";
+        String body = html == null ? "" : html;
+        int idx = body.toLowerCase().lastIndexOf("</body>");
+        if (idx >= 0) {
+            return body.substring(0, idx) + pixel + body.substring(idx);
+        }
+        return body + pixel;
+    }
+
+    private void assertTrackReady(MktMailMessageEntity entity) {
+        if (!Integer.valueOf(1).equals(entity.getIsTrack())) {
+            return;
+        }
+        if (!StringUtils.hasText(trackBaseUrl)) {
+            throw new WebException("已开启打开追踪,请配置 storlead.mail.track-base-url(公网可访问,含 context-path)");
+        }
+        if (!StringUtils.hasText(entity.getTrackToken())) {
+            throw new WebException("打开追踪 token 生成失败");
+        }
     }
 
     private void bindTempAttachments(Long mailId, List<Long> fileIds, Long operatorId) {