package mail import ( "eta/eta_crawler/utils" "fmt" "io" "log" "os" "path" "regexp" "strings" "time" "github.com/emersion/go-imap" "github.com/emersion/go-imap/client" "github.com/emersion/go-message" "github.com/emersion/go-message/mail" "github.com/h2non/filetype" ) type MailMessage struct { Date time.Time `description:"收件时间"` Uid uint32 `description:"该邮件在邮箱中的唯一id"` FromAddress string `description:"发件人邮箱"` From string `description:"发件人名称"` Title string `description:"邮件标题"` Content string `description:"邮件主体正文"` Resources map[string]string `description:"正文内嵌资源"` Attachment map[string][]byte `description:"附件资源"` } func ListenMail(mailAddress, folder, userName, password string, readBatchSize, lastNday int) (err error) { // 收件箱 defer func() { // 处理结束 if err != nil { fmt.Println("err:", err.Error()) utils.FileLog.Info("中国煤炭网邮件监听, err:%s", err.Error()) } }() // 建立与 IMAP 服务器的连接 c, err := client.DialTLS(mailAddress, nil) if err != nil { fmt.Printf("连接 IMAP 服务器失败: %+v \n", err) return } // 最后一定不要忘记退出登录 defer func() { _ = c.Logout() }() // 登录 if err = c.Login(userName, password); err != nil { fmt.Printf("邮箱[%s] 登录失败: %v \n", fmt.Sprintf("%s:%s", userName, mailAddress), err) return } // 列出当前邮箱中的文件夹 mailboxes := make(chan *imap.MailboxInfo, 10) done := make(chan error, 1) // 记录错误的 chan go func() { done <- c.List("", "*", mailboxes) }() utils.FileLog.Info("-->当前邮箱的文件夹 Mailboxes:") var folderExists bool for m := range mailboxes { utils.FileLog.Info("* %s", m.Name) if m.Name == folder { folderExists = true } } err = <-done if err != nil { utils.FileLog.Error("列出邮箱列表时,出现错误:%v \n", err) return } utils.FileLog.Info("-->列出邮箱列表完毕!") if !folderExists { err = fmt.Errorf("文件夹[%s] 不存在", folder) return } message.CharsetReader = myCharsetReader // 选择指定的文件夹 mbox, err := c.Select(folder, false) if err != nil { err = fmt.Errorf("选择邮件箱失败: %+v", err) return } utils.FileLog.Info("当前文件夹[%s]中,总共有 %d 封邮件", folder, mbox.Messages) if mbox.Messages == 0 { return } // 创建一个序列集,用于批量读取邮件 seqSet := new(imap.SeqSet) to := mbox.Messages // 此文件下的邮件总数 now := time.Now() startTime := time.Date(now.Year(), now.Month(), now.Day()-lastNday, 0, 0, 0, 0, time.Local) var isStopFor bool step := uint32(1) for i := to; i >= 1; { start := i - step + 1 seqSet.Clear() seqSet.AddRange(start, i) // 添加指定范围内的邮件编号 // 获取整个消息正文 // imap.FetchEnvelope:请求获取邮件的信封数据(例如发件人、收件人、主题等元数据)。 // imap.FetchRFC822:请求获取完整的邮件内容,包括所有头部和正文。 items := []imap.FetchItem{imap.FetchFlags, imap.FetchEnvelope, imap.FetchRFC822, imap.FetchBodyStructure} // 获取邮件内容 Start messages := make(chan *imap.Message, readBatchSize) // 创建一个通道,用于接收邮件消息 fetchDone := make(chan error, 1) // 创建一个通道,用于接收错误消息 go func() { // Fetch方法用于从服务器获取邮件数据,这里请求了邮件的信封和完整内容 fetchDone <- c.Fetch(seqSet, items, messages) }() err = <-fetchDone if err != nil { utils.FileLog.Error("获取邮件信息出现错误:%v \n", err) return } // 获取邮件内容 End for msg := range messages { // 如果需要终止,那么就不处理了 if isStopFor { continue } // 判断当前邮件收件时间是否小于设定的时间,如果是,那么就不处理了 envelope := msg.Envelope if envelope != nil { if envelope.Date.Before(startTime) { continue } } else { continue } utils.FileLog.Info("正在读取邮件uid: %d", msg.Uid) emailMessage, tmpErr := readEveryMsg(msg) if tmpErr != nil { // 移除本地文件 { for _, v := range emailMessage.Resources { os.Remove(v) } } utils.FileLog.Error("读取邮件内容时出现错误:%v \n", tmpErr) continue } } if isStopFor { // 已经找到了最小的邮件id,那么就退出循环了 break } i = i - step } utils.FileLog.Info("读取了所有邮件,完毕!") return } // document link: https://github.com/emersion/go-imap/wiki/Fetching-messages func readEveryMsg(msg *imap.Message) (emailMessage MailMessage, err error) { defer func() { if err != nil { utils.FileLog.Error("邮件读取失败;Err:%s", err.Error()) } }() message.CharsetReader = myCharsetReader emailMessage.Resources = make(map[string]string) // 内嵌资源 emailMessage.Attachment = make(map[string][]byte) // 附件 emailMessage.Uid = msg.Uid htmlStr := `` textStr := `` // 获取邮件正文 r := msg.GetBody(&imap.BodySectionName{}) if r == nil { utils.FileLog.Info("服务器没有返回消息内容") } mr, err := mail.CreateReader(r) if err != nil { err = fmt.Errorf("邮件读取时出现错误:%v", err) return } // 收件时间 { date, err := mr.Header.Date() if err != nil { log.Println("收件时间 异常:", err.Error()) } emailMessage.Date = date } // 发件人 { fromStr := mr.Header.Get("From") // 处理无效地址的情况 if !strings.Contains(fromStr, "@") { emailMessage.FromAddress = fromStr emailMessage.From = fromStr } else { from, tmpErr := mr.Header.AddressList("From") if tmpErr != nil { log.Println("发件人 异常:", err.Error()) } if len(from) > 0 { emailMessage.FromAddress = from[0].Address emailMessage.From = from[0].Name } } } // 邮件标题 subject, err := mr.Header.Subject() if err != nil { utils.FileLog.Warning("邮件主题 Subject ERR:%v", err) } emailMessage.Title = subject for { p, tmpErr := mr.NextPart() if tmpErr == io.EOF { break } else if tmpErr != nil { utils.FileLog.Error("读取邮件内容时出现错误:%v", tmpErr) err = tmpErr return } bodyBytes, _ := io.ReadAll(p.Body) if err != nil { err = fmt.Errorf("读取邮件部分时出现错误:%v", err) return } switch h := p.Header.(type) { case *mail.InlineHeader: // 这是消息的文本(可以是纯文本或 HTML) contentType := h.Get("Content-Type") if strings.HasPrefix(contentType, "text/plain") { textStr += string(bodyBytes) } else if strings.HasPrefix(contentType, "text/html") { htmlStr += string(bodyBytes) } // 这是内嵌资源 if cid := p.Header.Get("Content-ID"); cid != "" { // 确定文件后缀 fileSuffix := determineFileSuffix(bodyBytes) fileName := fmt.Sprintf("%s%s.%s", utils.CoalFilePath, cid[1:len(cid)-1], fileSuffix) err = SaveToFile(bodyBytes, fileName) if err != nil { err = fmt.Errorf("保存文件时出现错误:%v", err) return } emailMessage.Resources[cid] = fileName } case *mail.AttachmentHeader: // 这是一个附件 filename, _ := h.Filename() fmt.Printf("读取到到附件: %s \n", filename) utils.FileLog.Info("读取到附件: %s ", filename) if !IsMatchExt(filename) { continue } filePath := fmt.Sprintf("%s%s%s%s", utils.CoalFilePath, `file`, string(os.PathSeparator), filename) err = SaveToFile(bodyBytes, filePath) if err != nil { err = fmt.Errorf("保存文件时出现错误:%v", err) return } fmt.Printf("保存到文件: %s \n", filePath) utils.FileLog.Info("保存到文件: %s ", filePath) // 这是附件资源 if contentDisposition := p.Header.Get("Content-Disposition"); contentDisposition != "" { if strings.HasPrefix(contentDisposition, "attachment") { emailMessage.Attachment[filename] = bodyBytes } } else if cid := p.Header.Get("Content-ID"); cid != "" { // 这是内嵌资源 emailMessage.Resources[cid] = filePath } default: utils.FileLog.Info("未知格式:", h) } } emailMessage.Content = htmlStr if emailMessage.Content == `` { emailMessage.Content = textStr } return } // 根据文件内容确定文件后缀 func determineFileSuffix(content []byte) string { kind, err := filetype.Match(content) if err != nil { utils.FileLog.Error("无法确定文件类型:%v \n", err) return ".bin" } return kind.Extension } func SaveToFile(content []byte, fileName string) error { file, err := os.Create(fileName) if err != nil { return err } defer func() { _ = file.Close() }() _, err = file.Write(content) if err != nil { return err } return nil } // ContainsWholeWord 检查字符串 s 中是否包含完整的单词 word。 // 该函数使用正则表达式来匹配整个单词,确保不会错误地匹配到单词的一部分。 // 参数: // // s: 要搜索的字符串 // word: 要查找的完整单词 // // 返回值: // // 如果 s 中包含完整的单词 word,则返回 true;否则返回 false。 func ContainsWholeWord(s string, word string) bool { pattern := fmt.Sprintf(`\b%s\b`, regexp.QuoteMeta(word)) re := regexp.MustCompile(pattern) return re.MatchString(s) } func IsMatchExt(filename string) (ok bool) { exts := utils.CoalEmailFileExt extArr := strings.Split(exts, "|") for _, ext := range extArr { ex := strings.ToLower(path.Ext(filename)) if ext == ex { ok = true return } } return }