mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-06 20:32:55 +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] 增加查看附件功能
|
||||
- [ ] 免费版附件过大会造成 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] 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
|
||||
|
||||

|
||||
|
||||
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 (
|
||||
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
|
||||
);
|
||||
|
||||
@@ -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
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) {
|
||||
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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user