mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-07-11 07:21:28 +08:00
feat: attachment viewing function (#58)
This commit is contained in:
7
CHANGELOG
Normal file
7
CHANGELOG
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# CHANGE LOG
|
||||||
|
|
||||||
|
## 2024-01-13
|
||||||
|
|
||||||
|
DB changes
|
||||||
|
|
||||||
|
- db/2024-01-13-path.sql
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
- [x] 支持多语言
|
- [x] 支持多语言
|
||||||
- [x] 增加访问授权,可作为私人站点
|
- [x] 增加访问授权,可作为私人站点
|
||||||
- [x] 增加自动回复功能
|
- [x] 增加自动回复功能
|
||||||
|
- [x] 增加查看附件功能
|
||||||
- [ ] 免费版附件过大会造成 Exceeded CPU Limit 错误
|
- [ ] 免费版附件过大会造成 Exceeded CPU Limit 错误
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ This is a temporary email service that uses Cloudflare Workers to create a tempo
|
|||||||
- [x] Support multiple languages
|
- [x] Support multiple languages
|
||||||
- [x] Add access authorization, which can be used as a private site
|
- [x] Add access authorization, which can be used as a private site
|
||||||
- [x] Add auto reply feature
|
- [x] Add auto reply feature
|
||||||
|
- [x] Add attachment viewing function
|
||||||
- [ ] Exceeded CPU Limit error caused by the free version of the attachment
|
- [ ] Exceeded CPU Limit error caused by the free version of the attachment
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
4
db/2024-01-13-path.sql
Normal file
4
db/2024-01-13-path.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE
|
||||||
|
mails
|
||||||
|
ADD
|
||||||
|
message_id TEXT;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
CREATE TABLE IF NOT EXISTS mails (
|
CREATE TABLE IF NOT EXISTS mails (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
message_id TEXT,
|
||||||
source TEXT,
|
source TEXT,
|
||||||
address TEXT,
|
address TEXT,
|
||||||
subject TEXT,
|
subject TEXT,
|
||||||
@@ -23,3 +24,12 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
|
|||||||
enabled INTEGER DEFAULT 1,
|
enabled INTEGER DEFAULT 1,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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
|
||||||
|
);
|
||||||
|
|||||||
@@ -10,17 +10,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vicons/material": "^0.12.0",
|
"@vicons/material": "^0.12.0",
|
||||||
"@vueuse/core": "^10.7.0",
|
"@vueuse/core": "^10.7.1",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.5",
|
||||||
"naive-ui": "^2.35.0",
|
"naive-ui": "^2.37.3",
|
||||||
"vooks": "^0.2.12",
|
"vooks": "^0.2.12",
|
||||||
"vue": "^3.3.11",
|
"vue": "^3.4.13",
|
||||||
"vue-clipboard3": "^2.0.0",
|
"vue-clipboard3": "^2.0.0",
|
||||||
"vue-i18n": "^9.8.0",
|
"vue-i18n": "^9.9.0",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^4.6.2",
|
||||||
"vite": "^4.5.1",
|
"vite": "^4.5.1",
|
||||||
"vite-plugin-pwa": "^0.17.4"
|
"vite-plugin-pwa": "^0.17.4"
|
||||||
}
|
}
|
||||||
|
|||||||
908
frontend/pnpm-lock.yaml
generated
908
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -25,11 +25,11 @@ const apiFetch = async (path, options = {}) => {
|
|||||||
});
|
});
|
||||||
if (response.status === 401 && openSettings.value.auth) {
|
if (response.status === 401 && openSettings.value.auth) {
|
||||||
showAuth.value = true;
|
showAuth.value = true;
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized, you password is wrong")
|
||||||
}
|
}
|
||||||
if (response.status === 401 && path.startsWith("/admin")) {
|
if (response.status === 401 && path.startsWith("/admin")) {
|
||||||
showAdminAuth.value = true;
|
showAdminAuth.value = true;
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized, you admin password is wrong")
|
||||||
}
|
}
|
||||||
if (response.status >= 300) {
|
if (response.status >= 300) {
|
||||||
throw new Error(`${response.status} ${response.data}` || "error");
|
throw new Error(`${response.status} ${response.data}` || "error");
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { NSpace, NAlert, NSwitch, NCard, NInput, NInputGroupLabel } from 'naive-ui'
|
import { NSpace, NAlert, NSwitch, NCard, NInput, NInputGroupLabel } from 'naive-ui'
|
||||||
import { NButton, NLayout, NInputGroup, NModal, NSelect, NPagination } 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 { watch, onMounted, ref } from "vue";
|
||||||
import useClipboard from 'vue-clipboard3'
|
import useClipboard from 'vue-clipboard3'
|
||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { CloudDownloadRound } from '@vicons/material'
|
||||||
|
|
||||||
const { toClipboard } = useClipboard()
|
const { toClipboard } = useClipboard()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -25,6 +26,9 @@ const count = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const showAttachments = ref(false)
|
||||||
|
const curAttachments = ref([])
|
||||||
|
|
||||||
const { t } = useI18n({
|
const { t } = useI18n({
|
||||||
locale: 'zh',
|
locale: 'zh',
|
||||||
messages: {
|
messages: {
|
||||||
@@ -42,6 +46,7 @@ const { t } = useI18n({
|
|||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
passwordTip: 'Please copy the password and you can use it to login to your email account.',
|
passwordTip: 'Please copy the password and you can use it to login to your email account.',
|
||||||
|
attachments: 'Show Attachments',
|
||||||
},
|
},
|
||||||
zh: {
|
zh: {
|
||||||
yourAddress: '你的邮箱地址是',
|
yourAddress: '你的邮箱地址是',
|
||||||
@@ -57,6 +62,7 @@ const { t } = useI18n({
|
|||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
|
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 () => {
|
onMounted(async () => {
|
||||||
await api.getOpenSettings(message);
|
await api.getOpenSettings(message);
|
||||||
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
|
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
|
||||||
@@ -182,6 +213,10 @@ onMounted(async () => {
|
|||||||
<n-tag type="info">
|
<n-tag type="info">
|
||||||
ID: {{ row.id }}
|
ID: {{ row.id }}
|
||||||
</n-tag>
|
</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>
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
<div v-html="row.message"></div>
|
<div v-html="row.message"></div>
|
||||||
@@ -227,6 +262,31 @@ onMounted(async () => {
|
|||||||
<template #action>
|
<template #action>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -35,18 +35,22 @@ async function email(message, env, ctx) {
|
|||||||
const parser = new PostalMime.default();
|
const parser = new PostalMime.default();
|
||||||
parsedEmail = await parser.parse(rawEmail);
|
parsedEmail = await parser.parse(rawEmail);
|
||||||
}
|
}
|
||||||
|
const message_id = message.headers.get("Message-ID");
|
||||||
|
|
||||||
|
// process email
|
||||||
const { success } = await env.DB.prepare(
|
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(
|
).bind(
|
||||||
message.from, message.to,
|
message.from, message.to,
|
||||||
parsedEmail.subject || "",
|
parsedEmail.subject || "",
|
||||||
parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || ""
|
parsedEmail.html || parsedEmail.textAsHtml || parsedEmail.text || "",
|
||||||
|
message_id
|
||||||
).run();
|
).run();
|
||||||
if (!success) {
|
if (!success) {
|
||||||
message.setReject(`Failed save message to ${message.to}`);
|
message.setReject(`Failed save message to ${message.to}`);
|
||||||
console.log(`Failed save message from ${message.from} to ${message.to}`);
|
console.log(`Failed save message from ${message.from} to ${message.to}`);
|
||||||
}
|
}
|
||||||
|
// auto reply email
|
||||||
try {
|
try {
|
||||||
const results = await env.DB.prepare(
|
const results = await env.DB.prepare(
|
||||||
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
|
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
|
||||||
@@ -75,6 +79,27 @@ async function email(message, env, ctx) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("reply email error", 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 {
|
} else {
|
||||||
message.setReject(`Unknown address ${message.to}`);
|
message.setReject(`Unknown address ${message.to}`);
|
||||||
console.log(`Unknown address ${message.to}`);
|
console.log(`Unknown address ${message.to}`);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ api.get('/api/mails', async (c) => {
|
|||||||
return c.text("Invalid offset", 400)
|
return c.text("Invalid offset", 400)
|
||||||
}
|
}
|
||||||
const { results } = await c.env.DB.prepare(
|
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();
|
).bind(address, limit, offset).all();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
if (offset == 0) {
|
if (offset == 0) {
|
||||||
@@ -25,6 +25,23 @@ api.get('/api/mails', async (c) => {
|
|||||||
).bind(address).first();
|
).bind(address).first();
|
||||||
count = mailCount;
|
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({
|
return c.json({
|
||||||
results: results,
|
results: results,
|
||||||
count: count
|
count: count
|
||||||
@@ -34,7 +51,7 @@ api.get('/api/mails', async (c) => {
|
|||||||
api.get('/api/settings', async (c) => {
|
api.get('/api/settings', async (c) => {
|
||||||
const { address } = c.get("jwtPayload")
|
const { address } = c.get("jwtPayload")
|
||||||
const results = await c.env.DB.prepare(
|
const results = await c.env.DB.prepare(
|
||||||
`SELECT * FROM auto_reply_mails where address = ?`
|
`SELECT * FROM auto_reply_mails where address = ? `
|
||||||
).bind(address).first();
|
).bind(address).first();
|
||||||
if (!results) {
|
if (!results) {
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -68,9 +85,9 @@ api.post('/api/settings', async (c) => {
|
|||||||
const { success } = await c.env.DB.prepare(
|
const { success } = await c.env.DB.prepare(
|
||||||
`INSERT OR REPLACE INTO
|
`INSERT OR REPLACE INTO
|
||||||
auto_reply_mails
|
auto_reply_mails
|
||||||
(name, address, source_prefix, subject, message, enabled)
|
(name, address, source_prefix, subject, message, enabled)
|
||||||
VALUES
|
VALUES
|
||||||
(?, ?, ?, ?, ?, ?)`
|
(?, ?, ?, ?, ?, ?)`
|
||||||
).bind(name || '', address, source_prefix || '', subject || '', message || '', enabled ? 1 : 0).run();
|
).bind(name || '', address, source_prefix || '', subject || '', message || '', enabled ? 1 : 0).run();
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return c.text("Failed to save settings", 500)
|
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(`
|
const { count: mailCount } = await c.env.DB.prepare(`
|
||||||
SELECT count(*) as count FROM mails
|
SELECT count(*) as count FROM mails
|
||||||
where address NOT IN
|
where address NOT IN
|
||||||
(select concat('${c.env.PREFIX}', name) from address)`
|
(select concat('${c.env.PREFIX}', name) from address)`
|
||||||
).first();
|
).first();
|
||||||
count = mailCount;
|
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 }
|
export { api }
|
||||||
|
|||||||
Reference in New Issue
Block a user