feat: attachment viewing function (#58)

This commit is contained in:
Dream Hunter
2024-01-13 23:57:52 +08:00
committed by GitHub
parent 2952a641a5
commit 97a1f0a968
11 changed files with 604 additions and 472 deletions

7
CHANGELOG Normal file
View File

@@ -0,0 +1,7 @@
# CHANGE LOG
## 2024-01-13
DB changes
- db/2024-01-13-path.sql

View File

@@ -35,6 +35,7 @@
- [x] 支持多语言
- [x] 增加访问授权,可作为私人站点
- [x] 增加自动回复功能
- [x] 增加查看附件功能
- [ ] 免费版附件过大会造成 Exceeded CPU Limit 错误
---

View File

@@ -17,6 +17,7 @@ This is a temporary email service that uses Cloudflare Workers to create a tempo
- [x] Support multiple languages
- [x] Add access authorization, which can be used as a private site
- [x] Add auto reply feature
- [x] Add attachment viewing function
- [ ] Exceeded CPU Limit error caused by the free version of the attachment
![demo](readme_assets/demo.png)

4
db/2024-01-13-path.sql Normal file
View File

@@ -0,0 +1,4 @@
ALTER TABLE
mails
ADD
message_id TEXT;

View File

@@ -1,5 +1,6 @@
CREATE TABLE IF NOT EXISTS mails (
id INTEGER PRIMARY KEY,
message_id TEXT,
source TEXT,
address TEXT,
subject TEXT,
@@ -23,3 +24,12 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY,
source TEXT,
address TEXT,
message_id TEXT,
data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -10,17 +10,17 @@
},
"dependencies": {
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.7.0",
"axios": "^1.6.2",
"naive-ui": "^2.35.0",
"@vueuse/core": "^10.7.1",
"axios": "^1.6.5",
"naive-ui": "^2.37.3",
"vooks": "^0.2.12",
"vue": "^3.3.11",
"vue": "^3.4.13",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.8.0",
"vue-i18n": "^9.9.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
"@vitejs/plugin-vue": "^4.6.2",
"vite": "^4.5.1",
"vite-plugin-pwa": "^0.17.4"
}

908
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,11 +25,11 @@ const apiFetch = async (path, options = {}) => {
});
if (response.status === 401 && openSettings.value.auth) {
showAuth.value = true;
throw new Error("Unauthorized");
throw new Error("Unauthorized, you password is wrong")
}
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
throw new Error("Unauthorized");
throw new Error("Unauthorized, you admin password is wrong")
}
if (response.status >= 300) {
throw new Error(`${response.status} ${response.data}` || "error");

View File

@@ -1,13 +1,14 @@
<script setup>
import { NSpace, NAlert, NSwitch, NCard, NInput, NInputGroupLabel } from 'naive-ui'
import { NButton, NLayout, NInputGroup, NModal, NSelect, NPagination } from 'naive-ui'
import { NList, NListItem, NThing, NTag } from 'naive-ui'
import { NList, NListItem, NThing, NTag, NIcon } from 'naive-ui'
import { watch, onMounted, ref } from "vue";
import useClipboard from 'vue-clipboard3'
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { api } from '../api'
import { CloudDownloadRound } from '@vicons/material'
const { toClipboard } = useClipboard()
const message = useMessage()
@@ -25,6 +26,9 @@ const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
const { t } = useI18n({
locale: 'zh',
messages: {
@@ -42,6 +46,7 @@ const { t } = useI18n({
refresh: 'Refresh',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
attachments: 'Show Attachments',
},
zh: {
yourAddress: '你的邮箱地址是',
@@ -57,6 +62,7 @@ const { t } = useI18n({
refresh: '刷新',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
attachments: '查看附件',
}
}
});
@@ -128,6 +134,31 @@ const newEmail = async () => {
}
};
const getAttachments = async (attachment_id) => {
try {
const res = await api.fetch(
`/api/attachment/${attachment_id}`
);
curAttachments.value = res
.filter((item) => item?.content?.data)
.map((item) => {
return {
id: item.contentId || Math.random().toString(36).substring(2, 15),
filename: item.filename || "",
size: item.size,
url: URL.createObjectURL(
new Blob(
[new Uint8Array(item.content.data)],
{ type: item.contentType || 'application/octet-stream' }
))
}
});
showAttachments.value = true;
} catch (error) {
message.error(error.message || "error");
}
};
onMounted(async () => {
await api.getOpenSettings(message);
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
@@ -182,6 +213,10 @@ onMounted(async () => {
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-button v-if="row.attachment_id" size="small" tertiary type="info"
@click="getAttachments(row.attachment_id)">
{{ t('attachments') }}
</n-button>
</n-space>
</template>
<div v-html="row.message"></div>
@@ -227,6 +262,31 @@ onMounted(async () => {
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("attachments") }}</div>
</template>
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
</n-space>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small'" :download="row.filename" :href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-list-item>
</n-list>
<template #action>
</template>
</n-modal>
</div>
</template>

View File

@@ -35,18 +35,22 @@ async function email(message, env, ctx) {
const parser = new PostalMime.default();
parsedEmail = await parser.parse(rawEmail);
}
const message_id = message.headers.get("Message-ID");
// process email
const { success } = await env.DB.prepare(
`INSERT INTO mails (source, address, subject, message) VALUES (?, ?, ?, ?)`
`INSERT INTO mails (source, address, subject, message, message_id) VALUES (?, ?, ?, ?, ?)`
).bind(
message.from, message.to,
parsedEmail.subject || "",
parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || ""
parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || "",
message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
// auto reply email
try {
const results = await env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
@@ -75,6 +79,27 @@ async function email(message, env, ctx) {
} catch (error) {
console.log("reply email error", error);
}
// process attachments
try {
if (
parsedEmail.attachments
&& parsedEmail.attachments.length > 0
) {
const { success } = await env.DB.prepare(
`INSERT INTO attachments (source, address, message_id, data) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, message_id,
JSON.stringify(parsedEmail.attachments)
).run();
if (!success) {
message.setReject(`Failed save attachment to ${message.to}`);
console.log(`Failed save attachment from ${message.from} to ${message.to}`);
}
}
}
catch (error) {
console.log("save attachment error", error);
}
} else {
message.setReject(`Unknown address ${message.to}`);
console.log(`Unknown address ${message.to}`);

View File

@@ -16,7 +16,7 @@ api.get('/api/mails', async (c) => {
return c.text("Invalid offset", 400)
}
const { results } = await c.env.DB.prepare(
`SELECT id, source, subject, message FROM mails where address = ? order by id desc limit ? offset ?`
`SELECT id, source, subject, message, message_id FROM mails where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
@@ -25,6 +25,23 @@ api.get('/api/mails', async (c) => {
).bind(address).first();
count = mailCount;
}
// add attachments
let attachmentResults = [];
const message_ids = results.map((r) => r.message_id).filter((r) => r);
console.log("message_ids", message_ids.map((id) => `'${id}'`).join(","));
if (message_ids && message_ids.length > 0) {
const { results: innerAttachmentResults } = await c.env.DB.prepare(
`SELECT id, message_id FROM attachments where message_id in (${message_ids.map((id) => `'${id}'`).join(",")})`
).all();
attachmentResults = innerAttachmentResults || [];
}
results.forEach((r) => {
const attachment_id = attachmentResults.filter((ar) => ar.message_id == r.message_id).map((ar) => ar.id);
if (attachment_id && attachment_id.length > 0) {
r.attachment_id = attachment_id[0];
}
delete r.message_id;
})
return c.json({
results: results,
count: count
@@ -34,7 +51,7 @@ api.get('/api/mails', async (c) => {
api.get('/api/settings', async (c) => {
const { address } = c.get("jwtPayload")
const results = await c.env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ?`
`SELECT * FROM auto_reply_mails where address = ? `
).bind(address).first();
if (!results) {
return c.json({
@@ -68,9 +85,9 @@ api.post('/api/settings', async (c) => {
const { success } = await c.env.DB.prepare(
`INSERT OR REPLACE INTO
auto_reply_mails
(name, address, source_prefix, subject, message, enabled)
(name, address, source_prefix, subject, message, enabled)
VALUES
(?, ?, ?, ?, ?, ?)`
(?, ?, ?, ?, ?, ?)`
).bind(name || '', address, source_prefix || '', subject || '', message || '', enabled ? 1 : 0).run();
if (!success) {
return c.text("Failed to save settings", 500)
@@ -226,7 +243,7 @@ api.get('/admin/mails_unknow', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(`
SELECT count(*) as count FROM mails
where address NOT IN
(select concat('${c.env.PREFIX}', name) from address)`
(select concat('${c.env.PREFIX}', name) from address)`
).first();
count = mailCount;
}
@@ -236,5 +253,16 @@ api.get('/admin/mails_unknow', async (c) => {
})
});
// attachments
api.get("/api/attachment/:attachment_id", async (c) => {
const { attachment_id } = c.req.param();
const { data } = await c.env.DB.prepare(
`SELECT data FROM attachments where id = ? `
).bind(attachment_id).first();
if (!data) {
return c.text("Not found", 404)
}
return c.json(JSON.parse(data))
})
export { api }