diff --git a/mail-vue/src/i18n/en.js b/mail-vue/src/i18n/en.js index 767f8b2..bf0339e 100644 --- a/mail-vue/src/i18n/en.js +++ b/mail-vue/src/i18n/en.js @@ -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', diff --git a/mail-vue/src/i18n/zh.js b/mail-vue/src/i18n/zh.js index 66be1a2..abf13eb 100644 --- a/mail-vue/src/i18n/zh.js +++ b/mail-vue/src/i18n/zh.js @@ -183,7 +183,6 @@ const zh = { optional: '可选', subjectInputDesc: '请输入邮件主题', changeUserName: '修改用户名', - sendSeparately: '分别发送', send: '发送', reply: '回复', confirm: '确定', diff --git a/mail-vue/src/layout/write/index.vue b/mail-vue/src/layout/write/index.vue index 707fc83..6543246 100644 --- a/mail-vue/src/layout/write/index.vue +++ b/mail-vue/src/layout/write/index.vue @@ -15,8 +15,7 @@
- + - - - +
@@ -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 { } diff --git a/mail-worker/src/email/email.js b/mail-worker/src/email/email.js index 05608e1..77a2cd2 100644 --- a/mail-worker/src/email/email.js +++ b/mail-worker/src/email/email.js @@ -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; - -} diff --git a/mail-worker/src/entity/att.js b/mail-worker/src/entity/att.js index 87fb9a0..cb0962d 100644 --- a/mail-worker/src/entity/att.js +++ b/mail-worker/src/entity/att.js @@ -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(), diff --git a/mail-worker/src/i18n/en.js b/mail-worker/src/i18n/en.js index 633ccd8..0b03aa8 100644 --- a/mail-worker/src/i18n/en.js +++ b/mail-worker/src/i18n/en.js @@ -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', diff --git a/mail-worker/src/i18n/zh.js b/mail-worker/src/i18n/zh.js index 351415f..b98f6a0 100644 --- a/mail-worker/src/i18n/zh.js +++ b/mail-worker/src/i18n/zh.js @@ -15,7 +15,6 @@ const zh = { noOsDomainSendAtt: '对象存储域名未配置不能发送附件', noOsSendAtt: '对象存储未配置不能发送附件', disabledSend: '邮件发送功能已停用', - noSeparateSend: '分别发送暂时不支持附件', daySendLimit: '发送次数已到达每日限制', totalSendLimit: '发送次数已到达限制', daySendLack: '当日剩余发送次数不足', diff --git a/mail-worker/src/service/att-service.js b/mail-worker/src/service/att-service.js index ed69ad2..abe0255 100644 --- a/mail-worker/src/service/att-service.js +++ b/mail-worker/src/service/att-service.js @@ -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`, diff --git a/mail-worker/src/service/email-service.js b/mail-worker/src/service/email-service.js index f1fb1df..3669c37 100644 --- a/mail-worker/src/service/email-service.js +++ b/mail-worker/src/service/email-service.js @@ -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) { diff --git a/mail-worker/src/service/resend-service.js b/mail-worker/src/service/resend-service.js index f29f28e..d1ebc3e 100644 --- a/mail-worker/src/service/resend-service.js +++ b/mail-worker/src/service/resend-service.js @@ -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 } diff --git a/mail-worker/src/service/role-service.js b/mail-worker/src/service/role-service.js index d38dd74..f6f027e 100644 --- a/mail-worker/src/service/role-service.js +++ b/mail-worker/src/service/role-service.js @@ -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; } }; diff --git a/mail-worker/src/utils/file-utils.js b/mail-worker/src/utils/file-utils.js index 9b70bec..af37c4e 100644 --- a/mail-worker/src/utils/file-utils.js +++ b/mail-worker/src/utils/file-utils.js @@ -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) {