🔥 手把手教你实现前端邮件预览功能

你是否曾经想过,在浏览器中直接点击一个邮件附件,就能预览完整的邮件内容------包括发件人、收件人、抄送、正文甚至内嵌图片?

今天,我们要揭秘一个基于 Vue 3 和 Vant UI 的邮件预览上传组件 ,它不仅能上传 .eml 格式的邮件文件,还能在弹窗中完整渲染邮件内容,甚至支持附件图片的内联展示!


🧩 组件核心功能一览

  • ✅ 支持上传 .eml 格式邮件文件
  • ✅ 限制文件类型、大小、数量
  • ✅ 预览邮件内容(含发件人、收件人、抄送、正文、图片)
  • ✅ 支持附件下载
  • ✅ 响应式栅格布局,适配移动端

🧠 技术架构与实现细节

1. 文件上传与格式校验

组件使用 van-uploader 实现文件选择,并在 beforeRead 方法中进行格式和大小校验:

javascript 复制代码
const beforeRead = (file) => {
  if (!props.accept.includes(file.type)) {
    createToast.fail({ getContainer: 'body', message: '文件格式错误' })
    return false
  }
  // 上传逻辑...
}

2. 邮件内容解析:从二进制到可读 HTML

这是最核心的部分!组件通过 FileReader 读取 .eml 文件内容,并使用 emailjs-mime-codeceml-format 库进行解码:

javascript 复制代码
reader.onload = async (e) => {
  let emlContent = e.target.result;
  emlContent = Codec.quotedPrintableDecode(emlContent, "UTF-8");
  emlFormat.read(emlContent, (err, data) => {
    // 解析出邮件主题、发件人、收件人、正文等
  });
}

3. 邮件主题解码:处理 MIME 编码

邮件主题常常是 MIME 编码的,例如:

css 复制代码
=?UTF-8?B?5paw5bm56Zm15a+G?=

组件使用 Codec.mimeWordDecode 进行解码,确保中文等非 ASCII 字符正确显示。

4. 内嵌图片处理:Uint8Array → Base64

邮件中的图片通常以 cid: 引用,附件中以 Uint8Array 格式存储。组件将其转换为 Base64 并替换到 HTML 中:

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

5. 弹窗预览与下载

使用 Vant 的 Dialog 组件展示邮件内容,并支持一键下载原文件:

javascript 复制代码
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 格式邮件、解码主题、内联图片等高级功能。

如果你正在开发一个需要邮件附件的管理系统、工单系统或邮件审计工具,这个组件绝对是一个值得借鉴和复用的技术方案


如果这篇文章对你有帮助,欢迎点赞、收藏、转发!

我们也欢迎你在评论区留言,分享你在邮件解析或文件上传方面的实战经验!


🚀 源码

vue 复制代码
<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} &lt;${from.email}&gt;</div>`;
  let toList = to.map(item => `${item.name} &lt;${item.email}&gt;`).join('; ');
  header += `<div><b>收件人:</b>${toList}</div>`;
  let ccList = cc.map(item => `${item.name} &lt;${item.email}&gt;`).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>
相关推荐
PineappleCoder7 小时前
性能数据别再瞎轮询了!PerformanceObserver 异步捕获 LCP/CLS,不卡主线程
前端·性能优化
PineappleCoder7 小时前
告别字体闪烁 / 首屏卡顿!preload 让关键资源 “高优先级” 提前到
前端·性能优化
m0_471199637 小时前
【vue】通俗详解package-lock文件的作用
前端·javascript·vue.js
GIS之路8 小时前
GDAL 读取KML数据
前端
今天不要写bug8 小时前
vue项目基于vue-cropper实现图片裁剪与图片压缩
前端·javascript·vue.js·typescript
用户47949283569158 小时前
记住这张时间线图,你再也不会乱用 useEffect / useLayoutEffect
前端·react.js
汝生淮南吾在北8 小时前
SpringBoot+Vue养老院管理系统
vue.js·spring boot·后端·毕业设计·毕设
咬人喵喵9 小时前
14 类圣诞核心 SVG 交互方案拆解(附案例 + 资源)
开发语言·前端·javascript
问君能有几多愁~9 小时前
C++ 日志实现
java·前端·c++