你是否曾经想过,在浏览器中直接点击一个邮件附件,就能预览完整的邮件内容------包括发件人、收件人、抄送、正文甚至内嵌图片?
今天,我们要揭秘一个基于 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-codec 和 eml-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} <${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>