新增预警页面

This commit is contained in:
2025-12-14 14:21:32 +08:00
parent 228dd136d8
commit 0b40608761
42 changed files with 4871 additions and 2 deletions

View File

@@ -0,0 +1,24 @@
package com.jeesite.modules.app.dao;
import com.jeesite.modules.biz.entity.BizMailAttachments;
import com.jeesite.modules.biz.entity.BizMailReceived;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
public class MailReceived implements Serializable {
private BizMailReceived received;
private List<BizMailAttachments> attachments = new ArrayList<>();
public MailReceived(BizMailReceived received, List<BizMailAttachments> attachments) {
this.received = received;
this.attachments = attachments;
}
}

View File

@@ -144,6 +144,7 @@ public class LoggerUtils {
StandardCharsets.UTF_8 // 避免中文乱码
)
)) {
System.out.print(logContent);
writer.write(logContent.toString());
writer.flush();
} catch (IOException e) {

View File

@@ -0,0 +1,666 @@
package com.jeesite.modules.app.utils;
import com.jeesite.modules.app.dao.MailReceived;
import com.jeesite.modules.biz.entity.BizMailAccount;
import com.jeesite.modules.biz.entity.BizMailAttachments;
import com.jeesite.modules.biz.entity.BizMailReceived;
import javax.activation.MimetypesFileTypeMap;
import javax.mail.*;
import javax.mail.internet.*;
import javax.mail.search.FlagTerm;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.*;
public class MailReceiveUtils {
// 连接重试配置
private static final int CONNECT_RETRY_TIMES = 3;
private static final long CONNECT_RETRY_INTERVAL = 1000L;
private static final LoggerUtils logger = LoggerUtils.getInstance();
// 缓存ByteBuffer
private static final ThreadLocal<ByteBuffer> BUFFER_LOCAL = ThreadLocal.withInitial(() -> ByteBuffer.allocate(8192));
// MIME类型映射修复附件FileType识别
private static final MimetypesFileTypeMap MIME_TYPE_MAP = new MimetypesFileTypeMap();
static {
// 扩展MIME类型映射解决常见文件类型识别错误
MIME_TYPE_MAP.addMimeTypes("application/pdf pdf");
MIME_TYPE_MAP.addMimeTypes("application/msword doc");
MIME_TYPE_MAP.addMimeTypes("application/vnd.openxmlformats-officedocument.wordprocessingml.document docx");
MIME_TYPE_MAP.addMimeTypes("application/vnd.ms-excel xls");
MIME_TYPE_MAP.addMimeTypes("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx");
MIME_TYPE_MAP.addMimeTypes("application/vnd.ms-powerpoint ppt");
MIME_TYPE_MAP.addMimeTypes("application/vnd.openxmlformats-officedocument.presentationml.presentation pptx");
MIME_TYPE_MAP.addMimeTypes("image/jpeg jpg jpeg");
MIME_TYPE_MAP.addMimeTypes("image/png png");
MIME_TYPE_MAP.addMimeTypes("image/gif gif");
MIME_TYPE_MAP.addMimeTypes("application/zip zip");
MIME_TYPE_MAP.addMimeTypes("application/x-rar-compressed rar");
MIME_TYPE_MAP.addMimeTypes("application/x-7z-compressed 7z");
}
/**
* 接收未读邮件优化优先解析HTML格式正文
*/
public static List<MailReceived> receiveUnreadMails(BizMailAccount mailAccount, String saveBasePath) throws Exception {
List<MailReceived> receivedMailList = new ArrayList<>();
Session session = createMailSession(mailAccount);
Store store = null;
Folder folder = null;
try {
// 1. 建立IMAP连接带重试
store = session.getStore("imap");
boolean isConnected = false;
int retryCount = 0;
while (!isConnected) {
try {
store.connect(mailAccount.getHost(), mailAccount.getImapPort(),
mailAccount.getUsername(), mailAccount.getPassword());
isConnected = store.isConnected();
if (isConnected) {
logger.info("" + (retryCount + 1) + "次连接IMAP成功" + mailAccount.getHost());
}
} catch (AuthenticationFailedException e) {
logger.error("账号/密码错误,直接返回", e);
return receivedMailList;
} catch (MessagingException e) {
retryCount++;
logger.error("" + retryCount + "次连接失败:" + e.getMessage());
if (retryCount >= CONNECT_RETRY_TIMES) {
throw new IllegalStateException("IMAP连接失败重试3次", e);
}
Thread.sleep(CONNECT_RETRY_INTERVAL);
}
}
// 2. 打开收件箱
folder = store.getFolder("INBOX");
if (folder == null || !folder.exists()) {
logger.error("收件箱不存在");
return receivedMailList;
}
folder.open(Folder.READ_WRITE);
logger.info("未读邮件数:" + folder.getUnreadMessageCount());
// 3. 筛选未读邮件
Message[] unreadMessages = folder.search(new FlagTerm(new Flags(Flags.Flag.SEEN), false));
if (unreadMessages == null || unreadMessages.length == 0) {
logger.warn("无未读邮件");
return receivedMailList;
}
// 4. 处理每封邮件
for (Message message : unreadMessages) {
try {
BizMailReceived bizMail = buildBizMailReceived(message, mailAccount);
// 记录整封邮件附件下载开始时间
long mailAttachDownloadStart = System.currentTimeMillis();
logger.info("开始下载邮件[" + getMessageId(message) + "]的附件,开始时间:" + new Date(mailAttachDownloadStart));
List<BizMailAttachments> attachments = downloadExplicitAttachments(message, bizMail.getMessageId(), saveBasePath);
// 记录整封邮件附件下载结束时间
long mailAttachDownloadEnd = System.currentTimeMillis();
logger.info("完成下载邮件[" + getMessageId(message) + "]的附件,结束时间:" + new Date(mailAttachDownloadEnd) +
",耗时:" + (mailAttachDownloadEnd - mailAttachDownloadStart) + "ms");
receivedMailList.add(new MailReceived(bizMail, attachments));
message.setFlag(Flags.Flag.SEEN, true); // 标记已读
} catch (Exception e) {
logger.error("处理邮件失败Message-ID" + getMessageId(message) + "" + e.getMessage(), e);
continue;
}
}
} finally {
// 关闭资源
if (folder != null && folder.isOpen()) {
try {
folder.close(true);
} catch (MessagingException e) {
logger.error("关闭文件夹失败", e);
}
}
if (store != null && store.isConnected()) {
try {
store.close();
} catch (MessagingException e) {
logger.error("关闭连接失败", e);
}
}
BUFFER_LOCAL.remove();
}
return receivedMailList;
}
/**
* 创建邮件会话(优化编码+IMAP配置
*/
private static Session createMailSession(BizMailAccount mailAccount) {
Properties props = new Properties();
boolean sslEnable = "true".equals(mailAccount.getSslEnable());
props.put("mail.imap.host", mailAccount.getHost());
props.put("mail.imap.port", mailAccount.getImapPort());
props.put("mail.imap.ssl.enable", sslEnable);
props.put("mail.imap.ssl.protocols", "TLSv1.2"); // 强制TLS1.2避免SSL漏洞
props.put("mail.imap.auth", "true");
props.put("mail.imap.connectiontimeout", "10000");
props.put("mail.imap.timeout", "30000");
props.put("mail.imap.partialfetch", "false"); // 禁用部分获取,避免内容截断
props.put("mail.mime.charset", "UTF-8");
props.put("mail.mime.base64.ignoreerrors", "true");
props.put("mail.mime.decodefilenamehandler", "com.sun.mail.imap.protocol.IMAPUTF8DecodeHandler");
// 关键启用UTF-8文件名解码
System.setProperty("mail.mime.encodeparameters", "false");
return Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(mailAccount.getUsername(), mailAccount.getPassword());
}
});
}
/**
* 构建邮件实体核心优化优先解析HTML正文
*/
private static BizMailReceived buildBizMailReceived(Message message, BizMailAccount mailAccount) throws Exception {
BizMailReceived received = new BizMailReceived();
received.setCreateTime(new Date());
received.setMessageId(getMessageId(message));
received.setAccountId(mailAccount.getId());
received.setUstatus("1");
received.setMailbox("INBOX");
// 发件人(修复编码)
Address[] from = message.getFrom();
if (from != null && from.length > 0) {
InternetAddress fromAddr = (InternetAddress) from[0];
received.setFromAddress(fromAddr.getAddress());
// 优先解码发件人名称
String personal = fromAddr.getPersonal();
if (personal != null) {
received.setFromName(decodeMimeText(personal));
} else {
received.setFromName(fromAddr.getAddress());
}
}
// 收件人/抄送/密送(修复编码)
received.setToAddresses(convertAddresses(message.getRecipients(Message.RecipientType.TO)));
received.setCcAddresses(convertAddresses(message.getRecipients(Message.RecipientType.CC)));
received.setBccAddresses(convertAddresses(message.getRecipients(Message.RecipientType.BCC)));
// 主题(修复乱码)
String subject = message.getSubject();
received.setSubject(subject == null ? "" : decodeMimeText(subject));
// 核心优化优先解析HTML格式正文
String htmlContent = parseHtmlMailContent(message);
// 最终空内容兜底
received.setMailContent(htmlContent.isEmpty() ? "<p>(无正文内容)</p>" : htmlContent);
// 时间
received.setReceivedTime(new Date());
received.setSendTime(message.getSentDate() != null ? message.getSentDate() : new Date());
// 是否有显式附件
received.setHasAttachment(hasExplicitAttachment(message) ? "1" : "0");
return received;
}
/**
* 核心优化专门解析HTML格式正文优先提取HTML无则将纯文本转为HTML
*/
private static String parseHtmlMailContent(Message message) throws Exception {
Object content = message.getContent();
StringBuilder htmlSb = new StringBuilder();
// 递归提取HTML内容
extractHtmlContentRecursive(content, htmlSb);
// 清理HTML内容去除无效标签和空白
String rawHtml = htmlSb.toString().replaceAll("\\n+", "").replaceAll("\\s+", " ").trim();
// 如果没有提取到HTML内容尝试提取纯文本并转为HTML格式
if (rawHtml.isEmpty()) {
String plainText = extractPlainTextContent(message);
if (!plainText.isEmpty()) {
// 将纯文本转为基本HTML格式换行转<br>,空格转&nbsp;
rawHtml = "<p>" + plainText.replaceAll("\\n", "<br>").replaceAll(" ", "&nbsp;") + "</p>";
}
}
// 标准化HTML结构
return standardizeHtml(rawHtml);
}
/**
* 递归提取HTML内容优先获取text/html类型
*/
private static void extractHtmlContentRecursive(Object content, StringBuilder sb) throws Exception {
if (content == null) {
return;
}
// 1. 直接是HTML文本极少情况
if (content instanceof String text) {
if (text.contains("<html>") || text.contains("<body>") || text.contains("<p>") || text.contains("<br>")) {
sb.append(text);
}
return;
}
// 2. MimeMultipart优先提取text/html部分
if (content instanceof MimeMultipart multipart) {
String multipartType = multipart.getContentType().split(";")[0].trim().toLowerCase();
// 处理multipart/alternative多格式正文优先取text/html
if ("multipart/alternative".equals(multipartType)) {
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart part = multipart.getBodyPart(i);
String partType = part.getContentType().split(";")[0].trim().toLowerCase();
if ("text/html".equals(partType)) {
// 读取HTML内容并追加
sb.append(readBodyPartContent(part));
return; // 找到HTML后直接返回不再处理其他部分
}
}
}
// 处理其他multipart类型递归提取所有HTML部分
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart bodyPart = multipart.getBodyPart(i);
String disposition = bodyPart.getDisposition();
// 跳过显式附件
if (Part.ATTACHMENT.equalsIgnoreCase(disposition)) {
continue;
}
// 提取HTML内容
String partType = bodyPart.getContentType().split(";")[0].trim().toLowerCase();
if ("text/html".equals(partType)) {
sb.append(readBodyPartContent(bodyPart));
} else {
// 递归解析子内容
extractHtmlContentRecursive(bodyPart.getContent(), sb);
}
}
return;
}
// 3. 单个MimeBodyPart如果是HTML类型则读取
if (content instanceof MimeBodyPart bodyPart) {
String disposition = bodyPart.getDisposition();
// 跳过显式附件
if (Part.ATTACHMENT.equalsIgnoreCase(disposition)) {
return;
}
String partType = bodyPart.getContentType().split(";")[0].trim().toLowerCase();
if ("text/html".equals(partType)) {
sb.append(readBodyPartContent(bodyPart));
}
}
}
/**
* 提取纯文本内容作为HTML的降级方案
*/
private static String extractPlainTextContent(Message message) throws Exception {
Object content = message.getContent();
StringBuilder plainSb = new StringBuilder();
extractPlainTextRecursive(content, plainSb);
return plainSb.toString().replaceAll("^\\s+|\\s+$", "").replaceAll("\\n+", "\n");
}
/**
* 递归提取纯文本内容
*/
private static void extractPlainTextRecursive(Object content, StringBuilder sb) throws Exception {
if (content == null) {
return;
}
// 纯文本直接追加
if (content instanceof String) {
String text = ((String) content).trim();
if (!text.isEmpty()) {
sb.append(text).append("\n");
}
return;
}
// 处理MimeMultipart
if (content instanceof MimeMultipart multipart) {
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart bodyPart = multipart.getBodyPart(i);
// 跳过附件
if (Part.ATTACHMENT.equalsIgnoreCase(bodyPart.getDisposition())) {
continue;
}
// 提取纯文本
extractPlainTextRecursive(bodyPart.getContent(), sb);
}
return;
}
// 处理单个BodyPart
if (content instanceof MimeBodyPart bodyPart) {
if (Part.ATTACHMENT.equalsIgnoreCase(bodyPart.getDisposition())) {
return;
}
// 读取纯文本内容
String partContent = readBodyPartContent(bodyPart);
if (!partContent.isEmpty()) {
sb.append(partContent).append("\n");
}
}
}
/**
* 标准化HTML结构补全基本标签确保格式合法
*/
private static String standardizeHtml(String html) {
if (html.isEmpty()) {
return "";
}
// 补全HTML基本结构
if (!html.startsWith("<html>")) {
html = "<html><head><meta charset=\"UTF-8\"></head><body>" + html + "</body></html>";
}
// 修复常见的HTML格式问题
html = html.replaceAll("<br>", "<br/>")
.replaceAll("<hr>", "<hr/>")
.replaceAll("&", "&amp;")
.replaceAll("<p>\\s*</p>", "") // 移除空p标签
.replaceAll(">\\s+<", "><"); // 移除标签间多余空格
return html;
}
/**
* 读取BodyPart内容优先原始流自动识别编码
*/
private static String readBodyPartContent(BodyPart part) throws Exception {
// 1. 获取ContentType和编码
String contentType = part.getContentType() == null ? "" : part.getContentType().toLowerCase();
Charset charset = getCharsetFromContentType(contentType);
// 2. 优先读取原始输入流避免getContent()的自动转换错误)
try (InputStream is = part.getInputStream();
BufferedInputStream bis = new BufferedInputStream(is);
BufferedReader reader = new BufferedReader(new InputStreamReader(bis, charset))) {
StringBuilder sb = new StringBuilder();
char[] buffer = new char[4096];
int bytesRead;
while ((bytesRead = reader.read(buffer)) != -1) {
sb.append(buffer, 0, bytesRead);
}
// 清理无效字符
return sb.toString();
} catch (Exception e) {
logger.warn("读取BodyPart流失败" + e.getMessage());
Object fallbackContent = part.getContent();
return fallbackContent instanceof String ? ((String) fallbackContent).trim() : "";
}
}
/**
* 获取邮件内容
*/
private static Charset getCharsetFromContentType(String contentType) {
if (contentType == null || contentType.isEmpty()) {
return StandardCharsets.UTF_8;
}
// 提取charset参数支持多种格式
String charsetStr = null;
String[] parts = contentType.split(";");
for (String part : parts) {
part = part.trim().toLowerCase();
if (part.startsWith("charset=")) {
charsetStr = part.substring("charset=".length()).trim()
.replace("\"", "").replace("'", "");
break;
}
}
// 验证编码有效性
if (charsetStr != null && !charsetStr.isEmpty()) {
try {
return Charset.forName(charsetStr);
} catch (Exception e) {
logger.warn("不支持的编码:" + charsetStr + "使用默认UTF-8");
}
}
// 兜底编码优先GBK兼容中文邮件
try {
return Charset.forName("GBK");
} catch (Exception e) {
return StandardCharsets.UTF_8;
}
}
/**
* 解码MIME编码的文本主题/发件人名称等)
*/
private static String decodeMimeText(String text) {
if (text == null || text.isEmpty()) {
return "";
}
try {
return MimeUtility.decodeText(text);
} catch (UnsupportedEncodingException e) {
// 多级降级处理
try {
return new String(text.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
} catch (Exception e1) {
try {
return new String(text.getBytes(StandardCharsets.ISO_8859_1), "GBK");
} catch (Exception e2) {
try {
return new String(text.getBytes(StandardCharsets.ISO_8859_1), "GB2312");
} catch (Exception e3) {
return text;
}
}
}
}
}
/**
* 下载显式附件(保持原有逻辑)
*/
private static List<BizMailAttachments> downloadExplicitAttachments(Message message, String messageId, String saveBasePath) throws Exception {
List<BizMailAttachments> attachments = new ArrayList<>();
if (!message.isMimeType("multipart/*")) {
return attachments;
}
MimeMultipart multipart = (MimeMultipart) message.getContent();
File saveDir = new File(saveBasePath);
if (!saveDir.exists()) {
Files.createDirectories(saveDir.toPath());
}
// 遍历所有显式附件
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart part = multipart.getBodyPart(i);
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) {
try {
BizMailAttachments attach = downloadSingleAttachment(part, messageId, saveDir);
attachments.add(attach);
} catch (Exception e) {
logger.error("单附件下载失败(文件名:" + part.getFileName() + "" + e.getMessage(), e);
}
}
}
return attachments;
}
/**
* 下载单个附件(保持原有逻辑)
*/
private static BizMailAttachments downloadSingleAttachment(BodyPart part, String messageId, File saveDir) throws Exception {
BizMailAttachments attachment = new BizMailAttachments();
// 1. 记录单个附件下载开始时间
long attachDownloadStartTime = System.currentTimeMillis();
String originalFileName = part.getFileName();
String fileNameForLog = originalFileName == null ? "未知文件名" : decodeMimeText(originalFileName);
logger.info("开始下载附件[" + fileNameForLog + "],开始时间:" + new Date(attachDownloadStartTime));
// 2. 修复:文件名解码(解决乱码)
if (originalFileName != null) {
originalFileName = decodeMimeText(originalFileName);
} else {
originalFileName = "unknown_" + System.currentTimeMillis();
}
// 3. 修复附件FileType优先从文件扩展名识别解决ContentType错误
String fileExt = getFileExtension(originalFileName);
String fileType = MIME_TYPE_MAP.getContentType(originalFileName);
// 降级处理如果MIME类型为空使用BodyPart的ContentType
if (fileType == null || fileType.isEmpty() || fileType.equals("application/octet-stream")) {
fileType = part.getContentType().split(";")[0].trim(); // 去除charset等参数
}
// 4. 计算MD5+保存文件
MessageDigest md = MessageDigest.getInstance("MD5");
ByteBuffer buffer = BUFFER_LOCAL.get();
buffer.clear();
// 临时文件
File tempFile = new File(saveDir, UUID.randomUUID().toString());
try (InputStream is = part.getInputStream();
FileChannel channel = FileChannel.open(tempFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
int bytesRead;
while ((bytesRead = is.read(buffer.array())) != -1) {
md.update(buffer.array(), 0, bytesRead);
buffer.limit(bytesRead);
channel.write(buffer);
buffer.clear();
}
}
// MD5命名避免重名
String md5 = bytesToHex(md.digest());
File finalFile = new File(saveDir, md5 + (fileExt.isEmpty() ? "" : "." + fileExt));
// 处理文件已存在
if (finalFile.exists()) {
logger.warn("附件已存在MD5" + md5 + "),跳过重复保存");
} else {
if (!tempFile.renameTo(finalFile)) {
logger.warn("附件重命名失败,使用临时文件名:" + tempFile.getName());
finalFile = tempFile;
}
}
// 5. 记录单个附件下载结束时间
long attachDownloadEndTime = System.currentTimeMillis();
long costTime = attachDownloadEndTime - attachDownloadStartTime;
logger.info("完成下载附件[" + originalFileName + "],结束时间:" + new Date(attachDownloadEndTime) +
",耗时:" + costTime + "ms文件路径" + finalFile.getAbsolutePath());
// 6. 封装附件信息修复FileType增加下载时间字段
attachment.setStoragePath(finalFile.getAbsolutePath());
attachment.setFileSize(finalFile.length());
attachment.setCreateTime(new Date());
attachment.setTid(System.currentTimeMillis());
attachment.setMailId(System.currentTimeMillis());
attachment.setMessageId(messageId);
attachment.setFileName(originalFileName);
attachment.setFileType(fileType); // 修复后的MIME类型
attachment.setFileExt(fileExt);
attachment.setFileMd5(md5);
attachment.setDownloadCount(0);
attachment.setIsCompressed(isCompressedFile(originalFileName) ? "1" : "0");
attachment.setIsEncrypted("N");
attachment.setDownloadStartTime(new Date(attachDownloadStartTime)); // 附件下载开始时间
attachment.setDownloadEndTime(new Date(attachDownloadEndTime)); // 附件下载结束时间
attachment.setDownloadCostTime(costTime); // 附件下载耗时(毫秒)
return attachment;
}
/**
* 判断是否有显式附件
*/
private static boolean hasExplicitAttachment(Message message) throws Exception {
if (!message.isMimeType("multipart/*")) {
return false;
}
MimeMultipart multipart = (MimeMultipart) message.getContent();
for (int i = 0; i < multipart.getCount(); i++) {
if (Part.ATTACHMENT.equalsIgnoreCase(multipart.getBodyPart(i).getDisposition())) {
return true;
}
}
return false;
}
// ------------------------ 工具方法 ------------------------
private static String getMessageId(Message message) throws MessagingException {
String[] ids = message.getHeader("Message-ID");
if (ids != null && ids.length > 0) {
return ids[0].replace("<", "").replace(">", "").trim();
}
return UUID.randomUUID().toString() + "@" + message.getSession().getProperty("mail.imap.host");
}
private static String convertAddresses(Address[] addresses) throws Exception {
if (addresses == null || addresses.length == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
for (Address addr : addresses) {
sb.append(decodeMimeText(addr.toString())).append(",");
}
return !sb.isEmpty() ? sb.substring(0, sb.length() - 1) : "";
}
private static String getFileExtension(String fileName) {
if (fileName == null || !fileName.contains(".")) {
return "";
}
return fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
}
private static boolean isCompressedFile(String fileName) {
Set<String> exts = new HashSet<>(Arrays.asList("zip", "rar", "7z", "tar", "gz", "bz2"));
return exts.contains(getFileExtension(fileName));
}
private static String bytesToHex(byte[] bytes) {
char[] hex = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
int v = bytes[i] & 0xFF;
hex[i * 2] = Character.forDigit(v >>> 4, 16);
hex[i * 2 + 1] = Character.forDigit(v & 0x0F, 16);
}
return new String(hex).toLowerCase();
}
}