优化站内发件无需第三方

This commit is contained in:
eoao
2026-02-07 20:02:01 +08:00
parent 574725538b
commit 96e01c1cef
12 changed files with 238 additions and 193 deletions

View File

@@ -183,7 +183,6 @@ const en = {
optional: 'Optional',
subjectInputDesc: 'Please enter the email subject',
changeUserName: 'Change Username',
sendSeparately: 'Separately',
send: 'Send',
reply: 'Reply',
confirm: 'Confirm',

View File

@@ -183,7 +183,6 @@ const zh = {
optional: '可选',
subjectInputDesc: '请输入邮件主题',
changeUserName: '修改用户名',
sendSeparately: '分别发送',
send: '发送',
reply: '回复',
confirm: '确定',

View File

@@ -15,8 +15,7 @@
</div>
</div>
<div class="container">
<el-input-tag @add-tag="addTagChange" tag-type="primary" @input="inputChange" size="default" v-model="form.receiveEmail"
:placeholder="ruleEmailsInputDesc">
<el-input-tag @add-tag="addTagChange" tag-type="primary" @input="inputChange" size="default" v-model="form.receiveEmail" >
<template #prefix>
<div class="item-title" >{{ $t('recipient') }}</div>
<el-select
@@ -37,18 +36,12 @@
</el-select>
</template>
<template #suffix>
<div style="display: flex;">
<div style="display: flex;margin-right: 3px;">
<Icon icon="fa7-solid:user-plus" width="20" height="20" class="add-contact" @click.stop="openContacts" />
</div>
<span class="distribute" :class="form.manyType ? 'checked' : ''"
@click.stop="checkDistribute">{{ $t('sendSeparately') }}</span>
</template>
</el-input-tag>
<el-input v-model="form.subject" :placeholder="$t('subjectInputDesc')">
<template #prefix>
<div class="item-title">{{ $t('subject') }}</div>
</template>
</el-input>
<el-input v-model="form.subject" :placeholder="t('subject')" />
<tinyEditor :def-value="defValue" ref="editor" @change="change" @focus="focusChange" />
<div class="button-item">
<div class="att-add" @click="chooseFile">
@@ -141,7 +134,6 @@ const contactsTabRef = ref({})
const showContacts = ref(false)
const mySelect = ref()
let selectStatus = false
const ruleEmailsInputDesc = ref(t('ruleEmailsInputDesc'))
const backReply = reactive({
receiveEmail: [],
subject: '',
@@ -152,7 +144,6 @@ const form = reactive({
sendEmail: '',
receiveEmail: [],
accountId: -1,
manyType: null,
name: '',
subject: '',
content: '',
@@ -255,10 +246,6 @@ function addTagChange(val) {
if (selectStatus && has) openSelect()
}
function checkDistribute() {
form.manyType = form.manyType ? null : 'divide'
}
function clearContent() {
ElMessageBox.confirm(t('clearContentConfirm'), {
confirmButtonText: t('confirm'),
@@ -447,7 +434,11 @@ function openReply(email) {
email.subject = email.subject || ''
form.receiveEmail.push(email.sendEmail)
form.subject = (email.subject.startsWith('Re:') || email.subject.startsWith('回复:')) ? email.subject : 'Re: ' + email.subject
form.subject = (
email.subject.startsWith('Re:') ||
email.subject.startsWith('Re') ||
email.subject.startsWith('回复:') ||
email.subject.startsWith('回复:')) ? email.subject : 'Re:' + email.subject
form.sendType = 'reply'
form.emailId = email.emailId
@@ -651,21 +642,6 @@ function close() {
grid-template-rows: auto auto 1fr auto;
gap: 15px;
.distribute {
color: var(--el-color-info);
background: var(--el-color-info-light-9);
border: var(--el-color-info-light-8);
border-radius: 4px;
font-size: 12px;
padding: 0 5px;
}
.distribute.checked {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary) !important;
border-radius: 4px;
}
.item-title {
}

View File

@@ -5,10 +5,9 @@ import settingService from '../service/setting-service';
import attService from '../service/att-service';
import constant from '../const/constant';
import fileUtils from '../utils/file-utils';
import { emailConst, isDel, roleConst, settingConst } from '../const/entity-const';
import { emailConst, isDel, settingConst } from '../const/entity-const';
import emailUtils from '../utils/email-utils';
import roleService from '../service/role-service';
import verifyUtils from '../utils/verify-utils';
import userService from '../service/user-service';
import telegramService from '../service/telegram-service';
@@ -60,45 +59,16 @@ export async function email(message, env, ctx) {
if (account && userRow.email !== env.admin) {
let { banEmail, banEmailType, availDomain } = await roleService.selectByUserId({ env: env }, account.userId);
let { banEmail, availDomain } = await roleService.selectByUserId({ env: env }, account.userId);
if (!roleService.hasAvailDomainPerm(availDomain, message.to)) {
message.setReject('Mailbox disabled');
message.setReject('The recipient is not authorized to use this domain.');
return;
}
banEmail = banEmail.split(',').filter(item => item !== '');
if (banEmail.includes('*')) {
if (!banEmailHandler(banEmailType, message, email)) return;
}
for (const item of banEmail) {
if (verifyUtils.isDomain(item)) {
const banDomain = item.toLowerCase();
const receiveDomain = emailUtils.getDomain(email.from.address.toLowerCase());
if (banDomain === receiveDomain) {
if (!banEmailHandler(banEmailType, message, email)) return;
}
} else {
if (item.toLowerCase() === email.from.address.toLowerCase()) {
if (!banEmailHandler(banEmailType, message, email)) return;
}
}
if(roleService.isBanEmail(banEmail, email.from.address)) {
message.setReject('The recipient is disabled from receiving emails.');
return;
}
}
@@ -199,20 +169,3 @@ export async function email(message, env, ctx) {
throw e
}
}
function banEmailHandler(banEmailType, message, email) {
if (banEmailType === roleConst.banEmailType.ALL) {
message.setReject('Mailbox disabled');
return false;
}
if (banEmailType === roleConst.banEmailType.CONTENT) {
email.html = 'The content has been deleted';
email.text = 'The content has been deleted';
email.attachments = [];
}
return true;
}

View File

@@ -1,7 +1,7 @@
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const att = sqliteTable('attachments', {
export const att = sqliteTable('attachments', {
attId: integer('att_id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull(),
emailId: integer('email_id').notNull(),

View File

@@ -15,7 +15,6 @@ const en = {
noOsDomainSendAtt: 'Cannot send attachments: object storage domain not configured',
noOsSendAtt: 'Cannot send attachments: object storage not configured',
disabledSend: 'Email sending feature is disabled',
noSeparateSend: 'Attachments are not supported in separate sending',
daySendLimit: 'Daily send limit reached',
totalSendLimit: 'Total send limit reached',
daySendLack: 'Not enough remaining sends today',

View File

@@ -15,7 +15,6 @@ const zh = {
noOsDomainSendAtt: '对象存储域名未配置不能发送附件',
noOsSendAtt: '对象存储未配置不能发送附件',
disabledSend: '邮件发送功能已停用',
noSeparateSend: '分别发送暂时不支持附件',
daySendLimit: '发送次数已到达每日限制',
totalSendLimit: '发送次数已到达限制',
daySendLack: '当日剩余发送次数不足',

View File

@@ -171,6 +171,9 @@ const attService = {
attData.emailId = emailId;
attData.accountId = accountId;
attData.type = attConst.type.EMBED;
if (!attData.buff) {
continue;
}
await r2Service.putObj(c, attData.key, attData.buff, {
contentType: attData.mimeType,
cacheControl: `max-age=259200`,

View File

@@ -19,7 +19,8 @@ import kvConst from '../const/kv-const';
import { t } from '../i18n/i18n'
import domainUtils from '../utils/domain-uitls';
import account from "../entity/account";
import {sleep} from "../utils/time-utils";
import { att } from '../entity/att';
import telegramService from './telegram-service';
const emailService = {
@@ -147,25 +148,26 @@ const emailService = {
return orm(c).insert(email).values({ ...params }).returning().get();
},
//邮件发送
async send(c, params, userId) {
let {
accountId,
name,
sendType,
emailId,
receiveEmail,
manyType,
text,
content,
subject,
attachments
accountId, //发送账号id
name, //发件人名字
sendType, //发件类型
emailId, //邮件id如果是回复邮件会带
receiveEmail, //收件人邮箱
text, //邮件纯文本
content, //邮件内容
subject, //邮件标题
attachments //附件
} = params;
const { resendTokens, r2Domain, send } = await settingService.query(c);
const { resendTokens, r2Domain, send, domainList } = await settingService.query(c);
let { imageDataList, html } = await attService.toImageUrlHtml(c, content);
//判断是否关闭发件功能
if (send === settingConst.send.CLOSE) {
throw new BizError(t('disabledSend'), 403);
}
@@ -173,10 +175,12 @@ const emailService = {
const userRow = await userService.selectById(c, userId);
const roleRow = await roleService.selectById(c, userRow.type);
//如果不是管理员,发送被禁用
if (c.env.admin !== userRow.email && roleRow.sendType === 'ban') {
throw new BizError(t('bannedSend'), 403);
}
//如果不是管理员,权限设置了发送次数
if (c.env.admin !== userRow.email && roleRow.sendCount) {
if (userRow.sendCount >= roleRow.sendCount) {
@@ -191,11 +195,6 @@ const emailService = {
}
if (attachments.length > 0 && manyType === 'divide') {
throw new BizError(t('noSeparateSend'));
}
const accountRow = await accountService.selectById(c, accountId);
if (!accountRow) {
@@ -207,21 +206,28 @@ const emailService = {
}
if (c.env.admin !== userRow.email) {
//用户没有这个域名的使用权限
if(!roleService.hasAvailDomainPerm(roleRow.availDomain, accountRow.email)) {
throw new BizError(t('noDomainPermSend'),403)
}
}
//判断接收方是不是全部为站内邮箱
const allInternal = receiveEmail.every(email => {
const domain = '@' + emailUtils.getDomain(email);
return domainList.includes(domain);
});
const domain = emailUtils.getDomain(accountRow.email);
const resendToken = resendTokens[domain];
if (!resendToken) {
//如果接收方存在站外邮箱,又没有resend token
if (!resendToken && !allInternal) {
throw new BizError(t('noResendToken'));
}
//没有发件人名字自动截取
if (!name) {
name = emailUtils.getName(accountRow.email);
}
@@ -230,6 +236,7 @@ const emailService = {
messageId: null
};
//如果是回复邮件
if (sendType === 'reply') {
emailRow = await this.selectById(c, emailId);
@@ -240,37 +247,12 @@ const emailService = {
}
let resendResult = null;
let resendResult = {};
const resend = new Resend(resendToken);
//存在站外时邮箱全部由resend发送
if (!allInternal) {
//如果是分开发送
if (manyType === 'divide') {
let sendFormList = [];
receiveEmail.forEach(email => {
const sendForm = {
from: `${name} <${accountRow.email}>`,
to: [email],
subject: subject,
text: text,
html: html
};
if (sendType === 'reply') {
sendForm.headers = {
'in-reply-to': emailRow.messageId,
'references': emailRow.messageId
};
}
sendFormList.push(sendForm);
});
resendResult = await resend.batch.send(sendFormList);
} else {
const resend = new Resend(resendToken);
const sendForm = {
from: `${name} <${accountRow.email}>`,
@@ -304,6 +286,7 @@ const emailService = {
//把图片标签cid标签切换会通用url
html = this.imgReplace(html, imageDataList, r2Domain);
//封装数据保存到数据库
const emailData = {};
emailData.sendEmail = accountRow.email;
emailData.name = name;
@@ -311,72 +294,54 @@ const emailService = {
emailData.content = html;
emailData.text = text;
emailData.accountId = accountId;
emailData.status = emailConst.status.SENT;
emailData.type = emailConst.type.SEND;
emailData.userId = userId;
emailData.status = emailConst.status.SENT;
emailData.resendEmailId = data?.id;
const emailDataList = [];
const recipient = [];
if (manyType === 'divide') {
receiveEmail.forEach(item => {
recipient.push({ address: item, name: '' });
});
receiveEmail.forEach((item, index) => {
const emailDataItem = { ...emailData };
emailDataItem.resendEmailId = data.data[index].id;
emailDataItem.recipient = JSON.stringify([{ address: item, name: '' }]);
emailDataList.push(emailDataItem);
});
} else {
emailData.resendEmailId = data.id;
const recipient = [];
receiveEmail.forEach(item => {
recipient.push({ address: item, name: '' });
});
emailData.recipient = JSON.stringify(recipient);
emailDataList.push(emailData);
}
emailData.recipient = JSON.stringify(recipient);
if (sendType === 'reply') {
emailDataList.forEach(emailData => {
emailData.inReplyTo = emailRow.messageId;
emailData.relation = emailRow.messageId;
});
emailData.inReplyTo = emailRow.messageId;
emailData.relation = emailRow.messageId;
}
//如果权限有发送次数增加用户发送次数
if (roleRow.sendCount) {
await userService.incrUserSendCount(c, receiveEmail.length, userId);
}
const emailRowList = await Promise.all(
//保存到数据库并返回结果
const emailResult = await orm(c).insert(email).values(emailData).returning().get();
emailDataList.map(async (emailData) => {
const emailRow = await orm(c).insert(email).values(emailData).returning().get();
//保存内嵌附件
if (imageDataList.length > 0) {
await attService.saveArticleAtt(c, imageDataList, userId, accountId, emailResult.emailId);
}
if (imageDataList.length > 0) {
await attService.saveArticleAtt(c, imageDataList, userId, accountId, emailRow.emailId);
}
//保存普通附件
if (attachments?.length > 0) {
await attService.saveSendAtt(c, attachments, userId, accountId, emailResult.emailId);
}
if (attachments?.length > 0) {
await attService.saveSendAtt(c, attachments, userId, accountId, emailRow.emailId);
}
const attList = await attService.selectByEmailIds(c, [emailResult.emailId]);
emailResult.attList = attList;
const attsList = await attService.selectByEmailIds(c, [emailRow.emailId]);
emailRow.attList = attsList;
return emailRow;
})
);
//如果全是站内接收方,直接写入数据库
if (allInternal) {
await this.HandleOnSiteEmail(c, receiveEmail, emailResult, attList);
}
const dateStr = dayjs().format('YYYY-MM-DD');
let daySendTotal = await c.env.kv.get(kvConst.SEND_DAY_COUNT + dateStr);
//记录每天发件次数统计
if (!daySendTotal) {
await c.env.kv.put(kvConst.SEND_DAY_COUNT + dateStr, JSON.stringify(receiveEmail.length), { expirationTtl: 60 * 60 * 24 });
} else {
@@ -384,7 +349,117 @@ const emailService = {
await c.env.kv.put(kvConst.SEND_DAY_COUNT + dateStr, JSON.stringify(daySendTotal), { expirationTtl: 60 * 60 * 24 });
}
return emailRowList;
return [ emailResult ];
},
//处理站内邮件发送
async HandleOnSiteEmail(c, receiveEmail, sendEmailData, attList) {
const { noRecipient } = await settingService.query(c);
//查询所有收件人账号信息
let accountList = await orm(c).select().from(account).where(inArray(account.email, receiveEmail)).all();
//查询所有收件人权限身份
const userIds = accountList.map(accountRow => accountRow.userId);
let roleList = await roleService.selectByUserIds(c, userIds);
//封装数据库准备保存到数据库
const emailDataList = [];
for (const email of receiveEmail) {
//把发件人邮件改成收件
const emailValues = {...sendEmailData}
emailValues.status = emailConst.status.RECEIVE;
emailValues.type = emailConst.type.RECEIVE;
emailValues.toEmail = email;
emailValues.toName = emailUtils.getName(email);
emailValues.emailId = null;
const accountRow = accountList.find(accountRow => accountRow.email === email);
//如果收件人存在就把邮件信息改成收件人的
if (accountRow) {
//设置给收件人保存
emailValues.userId = accountRow.userId;
emailValues.accountId = accountRow.accountId;
emailValues.type = emailConst.type.RECEIVE;
emailValues.status = emailConst.status.RECEIVE;
const roleRow = roleList.find(roleRow => roleRow.userId === accountRow.userId);
let { banEmail, availDomain } = roleRow;
//如果收件人没有这个域名的使用权限和有邮件拦截,就把邮件改为拒收状态
if (email !== c.env.admin) {
if (!roleService.hasAvailDomainPerm(availDomain, email)) {
emailValues.status = emailConst.status.BOUNCED;
emailValues.message = `The recipient <${email}> is not authorized to use this domain.`;
} else if(roleService.isBanEmail(banEmail, sendEmailData.sendEmail)) {
emailValues.status = emailConst.status.BOUNCED;
emailValues.message = `The recipient <${email}> is disabled from receiving emails.`;
}
}
emailDataList.push(emailValues);
} else {
//设置无收件人邮件信息
emailValues.userId = 0;
emailValues.accountId = 0;
emailValues.type = emailConst.type.RECEIVE;
emailValues.status = emailConst.status.NOONE;
//如果无人收件关闭改为拒收
if (noRecipient === settingConst.noRecipient.CLOSE) {
emailValues.status = emailConst.status.BOUNCED;
emailValues.message = `Recipient not found: <${email}>`;
}
emailDataList.push(emailValues);
}
}
//保存邮件
const receiveEmailList = emailDataList.filter(emailRow => emailRow.status === emailConst.status.RECEIVE || emailRow.status === emailConst.status.NOONE);
for (const emailData of receiveEmailList) {
const emailRow = await orm(c).insert(email).values(emailData).returning().get();
//设置附件保存
for (const attRow of attList) {
const attValues = {...attRow};
attValues.emailId = emailRow.emailId;
attValues.accountId = emailRow.accountId;
attValues.userId = emailRow.userId;
attValues.attId = null;
await orm(c).insert(att).values(attValues).run();
}
}
const bouncedEmail = emailDataList.find(emailRow => emailRow.status === emailConst.status.BOUNCED);
let status = emailConst.status.DELIVERED;
let message = ''
//如果有拒收邮件,就把发件人的邮件改成拒收
if (bouncedEmail) {
const messageJson = { message: bouncedEmail.message };
message = JSON.stringify(messageJson);
status = emailConst.status.BOUNCED;
}
await orm(c).update(email).set({ status: emailConst.status.DELIVERED, message: message }).where(eq(email.emailId, sendEmailData.emailId)).run();
},
imgReplace(content, cidAttList, r2domain) {

View File

@@ -6,17 +6,18 @@ const resendService = {
async webhooks(c, body) {
const params = {}
console.error(body)
const params = {
resendEmailId: body.data.email_id,
status: emailConst.status.SENT
}
if (body.type === 'email.delivered') {
params.status = emailConst.status.DELIVERED
params.resendEmailId = body.data.email_id
params.message = null
}
if (body.type === 'email.complained') {
params.status = emailConst.status.COMPLAINED
params.resendEmailId = body.data.email_id
params.message = null
}
@@ -24,19 +25,16 @@ const resendService = {
let bounce = body.data.bounce
bounce = JSON.stringify(bounce);
params.status = emailConst.status.BOUNCED
params.resendEmailId = body.data.email_id
params.message = bounce
}
if (body.type === 'email.delivery_delayed') {
params.status = emailConst.status.DELAYED
params.resendEmailId = body.data.email_id
params.message = null
}
if (body.type === 'email.failed') {
params.status = emailConst.status.FAILED
params.resendEmailId = body.data.email_id
params.message = body.data.failed.reason
}

View File

@@ -174,6 +174,50 @@ const roleService = {
selectByName(c, roleName) {
return orm(c).select().from(role).where(eq(role.name, roleName)).get();
},
selectByUserIds(c, userIds) {
if (!userIds && userIds.length === 0) {
return [];
}
return orm(c).select({ ...role, userId: user.userId }).from(user).leftJoin(role, eq(role.roleId, user.type)).where(inArray(user.userId, userIds)).all();
},
isBanEmail(banEmail, fromEmail) {
banEmail = banEmail.split(',').filter(item => item !== '');
if (banEmail.includes('*')) {
return true;
}
for (const item of banEmail) {
if (verifyUtils.isDomain(item)) {
const banDomain = item.toLowerCase();
const receiveDomain = emailUtils.getDomain(fromEmail.toLowerCase());
if (banDomain === receiveDomain) {
return true;
}
} else {
if (item.toLowerCase() === fromEmail.toLowerCase()) {
return true;
}
}
}
return false;
}
};

View File

@@ -11,7 +11,7 @@ const fileUtils = {
async getBuffHash(buff) {
const hashBuffer = await crypto.subtle.digest('SHA-256', buff);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashArray.slice(0, 16).map(b => b.toString(16).padStart(2, '0')).join('');
},
base64ToDataStr(base64) {