博客 / 詳情

返回

手把手教你實現前端郵件預覽功能

🧑‍💻 寫在開頭

點贊 + 收藏 === 學會🤣🤣🤣

你是否曾經想過,在瀏覽器中直接點擊一個郵件附件,就能預覽完整的郵件內容——包括髮件人、收件人、抄送、正文甚至內嵌圖片?
今天,我們要揭秘一個基於 Vue 3 和 Vant UI 的郵件預覽上傳組件,它不僅能上傳 .eml 格式的郵件文件,還能在彈窗中完整渲染郵件內容,甚至支持附件圖片的內聯展示!


🧩 組件核心功能一覽

  • ✅ 支持上傳 .eml 格式郵件文件
  • ✅ 限制文件類型、大小、數量
  • ✅ 預覽郵件內容(含發件人、收件人、抄送、正文、圖片)
  • ✅ 支持附件下載
  • ✅ 響應式柵格佈局,適配移動端

🧠 技術架構與實現細節

1. 文件上傳與格式校驗

組件使用 van-uploader 實現文件選擇,並在 beforeRead 方法中進行格式和大小校驗:

const beforeRead = (file) => {
  if (!props.accept.includes(file.type)) {
    createToast.fail({ getContainer: 'body', message: '文件格式錯誤' })
    return false
  }
  // 上傳邏輯...
}

2. 郵件內容解析:從二進制到可讀 HTML

這是最核心的部分!組件通過 FileReader 讀取 .eml 文件內容,並使用 emailjs-mime-codec 和 eml-format 庫進行解碼:

reader.onload = async (e) => {
  let emlContent = e.target.result;
  emlContent = Codec.quotedPrintableDecode(emlContent, "UTF-8");
  emlFormat.read(emlContent, (err, data) => {
    // 解析出郵件主題、發件人、收件人、正文等
  });
}

3. 郵件主題解碼:處理 MIME 編碼

郵件主題常常是 MIME 編碼的,例如:

=?UTF-8?B?5paw5bm56Zm15a+G?=

組件使用 Codec.mimeWordDecode 進行解碼,確保中文等非 ASCII 字符正確顯示。

4. 內嵌圖片處理:Uint8Array → Base64

郵件中的圖片通常以 cid: 引用,附件中以 Uint8Array 格式存儲。組件將其轉換為 Base64 並替換到 HTML 中:

const base64String = uint8ArrayToBase64(item.data);
_html = _html.replaceAll(`cid:${cid}`, `data:image/${item.name.split('.').at(-1)};base64,${base64String}`);

5. 彈窗預覽與下載

使用 Vant 的 Dialog 組件展示郵件內容,並支持一鍵下載原文件:

Dialog({
  message: concatHeader(data, title),
  messageAlign: 'left',
  className: "eml-dialog",
  showCancelButton: true,
  confirmButtonText: "下載"
}).then(async() => {
  await nativeApi.downloadFile(encodeURI(item))
  createToast.success({ getContainer: 'body', message: '保存成功' })
})

🎨 界面與交互設計

  • 使用 van-grid 實現響應式文件列表
  • 每個文件項顯示為附件圖標,點擊可預覽或下載
  • 右上角刪除按鈕支持編輯模式下移除文件
  • 提示信息友好,限制條件明確

🛠 可擴展性與優化建議

  • 類型推斷:可增加一個函數根據 file.type 推斷文件後綴名,增強兼容性
  • 錯誤處理:增加更多讀取失敗或格式錯誤的 fallback 邏輯
  • 性能優化:大文件分片讀取,避免阻塞 UI

🚀 總結

這個組件不僅實現了郵件上傳與預覽的完整鏈路,還展示瞭如何在瀏覽器中處理複雜的 MIME 格式郵件、解碼主題、內聯圖片等高級功能。
如果你正在開發一個需要郵件附件的管理系統、工單系統或郵件審計工具,這個組件絕對是一個值得借鑑和複用的技術方案


如果這篇文章對你有幫助,歡迎點贊、收藏、轉發!
我們也歡迎你在評論區留言,分享你在郵件解析或文件上傳方面的實戰經驗!


🚀 源碼

<template>
  <div>
    <van-grid :border="false" :column-num="4" :gutter="10" class="emlBox">
      <!-- 上傳更多圖片 -->
      <van-grid-item v-for="(item, index) in list" :key="index">
        <div class="picBox">
          <div @click.stop="preview(item)">
            <svg-icon
              icon-class="new-fujian"
              icon="new-fujian"
              class-name="file-svg-icon"
            />
          </div>
          <van-icon
            @click.stop="list.splice(index,1)"
            class="closeIcon"
            name="clear"
            v-if="isEdit"
          />
        </div>
      </van-grid-item>

      <van-grid-item class="uploadGrid" v-if="list.length < maxCount && isEdit">
        <van-uploader
          :max-count="maxCount"
          :max-size="maxSize*1024*1024"
          :accept="accept"
          class="file-upload__uploader"
          :preview-image="false"
          upload-icon="plus"
          :before-read="beforeRead"
        />
      </van-grid-item>
    </van-grid>
    <div class="tip" v-if="isEdit">只能上傳{{ acceptFile }}文件,不超過{{ maxSize }}M</div>
  </div>
