新增TG邮件查看HTML

This commit is contained in:
eoao
2025-10-22 21:33:45 +08:00
parent 140d451472
commit b8b1bee015
15 changed files with 332 additions and 62 deletions
@@ -36,7 +36,7 @@ function updateContent() {
all: initial;
width: 100%;
height: 100%;
font-family: Inter, -apple-system, BlinkMacSystemFont,
font-family: -apple-system, Inter, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
@@ -119,7 +119,7 @@ watch(() => props.html, () => {
width: 100%;
height: 100%;
overflow: hidden;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-family: -apple-system, Inter, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
}
.content-html {
+9 -9
View File
@@ -137,7 +137,7 @@ const en = {
websiteReg: 'Sign Up',
loginDomain: 'Sign-In Box Domain',
multipleEmail: 'Multiple Accounts',
multipleEmailDesc: 'Enable this feature to allow users to add multiple accounts',
multipleEmailDesc: 'Enable this feature to allow users to add multiple accounts.',
customization: 'Customization',
websiteTitle: 'Title',
loginBoxOpacity: 'Login Box Opacity',
@@ -145,7 +145,7 @@ const en = {
emailSetting: 'Email',
receiveEmail: 'Receive Email',
autoRefresh: 'Auto Refresh',
autoRefreshDesc: 'Automatically fetch the latest emails from the server',
autoRefreshDesc: 'Automatically fetch the latest emails from the server.',
sendEmail: 'Send Email',
resendToken: 'Resend Token',
oss: 'Object Storage',
@@ -165,7 +165,7 @@ const en = {
version: 'Version',
community: 'Community',
changeTitle: 'Change Title',
addResendTokenDesc: 'Input to add; leave empty to delete',
addResendTokenDesc: 'Input to add; leave empty to delete.',
addOsDomain: 'Add Domain',
domainDesc: 'Domain',
addTurnstileSecret: 'Add turnstile secret',
@@ -173,14 +173,14 @@ const en = {
tgBotDesc: 'Forward received emails to a Telegram bot',
tgBotToken: 'Bot token',
toBotTokenDesc: 'Multiple user chat_ids, separated by commas',
otherEmailDesc: 'emails can be forwarded to external email, but must be verified via cloudflare',
otherEmailDesc: 'emails can be forwarded to external email, but must be verified via cloudflare.',
otherEmailInputDesc: 'Separate multiple email addresses with commas.',
forwardingRulesDesc: 'Rule-based forwarding only forwards emails received by the specified address',
ruleEmailsInputDesc: 'Separate multiple email addresses with commas',
forwardingRulesDesc: 'Rule-based forwarding only forwards emails received by the specified address.',
ruleEmailsInputDesc: 'Separate multiple email addresses with commas.',
resendTokenList: 'Token List',
domain: 'Domain',
optional: 'Optional',
subjectInputDesc: 'Please enter the email subject',
subjectInputDesc: 'Please enter the email subject.',
changeUserName: 'Change Username',
sendSeparately: 'Separately',
send: 'Send',
@@ -295,8 +295,8 @@ const en = {
confirmDeletionOfContacts: 'Confirm clearing contacts?',
recentContacts: 'Recent contacts',
selectContacts: 'Select',
forcePathStyleDesc: 'Some self-hosted object storages require path-style access to be enabled',
kvStorageDesc: 'Replace object storage with KV, and update the access domain to a Worker custom domain',
forcePathStyleDesc: 'Some self-hosted object storages require path-style access to be enabled.',
kvStorageDesc: 'Replace object storage with KV, and update the access domain to a Worker custom domain.',
kvStorage: 'KV Storage'
}
+2 -1
View File
@@ -298,6 +298,7 @@ const zh = {
selectContacts: '选中',
forcePathStyleDesc: '路径样式访问,一些自建的对象存储需要打开',
kvStorageDesc: '使用KV替代对象存储,访问域名改成worker自定义域',
kvStorage: 'KV存储'
kvStorage: 'KV存储',
customDomainDesc: 'Worker 自定义域'
}
export default zh
+6 -1
View File
@@ -485,6 +485,7 @@
<el-input :placeholder="$t('tgBotToken')" v-model="tgBotToken"></el-input>
<el-input-tag tag-type="warning" :placeholder="$t('toBotTokenDesc')" v-model="tgChatId"
@add-tag="addChatTag"></el-input-tag>
<el-input tag-type="warning" :placeholder="$t('customDomainDesc')" v-model="customDomain" ></el-input>
</div>
<template #footer>
<div class="dialog-footer">
@@ -777,6 +778,7 @@ const options = computed(() => [
])
const tgChatId = ref([])
const customDomain = ref('')
const tgBotStatus = ref(0)
const tgBotToken = ref('')
const forwardEmail = ref([])
@@ -898,6 +900,7 @@ function closedSetBackground() {
function openTgSetting() {
tgBotStatus.value = setting.value.tgBotStatus
tgBotToken.value = setting.value.tgBotToken
customDomain.value = setting.value.customDomain
tgChatId.value = []
if (setting.value.tgChatId) {
const list = setting.value.tgChatId.split(',')
@@ -1031,6 +1034,7 @@ function saveS3() {
function tgBotSave() {
const form = {
tgBotToken: tgBotToken.value,
customDomain: customDomain.value,
tgBotStatus: tgBotStatus.value,
tgChatId: tgChatId.value + ''
}
@@ -1618,10 +1622,11 @@ function editSetting(settingForm, refreshStatus = true) {
justify-content: space-between;
gap: 10px;
.force-path-style-left {
padding-left: 2px;
display: flex;
justify-content: center;
align-items: center;
gap: 3px;
gap: 5px;
}
}
-1
View File
@@ -14,7 +14,6 @@ app.put('/role/setDefault', async (c) => {
return c.json(result.ok());
});
app.put('/role/set', async (c) => {
await roleService.setRole(c, await c.req.json());
return c.json(result.ok());
+8
View File
@@ -0,0 +1,8 @@
import app from '../hono/hono';
import telegramService from '../service/telegram-service';
app.get('/telegram/getEmail/:token', async (c) => {
const content = await telegramService.getEmailContent(c, c.req.param());
c.header('Cache-Control', 'public, max-age=604800, immutable');
return c.html(content)
});
+4 -40
View File
@@ -7,16 +7,11 @@ import constant from '../const/constant';
import fileUtils from '../utils/file-utils';
import { emailConst, isDel, roleConst, settingConst } from '../const/entity-const';
import emailUtils from '../utils/email-utils';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import roleService from '../service/role-service';
import verifyUtils from '../utils/verify-utils';
import r2Service from '../service/r2-service';
import userService from '../service/user-service';
dayjs.extend(utc);
dayjs.extend(timezone);
import telegramService from '../service/telegram-service';
export async function email(message, env, ctx) {
@@ -24,7 +19,6 @@ export async function email(message, env, ctx) {
const {
receive,
tgBotToken,
tgChatId,
tgBotStatus,
forwardStatus,
@@ -179,42 +173,12 @@ export async function email(message, env, ctx) {
}
//转发到TG
if (tgBotStatus === settingConst.tgBotStatus.OPEN && tgChatId) {
const tgMessage = `<b>${params.subject}</b>
<b>发件人</b>${params.name} &lt;${params.sendEmail}&gt;
<b>收件人\u200B</b>${message.to}
<b>时间</b>${dayjs.utc(emailRow.createTime).tz('Asia/Shanghai').format('YYYY-MM-DD HH:mm')}
${params.text || emailUtils.htmlToText(params.content) || ''}
`;
const tgChatIds = tgChatId.split(',');
await Promise.all(tgChatIds.map(async chatId => {
try {
const res = await fetch(`https://api.telegram.org/bot${tgBotToken}/sendMessage`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chat_id: chatId,
parse_mode: 'HTML',
text: tgMessage
})
});
if (!res.ok) {
console.error(`转发 Telegram 失败: chatId=${chatId}, 状态码=${res.status}`);
}
} catch (e) {
console.error(`转发 Telegram 失败: chatId=${chatId}`, e);
}
}));
await telegramService.sendEmailToBot({ env }, emailRow)
}
//转发到其他邮箱
if (forwardStatus === settingConst.forwardStatus.OPEN && forwardEmail) {
const emails = forwardEmail.split(',');
+2 -1
View File
@@ -41,6 +41,7 @@ export const setting = sqliteTable('setting', {
s3AccessKey: text('s3_access_key').default('').notNull(),
s3SecretKey: text('s3_secret_key').default('').notNull(),
kvStorage: integer('kv_storage').default(1).notNull(),
forcePathStyle: integer('force_path_style').default(1).notNull()
forcePathStyle: integer('force_path_style').default(1).notNull(),
customDomain: text('custom_domain').default('').notNull()
});
export default setting
+1
View File
@@ -18,4 +18,5 @@ import '../api/init-api'
import '../api/analysis-api'
import '../api/reg-key-api'
import '../api/public-api'
import '../api/telegram-api'
export default app;
+3 -2
View File
@@ -31,10 +31,11 @@ const init = {
try {
await c.env.db.batch([
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN force_path_style INTEGER NOT NULL DEFAULT 1;`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN kv_storage INTEGER NOT NULL DEFAULT 1;`)
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN kv_storage INTEGER NOT NULL DEFAULT 1;`),
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN custom_domain TEXT NOT NULL DEFAULT '';`)
]);
} catch (e) {
console.error(e.message)
console.error(e)
}
},
+5 -5
View File
@@ -15,7 +15,9 @@ const exclude = [
'/setting/websiteConfig',
'/webhooks',
'/init',
'/public/genToken'
'/public/genToken',
'/telegram',
'/test'
];
const requirePerms = [
@@ -85,10 +87,6 @@ app.use('*', async (c, next) => {
const path = c.req.path;
if (path.startsWith('/test')) {
return await next();
}
const index = exclude.findIndex(item => {
return path.startsWith(item);
});
@@ -162,7 +160,9 @@ app.use('*', async (c, next) => {
});
function permKeyToPaths(permKeys) {
const paths = [];
for (const key of permKeys) {
const routeList = premKey[key];
if (routeList && Array.isArray(routeList)) {
+109
View File
@@ -0,0 +1,109 @@
import orm from '../entity/orm';
import email from '../entity/email';
import settingService from './setting-service';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
import { eq } from 'drizzle-orm';
import jwtUtils from '../utils/jwt-utils';
import emailMsgTemplate from '../template/email-msg';
import emailTextTemplate from '../template/email-text';
import emailHtmlTemplate from '../template/email-html';
import verifyUtils from '../utils/verify-utils';
const telegramService = {
async getEmailById(c, params) {
const { emailId } = params
const emailRow = await orm(c).select().from(email).where(eq(email.emailId, emailId)).get();
if (emailRow) {
if (emailRow.content) {
return emailHtmlTemplate(emailRow.content || '')
} else {
return emailTextTemplate(emailRow.text || '')
}
} else {
return emailTextTemplate('The email does not exist')
}
},
async getEmailContent(c, params) {
const { token } = params
const result = await jwtUtils.verifyToken(c, token);
if (!result) {
return emailTextTemplate('Access denied')
}
const emailRow = await orm(c).select().from(email).where(eq(email.emailId, result.emailId)).get();
if (emailRow) {
if (emailRow.content) {
return emailHtmlTemplate(emailRow.content || '')
} else {
return emailTextTemplate(emailRow.text || '')
}
} else {
return emailTextTemplate('The email does not exist')
}
},
async sendEmailToBot(c, email) {
const { tgBotToken, tgChatId, customDomain, tgMsgTo, tgMsgFrom } = await settingService.query(c);
const tgChatIds = tgChatId.split(',');
const jwtToken = await jwtUtils.generateToken(c, { emailId: email.emailId })
const webAppUrl = verifyUtils.isDomain(customDomain) ? `https://${customDomain}/api/telegram/getEmail/${jwtToken}` : 'https://www.cloudflare.com/404'
await Promise.all(tgChatIds.map(async chatId => {
try {
const res = await fetch(`https://api.telegram.org/bot${tgBotToken}/sendMessage`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chat_id: chatId,
parse_mode: 'HTML',
text: emailMsgTemplate(email, tgMsgTo, tgMsgFrom),
reply_markup: {
inline_keyboard: [
[
{
text: '查看',
web_app: { url: webAppUrl }
}
]
]
}
})
});
if (!res.ok) {
console.error(`转发 Telegram 失败: chatId=${chatId}, 状态码=${res.status}`);
}
} catch (e) {
console.error(`转发 Telegram 失败: chatId=${chatId}`, e.message);
}
}));
}
}
export default telegramService
+139
View File
@@ -0,0 +1,139 @@
import { parseHTML } from 'linkedom';
export default function emailHtmlTemplate(html) {
const { document } = parseHTML(html);
document.querySelectorAll('script').forEach(script => script.remove());
html = document.documentElement.outerHTML;
return `<!DOCTYPE html>
<html lang='en' >
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
background: #FFF;
}
.content-box {
padding: 15px 10px;
width: 100%;
height: 100%;
overflow: auto; /* 改为 auto 允许滚动 */
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.content-html {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div class='content-box'>
<div id='container' class='content-html'></div>
</div>
<script>
function renderHTML(html) {
const container = document.getElementById('container');
const shadowRoot = container.attachShadow({ mode: 'open' });
// 提取 <body> 的 style 属性
const bodyStyleRegex = /<body[^>]*style="([^"]*)"[^>]*>/i;
const bodyStyleMatch = html.match(bodyStyleRegex);
const bodyStyle = bodyStyleMatch ? bodyStyleMatch[1] : '';
// 移除 <body> 标签
const cleanedHtml = html.replace(/<\\/?body[^>]*>/gi, '');
// 渲染内容
shadowRoot.innerHTML = \`
<style>
:host {
all: initial;
width: 100%;
height: 100%;
font-family: Inter, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #13181D;
word-break: break-word;
overflow: auto; /* 添加滚动 */
}
h1, h2, h3, h4 {
font-size: 18px;
font-weight: 700;
}
p {
margin: 0;
}
a {
text-decoration: none;
color: #0E70DF;
}
.shadow-content {
background: #FFFFFF;
width: fit-content;
height: fit-content;
min-width: 100%;
\${bodyStyle ? bodyStyle : ''} /* 注入 body 的 style */
}
img:not(table img) {
max-width: 100% !important;
height: auto !important;
}
</style>
<div class="shadow-content">
\${cleanedHtml}
</div>
\`;
// 自动缩放
autoScale(shadowRoot, container);
}
function autoScale(shadowRoot, container) {
if (!shadowRoot || !container) return;
const parent = container;
const shadowContent = shadowRoot.querySelector('.shadow-content');
if (!shadowContent) return;
const parentWidth = parent.offsetWidth;
const parentHeight = parent.offsetHeight;
const childWidth = shadowContent.scrollWidth;
const childHeight = shadowContent.scrollHeight;
if (childWidth === 0 || childHeight === 0) return;
const scaleX = parentWidth / childWidth;
const scaleY = parentHeight / childHeight;
const scale = Math.min(scaleX, scaleY);
const hostElement = shadowRoot.host;
hostElement.style.zoom = scale;
}
// 使用示例
const exampleHtml = \`${html}\`;
// 渲染HTML
renderHTML(exampleHtml);
</script>
</body>
</html>`
}
+7
View File
@@ -0,0 +1,7 @@
export default function emailMsgTemplate(email) {
return `<b>${email.subject}</b>
发件人${email.name} &lt;${email.sendEmail}&gt;
收件人\u200B${email.toEmail}`
}
+35
View File
@@ -0,0 +1,35 @@
export default function emailTextTemplate(text) {
return `<!DOCTYPE html>
<html lang='en' >
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<style>
html {
margin: 0;
padding: 0;
background: #FFF;
}
body {
box-sizing: border-box;
margin: 0;
padding: 10px 10px;
width: 100%;
height: 100%;
overflow: auto; /* 改为 auto 允许滚动 */
}
span {
font-family: inherit;
white-space: pre-wrap;
word-break: break-word;
}
</style>
</head>
<body>
<span>${text}</span>
</body>
</html>`
}