新增邮件群发和数据可视化

This commit is contained in:
eoao
2025-06-17 09:20:00 +08:00
parent ea309f9171
commit 605829f180
65 changed files with 2352 additions and 900 deletions

View File

@@ -5,7 +5,7 @@ import userService from './user-service';
import emailService from './email-service';
import orm from '../entity/orm';
import account from '../entity/account';
import { and, asc, eq, gt, inArray, count, like } from 'drizzle-orm';
import { and, asc, eq, gt, inArray, count, sql } from 'drizzle-orm';
import { isDel } from '../const/entity-const';
import settingService from './setting-service';
import turnstileService from './turnstile-service';
@@ -33,7 +33,7 @@ const accountService = {
throw new BizError('不存在的邮箱域名');
}
const accountRow = await this.selectByEmailIncludeDel(c, email);
const accountRow = await this.selectByEmailIncludeDelNoCase(c, email);
if (accountRow && accountRow.isDel === isDel.DELETE) {
throw new BizError('该邮箱已被注销');
@@ -55,9 +55,16 @@ const accountService = {
await turnstileService.verify(c, token);
}
return orm(c).insert(account).values({ email: email, userId: userId }).returning().get();
return orm(c).insert(account).values({ email: email, userId: userId, name: emailUtils.getName(email) }).returning().get();
},
selectByEmailIncludeDelNoCase(c, email) {
return orm(c)
.select()
.from(account)
.where(sql`${account.email} COLLATE NOCASE = ${email}`)
.get();
},
selectByEmailIncludeDel(c, email) {
return orm(c).select().from(account).where(eq(account.email, email)).get();
},
@@ -170,6 +177,11 @@ const accountService = {
async restoreByUserId(c, userId) {
await orm(c).update(account).set({isDel: isDel.NORMAL}).where(eq(account.userId, userId)).run();
},
async setName(c, params, userId) {
const { name, accountId } = params
await orm(c).update(account).set({name}).where(and(eq(account.userId, userId),eq(account.accountId, accountId))).run();
}
};

View File

@@ -0,0 +1,88 @@
import analysisDao from '../dao/analysis-dao';
import orm from '../entity/orm';
import email from '../entity/email';
import { desc, count, eq } from 'drizzle-orm';
import { emailConst } from '../const/entity-const';
import kvConst from '../const/kv-const';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
dayjs.extend(timezone)
const analysisService = {
async echarts(c) {
const [
numberCount,
nameRatio,
sendEmailRatio,
userDayCountRaw,
receiveDayCountRaw,
sendDayCountRaw,
daySendTotalRaw
] = await Promise.all([
analysisDao.numberCount(c),
orm(c)
.select({ name: email.name, total: count() })
.from(email)
.where(eq(email.type, emailConst.type.RECEIVE))
.groupBy(email.name)
.orderBy(desc(count()))
.limit(6),
orm(c)
.select({ email: email.sendEmail, total: count() })
.from(email)
.where(eq(email.type, emailConst.type.RECEIVE))
.groupBy(email.sendEmail)
.orderBy(desc(count()))
.limit(6),
analysisDao.userDayCount(c),
analysisDao.receiveDayCount(c),
analysisDao.sendDayCount(c),
c.env.kv.get(kvConst.SEND_DAY_COUNT + dayjs().format('YYYY-MM-DD')),
]);
const userDayCount = this.filterEmptyDay(userDayCountRaw);
const receiveDayCount = this.filterEmptyDay(receiveDayCountRaw);
const sendDayCount = this.filterEmptyDay(sendDayCountRaw);
const daySendTotal = daySendTotalRaw || 0;
return {
numberCount,
userDayCount,
receiveRatio: {
nameRatio,
sendEmailRatio
},
emailDayCount: {
receiveDayCount,
sendDayCount
},
daySendTotal: Number(daySendTotal)
};
},
filterEmptyDay(data) {
const today = dayjs().tz('Asia/Shanghai').subtract(1, 'day');
const previousDays = Array.from({ length: 15 }, (_, i) => {
return today.subtract(i, 'day').format('YYYY-MM-DD');
}).reverse();
return previousDays.map(day => {
const index = data.findIndex(item => item.date === day)
const total = index > - 1 ? data[index].total : 0
return {date: day,total}
})
}
}
export default analysisService