</template>
<script setup>
import { useVModel } from "@vueuse/core"
import { ref, defineProps, defineEmits } from "vue"
import inspectionApi from "@/service/apis/modules/inspectionApi.js"
import { useToast } from '@/hooks'
import * as emlFormat from 'eml-format';
import * as Codec from 'emailjs-mime-codec';
import { Dialog } from "vant";
import nativeApi from '@/tools/native.js'

const props = defineProps({
  maxCount: {
    type: [Number, String],
    defaule: 5
  },
  maxSize: {
    type: [Number, String],
    defaule: 20
  },
  filelList: {
    type: Array,
    default: () => []
  },
  accept: {
    type: String,
    default: 'message/rfc822'
  },
  acceptFile: {
    type: String,
    default: "eml"
  },
  isEdit: {
    type: Boolean,
    default: true
  }
})
const emit = defineEmits(['update:filelList'])
const list = useVModel(props, 'filelList', emit, {
  defaultValue: []
})
const { createToast } = useToast()
const beforeRead = (file) => {
  if (!props.accept.includes(file.type)) {
    createToast.fail({ getContainer: 'body', message: '文件格式錯誤' })
    return false
  }
  let formData = new FormData();
  formData.append("file", file);
  inspectionApi.uploadVideoAPI(formData).then(res => {
    list.value.push(res);
  })
  return true
}
function removeGarbledChars(html) {
  // 刪除最後一個div閉合標籤後的多餘字符
  let content = html;
  let lastDivIndex = html.lastIndexOf('</div>');
  if (lastDivIndex !== -1) {
    content = content.substring(0, lastDivIndex + 6);
  }
  return content
}
// 拼接郵件內容的發件人/收件人/抄送/附件等信息
function concatHeader (file, title) {
  const {to, from, cc, attachments, html} = file;
  let header = `<div><b>主題:</b>${title}</div>`;
  header += `<div><b>發件人:</b>${from.name} <${from.email}></div>`;
  let toList = to.map(item => `${item.name} <${item.email}>`).join('; ');
  header += `<div><b>收件人:</b>${toList}</div>`;
  let ccList = cc.map(item => `${item.name} <${item.email}>`).join('; ');
  header += `<div><b>抄送:</b>${ccList}</div>`;
  header += '<div><b>郵件內容:</b></div>';
  header += removeGarbledChars(html);
  return header;
}
const preview = async (item) => {
  // 郵件預覽功能暫時取消,直接下載文件,附件回顯問題無法解決
  if (item.split(".").at(-1).toLowerCase() === 'eml') {
    fetch(encodeURI(item)).then(res => res.blob()).then((data) => {
      const blob = new Blob([data]);
      const reader = new FileReader();
      reader.onload = async (e) => {
        let emlContent = e.target.result;
        emlContent = Codec.quotedPrintableDecode(emlContent);
        emlFormat.read(emlContent, (err, data) => {
          let title = ''
          if (data.subject) {
            title = Codec.mimeWorsdDecode(data.subject);
          }
          Dialog({
            message: concatHeader(data, title),
            messageAlign: 'left',
            className: "eml-dialog",
            showCancelButton: true,
            confirmButtonText: "下載"
          })
        })
      }
      reader.readAsText(blob);
    })
  }
}
// 文件對象中的type和後綴名不一定一致,所以需要判斷,寫一個函數根據文件的type返回文件後綴名


</script>
<style lang="less">
.eml-dialog {
  .van-dialog__message {
    display: flex;
    flex-direction: column;
  }
  .van-dialog__message > div {
    width: fit-content;
  }
}
</style>
<style lang="less" scoped>
.tip {
  color: #999999;
  margin-bottom: 12px;
  padding-left: 16px !important;
}
.closeIcon {
  position: absolute;
  right: 0px;
  top: 10px;
  font-size: 20px;
}
.file-upload__uploader {
  width: 100%;
  ::v-deep {
    .van-uploader__upload {
      margin: 0;
    }
    .van-uploader__upload-icon {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      color: #999999;
      font-weight: bold;
      width: 100%;
      height: 80px;
      background: #fdfdfd;
      border-radius: 6px;
      border: 1px solid #e5e5e5;
      overflow: hidden;
      font-size: 12px;
    }
  }
}
.emlBox {
  padding-left: 16px !important;
  padding-right: 6px;
  .picBox {
    width: 100%;
  }
  .uploadGrid {
    padding-right: 0 !important;
  }
  ::v-deep {
    .van-grid-item__content {
      padding: 10px 0;
      justify-content: start;
      position: relative;
    }
  }
}
.file-svg-icon {
  width: 100%;
  height: 80px;
}
</style>

如果對您有所幫助,歡迎您點個關注,我會定時更新技術文檔,大家一起討論學習,一起進步。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.