View File

@@ -15,6 +15,9 @@ import roleService from './role-service';
import user from '../entity/user';
import account from '../entity/account';
import starService from './star-service';
import dayjs from 'dayjs';
import kvConst from '../const/kv-const';
import constant from '../const/constant';
const emailService = {
@@ -146,11 +149,10 @@ const emailService = {
async send(c, params, userId) {
let { accountId, name, receiveEmail, text, content, subject, attachments } = params;
let { accountId, name, sendType, emailId, receiveEmail, manyType, text, content, subject, attachments } = params;
const { resendTokens, r2Domain, send } = await settingService.query(c);
const { attDataList, html } = await attService.toImageUrlHtml(c, content, r2Domain);
if (attDataList.length > 0 && !r2Domain) {
@@ -173,13 +175,26 @@ const emailService = {
throw new BizError('邮箱发送功能已停用', 403);
}
if (attachments.length > 0 && manyType === 'divide') {
throw new BizError('分别发送暂时不支持附件');
}
const userRow = await userService.selectById(c, userId);
const roleRow = await roleService.selectById(c, userRow.type);
if (userRow.sendCount >= roleRow.sendCount && c.env.admin !== userRow.email) {
if (roleRow.sendType === 'day') throw new BizError('已到达每日发送数量限制', 403);
if (roleRow.sendType === 'count') throw new BizError('已到达发送数量限制', 403);
if (c.env.admin !== userRow.email && roleRow.sendCount) {
if (userRow.sendCount >= roleRow.sendCount) {
if (roleRow.sendType === 'day') throw new BizError('已到达每日发送次数限制', 403);
if (roleRow.sendType === 'count') throw new BizError('已到达发送次数限制', 403);
}
if (userRow.sendCount + receiveEmail.length > roleRow.sendCount) {
if (roleRow.sendType === 'day') throw new BizError('剩余每日发送次数不足', 403);
if (roleRow.sendType === 'count') throw new BizError('剩余发送次数不足', 403);
}
}
@@ -203,53 +218,161 @@ const emailService = {
name = emailUtils.getName(accountRow.email);
}
let emailRow = {
messageId: null
};
if (sendType === 'reply') {
emailRow = await this.selectById(c, emailId);
if (!emailRow) {
throw new BizError('邮件不存在无法回复');
}
}
let resendResult = null;
const resend = new Resend(resendToken);
const { data, error } = await resend.emails.send({
from: `${name} <${accountRow.email}>`,
to: [receiveEmail],
subject: subject,
text: text,
html: html,
attachments: attachments
});
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 sendForm = {
from: `${name} <${accountRow.email}>`,
to: [...receiveEmail],
subject: subject,
text: text,
html: html,
attachments: attachments
};
if (sendType === 'reply') {
sendForm.headers = {
'in-reply-to': emailRow.messageId,
'references': emailRow.messageId
};
}
resendResult = await resend.emails.send(sendForm);
}
const { data, error } = resendResult;
if (error) {
console.error(error);
throw new BizError(error.message);
}
const emailData = {};
emailData.sendEmail = accountRow.email;
emailData.name = name;
emailData.subject = subject;
emailData.receiveEmail = receiveEmail;
emailData.content = html;
emailData.text = text;
emailData.accountId = accountId;
emailData.type = emailConst.type.SEND;
emailData.userId = userId;
emailData.status = emailConst.status.SENT;
emailData.resendEmailId = data.id;
const emailRow = await orm(c).insert(email).values(emailData).returning().get();
const emailDataList = [];
if (attDataList.length > 0) {
await attService.saveArticleAtt(c, attDataList, userId, accountId, emailRow.emailId);
if (manyType === 'divide') {
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);
}
if (sendType === 'reply') {
emailDataList.forEach(emailData => {
emailData.inReplyTo = emailRow.messageId;
emailData.relation = emailRow.messageId;
});
}
if (roleRow.sendCount) {
await userService.incrUserService(c, 1, userId);
await userService.incrUserSendCount(c, receiveEmail.length, userId);
}
if (attachments?.length > 0 && c.env.r2) {
await attService.saveSendAtt(c, attachments, userId, accountId, emailRow.emailId);
const emailRowList = await Promise.all(
emailDataList.map(async (emailData) => {
const emailRow = await orm(c).insert(email).values(emailData).returning().get();
if (attDataList.length > 0) {
await attService.saveArticleAtt(c, attDataList, userId, accountId, emailRow.emailId);
}
if (attachments?.length > 0 && c.env.r2) {
await attService.saveSendAtt(c, attachments, userId, accountId, emailRow.emailId);
}
const attsList = await attService.selectByEmailIds(c, [emailRow.emailId]);
emailRow.attList = attsList;
return emailRow;
})
);
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 {
daySendTotal = Number(daySendTotal) + receiveEmail.length
await c.env.kv.put(kvConst.SEND_DAY_COUNT + dateStr, JSON.stringify(daySendTotal), { expirationTtl: 60 * 60 * 24 });
}
const attsList = await attService.selectByEmailIds(c, [emailRow.emailId]);
emailRow.attList = attsList;
return emailRow;
return emailRowList;
},
selectById(c, emailId) {
@@ -398,7 +521,7 @@ const emailService = {
conditions.push(like(email.subject, `${subject}%`));
}
conditions.push(ne(email.status, emailConst.status.SAVING))
conditions.push(ne(email.status, emailConst.status.SAVING));
const countConditions = [...conditions];
@@ -447,7 +570,10 @@ const emailService = {
},
async completeReceive(c, emailId) {
await orm(c).update(email).set({ isDel: isDel.NORMAL, status: emailConst.status.RECEIVE }).where(eq(email.emailId, emailId)).run();
await orm(c).update(email).set({
isDel: isDel.NORMAL,
status: emailConst.status.RECEIVE
}).where(eq(email.emailId, emailId)).run();
}
};

View File

@@ -1,253 +0,0 @@
import settingService from './setting-service';
const initService = {
async init(c) {
const secret = c.req.param('secret');
if (secret !== c.env.jwt_secret) {
return c.text('secret不匹配');
}
await this.intDB(c);
await this.v1DB(c);
await settingService.refresh(c);
return c.text('初始化成功');
},
async v1DB(c) {
// 添加字段
const ADD_COLUMN_SQL_LIST = [
`ALTER TABLE email ADD COLUMN type INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE email ADD COLUMN status INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE email ADD COLUMN resend_email_id TEXT;`,
`ALTER TABLE email ADD COLUMN message TEXT;`,
`ALTER TABLE setting ADD COLUMN resend_tokens TEXT NOT NULL DEFAULT '{}';`,
`ALTER TABLE setting ADD COLUMN send INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE setting ADD COLUMN r2_domain TEXT;`,
`ALTER TABLE setting ADD COLUMN site_key TEXT;`,
`ALTER TABLE setting ADD COLUMN secret_key TEXT;`,
`ALTER TABLE setting ADD COLUMN background TEXT;`,
`ALTER TABLE setting ADD COLUMN login_opacity INTEGER NOT NULL DEFAULT 0.88;`,
`ALTER TABLE user ADD COLUMN create_ip TEXT;`,
`ALTER TABLE user ADD COLUMN active_ip TEXT;`,
`ALTER TABLE user ADD COLUMN os TEXT;`,
`ALTER TABLE user ADD COLUMN browser TEXT;`,
`ALTER TABLE user ADD COLUMN device TEXT;`,
`ALTER TABLE user ADD COLUMN sort INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE user ADD COLUMN send_count INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE attachments ADD COLUMN status INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE attachments ADD COLUMN type INTEGER NOT NULL DEFAULT 0;`
];
for (let sql of ADD_COLUMN_SQL_LIST) {
try {
await c.env.db.prepare(sql).run();
} catch (e) {
console.warn(`跳过字段添加,原因:${e.message}`);
}
}
// 创建 perm 表并初始化
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS perm (
perm_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
perm_key TEXT,
pid INTEGER NOT NULL DEFAULT 0,
type INTEGER NOT NULL DEFAULT 2,
sort INTEGER
)
`).run();
const {permTotal} = await c.env.db.prepare(`SELECT COUNT(*) as permTotal FROM perm`).first();
if (permTotal === 0) {
await c.env.db.prepare(`
INSERT INTO perm (perm_id, name, perm_key, pid, type, sort) VALUES
(1, '邮件', NULL, 0, 0, 0),
(2, '邮件删除', 'email:delete', 1, 2, 1),
(3, '邮件发送', 'email:send', 1, 2, 0),
(4, '个人设置', '', 0, 1, 2),
(5, '用户注销', 'my:delete', 4, 2, 0),
(6, '用户信息', NULL, 0, 1, 3),
(7, '用户查看', 'user:query', 6, 2, 0),
(8, '密码修改', 'user:set-pwd', 6, 2, 2),
(9, '状态修改', 'user:set-status', 6, 2, 3),
(10, '权限修改', 'user:set-type', 6, 2, 4),
(11, '用户删除', 'user:delete', 6, 2, 7),
(12, '用户收藏', 'user:star', 6, 2, 5),
(13, '权限控制', '', 0, 1, 5),
(30, '身份添加', 'role:add', 13, 2, -1),
(14, '身份查看', 'role:query', 13, 2, 0),
(15, '身份修改', 'role:set', 13, 2, 1),
(16, '身份删除', 'role:delete', 13, 2, 2),
(17, '系统设置', '', 0, 1, 6),
(18, '设置查看', 'setting:query', 17, 2, 0),
(19, '设置修改', 'setting:set', 17, 2, 1),
(20, '物理清空', 'setting:clean', 17, 2, 2),
(21, '邮箱侧栏', '', 0, 0, 1),
(22, '邮箱查看', 'account:query', 21, 2, 0),
(23, '邮箱添加', 'account:add', 21, 2, 1),
(24, '邮箱删除', 'account:delete', 21, 2, 2),
(25, '用户添加', 'user:add', 6, 2, 1),
(26, '发件重置', 'user:reset-send', 6, 2, 6),
(27, '邮件列表', '', 0, 1, 4),
(28, '邮件查看', 'sys-email:query', 27, 2, 0),
(29, '邮件删除', 'sys-email:delete', 27, 2, 0)
`).run();
}
await c.env.db.prepare(`UPDATE perm SET perm_key = 'setting:clean' WHERE perm_key = 'seting:clear'`).run();
await c.env.db.prepare(`DELETE FROM perm WHERE perm_key = 'user:star'`).run();
// 创建 role 表并插入默认身份
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS role (
role_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key TEXT,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
sort INTEGER DEFAULT 0,
description TEXT,
user_id INTEGER,
is_default INTEGER DEFAULT 0,
send_count INTEGER,
send_type TEXT NOT NULL DEFAULT 'count',
account_count INTEGER
)
`).run();
const { roleCount } = await c.env.db.prepare(`SELECT COUNT(*) as roleCount FROM role`).first();
if (roleCount === 0) {
await c.env.db.prepare(`
INSERT INTO role (
role_id, name, key, create_time, sort, description, user_id, is_default, send_count, send_type, account_count
) VALUES (
1, '普通用户', NULL, '0000-00-00 00:00:00', 0, '只有普通使用权限', 0, 1, NULL, 'count', 10
)
`).run();
}
// 创建 role_perm 表并初始化数据
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS role_perm (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role_id INTEGER,
perm_id INTEGER
)
`).run();
const {rolePermCount} = await c.env.db.prepare(`SELECT COUNT(*) as rolePermCount FROM role_perm`).first();
if (rolePermCount === 0) {
await c.env.db.prepare(`
INSERT INTO role_perm (id, role_id, perm_id) VALUES
(100, 1, 2),
(101, 1, 21),
(102, 1, 22),
(103, 1, 23),
(104, 1, 24),
(105, 1, 4),
(106, 1, 5),
(107, 1, 1)
`).run();
}
},
async intDB(c) {
// 初始化数据库表结构
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS email (
email_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
send_email TEXT,
name TEXT,
receive_email TEXT NOT NULL,
account_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
subject TEXT,
content TEXT,
text TEXT,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_del INTEGER DEFAULT 0 NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS star (
star_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email_id INTEGER NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS attachments (
att_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
email_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
key TEXT NOT NULL,
filename TEXT,
mime_type TEXT,
size INTEGER,
disposition TEXT,
related TEXT,
content_id TEXT,
encoding TEXT,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS user (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
type INTEGER DEFAULT 1 NOT NULL,
password TEXT NOT NULL,
salt TEXT NOT NULL,
status INTEGER DEFAULT 0 NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
active_time DATETIME,
is_del INTEGER DEFAULT 0 NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS account (
account_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
status INTEGER DEFAULT 0 NOT NULL,
latest_email_time DATETIME,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER NOT NULL,
is_del INTEGER DEFAULT 0 NOT NULL
)
`).run();
await c.env.db.prepare(`
CREATE TABLE IF NOT EXISTS setting (
register INTEGER NOT NULL,
receive INTEGER NOT NULL,
add_email INTEGER NOT NULL,
many_email INTEGER NOT NULL,
title TEXT NOT NULL,
auto_refresh_time INTEGER NOT NULL,
register_verify INTEGER NOT NULL,
add_email_verify INTEGER NOT NULL
)
`).run();
await c.env.db.prepare(`
INSERT INTO setting (
register, receive, add_email, many_email, title, auto_refresh_time, register_verify, add_email_verify
)
SELECT 0, 0, 0, 1, 'Cloud 邮箱', 0, 1, 1
WHERE NOT EXISTS (SELECT 1 FROM setting)
`).run();
}
};
export default initService;

View File

@@ -38,7 +38,7 @@ const loginService = {
throw new BizError('非法邮箱域名');
}
const accountRow = await accountService.selectByEmailIncludeDel(c, email);
const accountRow = await accountService.selectByEmailIncludeDelNoCase(c, email);
if (accountRow && accountRow.isDel === isDel.DELETE) {
throw new BizError('该邮箱已被注销');
@@ -60,7 +60,7 @@ const loginService = {
await userService.updateUserInfo(c, userId, true);
await accountService.insert(c, { userId: userId, email });
await accountService.insert(c, { userId: userId, email, name: emailUtils.getName(email) });
},
async login(c, params) {
@@ -110,7 +110,6 @@ const loginService = {
}
await userService.updateUserInfo(c, userRow.userId);
await c.env.kv.put(KvConst.AUTH_INFO + userRow.userId, JSON.stringify(authInfo), { expirationTtl: constant.TOKEN_EXPIRE });

View File

@@ -33,6 +33,7 @@ const userService = {
user.sendCount = userRow.sendCount;
user.email = userRow.email;
user.accountId = account.accountId;
user.name = account.name;
user.permKeys = permKeys;
user.role = roleRow
@@ -304,7 +305,7 @@ const userService = {
},
async incrUserService(c, quantity, userId) {
async incrUserSendCount(c, quantity, userId) {
await orm(c).update(user).set({
sendCount: sql`${user.sendCount}
+
@@ -352,7 +353,7 @@ const userService = {
const userId = await userService.insert(c, { email, password: hash, salt, type });
await accountService.insert(c, { userId: userId, email, type });
await accountService.insert(c, { userId: userId, email, type, name: emailUtils.getName(email) });
},
async resetDaySendCount(c) {