修复认证验证码投递与登录入口体验 05-27-oa-auth-delivery-wecom-entry-fix
这个提交包含在:
+80
-7
@@ -8,7 +8,7 @@ const { validatePassword } = require("../utils/passwordPolicy");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { createNotifications } = require("../services/notifications");
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { resolveEmailDeliveryStatus, sendNotificationEmailSafe } = require("../services/email");
|
||||
const { generators, getOidcClient } = require("../services/oidc");
|
||||
|
||||
const PENDING_WECOM_BIND_COOKIE_NAME = "wecom_login_bind";
|
||||
@@ -245,6 +245,53 @@ function emailLoginCodeError() {
|
||||
});
|
||||
}
|
||||
|
||||
function emailDeliveryUnavailableError() {
|
||||
return new AppError({
|
||||
code: "SYS_001",
|
||||
message: "邮件服务暂未启用",
|
||||
statusCode: 503,
|
||||
details: { email: "delivery_not_configured" },
|
||||
});
|
||||
}
|
||||
|
||||
function assertEmailDeliveryReady(config) {
|
||||
const status = resolveEmailDeliveryStatus(config);
|
||||
if (!status.ready) {
|
||||
throw emailDeliveryUnavailableError();
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function getPart(parts, type) {
|
||||
return parts.find((part) => part.type === type)?.value ?? "";
|
||||
}
|
||||
|
||||
function formatChinaDateTime(date) {
|
||||
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
|
||||
return `${getPart(parts, "year")}年${getPart(parts, "month")}月${getPart(parts, "day")}日 ${getPart(parts, "hour")}:${getPart(parts, "minute")}(北京时间)`;
|
||||
}
|
||||
|
||||
function buildAuthVerificationEmail({ code, expiresAt, purpose }) {
|
||||
const subject =
|
||||
purpose === "password_reset"
|
||||
? "【海音达OA】密码重置验证码"
|
||||
: "【海音达OA】登录验证码";
|
||||
|
||||
return {
|
||||
subject,
|
||||
body: `验证码:${code}\n有效期至:${formatChinaDateTime(expiresAt)}\n如非本人操作,请忽略并联系管理员。`,
|
||||
};
|
||||
}
|
||||
|
||||
async function createRefreshSession(tx, { employeeId, refreshTokenHash, deviceFingerprint, request, now, expiresAt }) {
|
||||
const activeSessions = await tx.authSession.findMany({
|
||||
where: {
|
||||
@@ -303,6 +350,22 @@ async function authRoutes(fastify) {
|
||||
: undefined;
|
||||
const accessTokenExpiresIn = parseAccessTokenTtlSeconds(fastify.config.accessTokenTtl);
|
||||
|
||||
fastify.get("/capabilities", async (request, reply) => {
|
||||
const emailDelivery = resolveEmailDeliveryStatus(fastify.config);
|
||||
return sendData(reply, request, {
|
||||
emailDelivery: {
|
||||
ready: emailDelivery.ready,
|
||||
statusReasons: emailDelivery.reasons,
|
||||
},
|
||||
emailLoginCode: {
|
||||
enabled: emailDelivery.ready,
|
||||
},
|
||||
passwordReset: {
|
||||
enabled: emailDelivery.ready,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
|
||||
const parsed = loginBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
@@ -574,6 +637,8 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
assertEmailDeliveryReady(fastify.config);
|
||||
|
||||
const { email } = parsed.data;
|
||||
const employee = await fastify.prisma.employee.findFirst({
|
||||
where: { email },
|
||||
@@ -588,6 +653,7 @@ async function authRoutes(fastify) {
|
||||
const codeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000);
|
||||
const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "login" });
|
||||
|
||||
await fastify.prisma.emailLoginCode.create({
|
||||
data: {
|
||||
@@ -616,8 +682,8 @@ async function authRoutes(fastify) {
|
||||
requestId: request.id,
|
||||
type: "auth.email_login_code_requested",
|
||||
recipientEmployeeIds: [employee.id],
|
||||
subject: "【安全】OA 登录验证码",
|
||||
body: `你的 OA 登录验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
|
||||
subject: emailMessage.subject,
|
||||
body: emailMessage.body,
|
||||
});
|
||||
|
||||
return sendData(reply, request, {});
|
||||
@@ -1355,7 +1421,7 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
|
||||
const forgotPasswordBodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
email: z.string().trim().email().transform((value) => value.toLowerCase()),
|
||||
});
|
||||
|
||||
fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => {
|
||||
@@ -1369,6 +1435,8 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
assertEmailDeliveryReady(fastify.config);
|
||||
|
||||
const { email } = parsed.data;
|
||||
const employee = await fastify.prisma.employee.findFirst({
|
||||
where: { email },
|
||||
@@ -1383,6 +1451,7 @@ async function authRoutes(fastify) {
|
||||
const codeHash = sha256Hex(code);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000);
|
||||
const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "password_reset" });
|
||||
|
||||
await fastify.prisma.passwordResetCode.create({
|
||||
data: {
|
||||
@@ -1427,8 +1496,8 @@ async function authRoutes(fastify) {
|
||||
requestId: request.id,
|
||||
type: "auth.password_reset_requested",
|
||||
recipientEmployeeIds: [employee.id],
|
||||
subject: "【安全】密码重置验证码",
|
||||
body: `你的密码重置验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
|
||||
subject: emailMessage.subject,
|
||||
body: emailMessage.body,
|
||||
});
|
||||
|
||||
if (fastify.config.devShowPasswordResetCode) {
|
||||
@@ -1442,7 +1511,7 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
|
||||
const resetPasswordBodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
email: z.string().trim().email().transform((value) => value.toLowerCase()),
|
||||
code: z.string().length(6),
|
||||
newPassword: z.string().min(12),
|
||||
});
|
||||
@@ -1577,3 +1646,7 @@ async function authRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = authRoutes;
|
||||
module.exports._test = {
|
||||
buildAuthVerificationEmail,
|
||||
formatChinaDateTime,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require('dotenv/config');
|
||||
|
||||
const { getConfig } = require('../config');
|
||||
const { resolveEmailDeliveryStatus } = require('../services/email');
|
||||
|
||||
function assertCondition(condition, message) {
|
||||
if (!condition) {
|
||||
@@ -42,17 +43,20 @@ function evaluateWecom(config) {
|
||||
}
|
||||
|
||||
function evaluateEmail(config) {
|
||||
if (!config.emailEnabled) {
|
||||
const status = resolveEmailDeliveryStatus(config);
|
||||
if (status.reasons.includes('email_disabled')) {
|
||||
return { state: 'disabled', detail: '企业邮箱 SMTP 未启用', checks: [] };
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
if (!String(config.emailFrom ?? '').trim()) missing.push('EMAIL_FROM');
|
||||
if (!String(config.smtpHost ?? '').trim()) missing.push('SMTP_HOST');
|
||||
if (!Number.isFinite(Number(config.smtpPort)) || Number(config.smtpPort) <= 0) missing.push('SMTP_PORT');
|
||||
if (String(config.smtpUser ?? '').trim() && !String(config.smtpPass ?? '').trim()) missing.push('SMTP_PASS');
|
||||
const reasonLabels = {
|
||||
email_from_missing: 'EMAIL_FROM',
|
||||
smtp_host_missing: 'SMTP_HOST',
|
||||
smtp_port_invalid: 'SMTP_PORT',
|
||||
smtp_auth_incomplete: 'SMTP_USER/SMTP_PASS',
|
||||
};
|
||||
const missing = status.reasons.map((reason) => reasonLabels[reason] ?? reason);
|
||||
|
||||
assertCondition(missing.length === 0, `SMTP 配置不完整:${formatList(missing)}`);
|
||||
assertCondition(status.ready, `SMTP 配置不完整:${formatList(missing)}`);
|
||||
|
||||
return {
|
||||
state: 'ready',
|
||||
|
||||
@@ -51,6 +51,37 @@ function getTransporter(config) {
|
||||
return cachedTransporter;
|
||||
}
|
||||
|
||||
function resolveEmailDeliveryStatus(config) {
|
||||
const reasons = [];
|
||||
const authUser = String(config.smtpUser ?? '').trim();
|
||||
const authPass = String(config.smtpPass ?? '').trim();
|
||||
const smtpPort = Number(config.smtpPort);
|
||||
|
||||
if (!config.emailEnabled) {
|
||||
reasons.push('email_disabled');
|
||||
}
|
||||
if (!String(config.emailFrom ?? '').trim()) {
|
||||
reasons.push('email_from_missing');
|
||||
}
|
||||
if (!String(config.smtpHost ?? '').trim()) {
|
||||
reasons.push('smtp_host_missing');
|
||||
}
|
||||
if (!Number.isFinite(smtpPort) || smtpPort <= 0) {
|
||||
reasons.push('smtp_port_invalid');
|
||||
}
|
||||
if (authUser && !authPass) {
|
||||
reasons.push('smtp_auth_incomplete');
|
||||
}
|
||||
if (!authUser && authPass) {
|
||||
reasons.push('smtp_auth_incomplete');
|
||||
}
|
||||
|
||||
return {
|
||||
ready: reasons.length === 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
function uniq(values) {
|
||||
return Array.from(new Set((values ?? []).filter(Boolean)));
|
||||
}
|
||||
@@ -168,6 +199,7 @@ async function sendNotificationEmail({
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveEmailDeliveryStatus,
|
||||
sendNotificationEmailSafe,
|
||||
sendNotificationEmail,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ const fastifyCookie = require("@fastify/cookie");
|
||||
|
||||
const authRoutes = require("../../../src/routes/auth");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const { hashPassword } = require("../../../src/utils/crypto");
|
||||
const { hashPassword, sha256Hex } = require("../../../src/utils/crypto");
|
||||
const { signPayload } = require("../../../src/utils/signedUrl");
|
||||
|
||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
@@ -28,15 +28,18 @@ async function buildApp(passwordHash) {
|
||||
login_failed_first_at: null,
|
||||
login_locked_until: null,
|
||||
});
|
||||
const employeeFindMany = vi.fn().mockResolvedValue([]);
|
||||
const employeeUpdate = vi.fn().mockResolvedValue({});
|
||||
const authSessionFindMany = vi.fn().mockResolvedValue([]);
|
||||
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
|
||||
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
||||
const notificationCreateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
||||
const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
||||
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const passwordResetCodeCreate = vi.fn().mockResolvedValue({ id: "password-reset-code-1" });
|
||||
const wecomFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
||||
|
||||
@@ -56,6 +59,9 @@ async function buildApp(passwordHash) {
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
notification: {
|
||||
createMany: notificationCreateMany,
|
||||
},
|
||||
wecomIdentityBinding: {
|
||||
findFirst: wecomFindFirst,
|
||||
upsert: wecomUpsert,
|
||||
@@ -72,6 +78,14 @@ async function buildApp(passwordHash) {
|
||||
loginFailMaxAttempts: 5,
|
||||
emailLoginCodeTtlSeconds: 600,
|
||||
emailLoginCodeMaxAttempts: 5,
|
||||
passwordResetCodeTtlSeconds: 600,
|
||||
emailEnabled: true,
|
||||
emailFrom: "[email protected]",
|
||||
smtpHost: "smtp.hyinda.test",
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUser: "",
|
||||
smtpPass: "",
|
||||
rateLimitEnabled: false,
|
||||
jwtSecret: JWT_SECRET,
|
||||
appOrigin: "http://127.0.0.1:3000",
|
||||
@@ -80,14 +94,21 @@ async function buildApp(passwordHash) {
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
findFirst: employeeFindFirst,
|
||||
findMany: employeeFindMany,
|
||||
},
|
||||
emailLoginCode: {
|
||||
create: emailLoginCodeCreate,
|
||||
findFirst: emailLoginCodeFindFirst,
|
||||
},
|
||||
passwordResetCode: {
|
||||
create: passwordResetCodeCreate,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
notification: {
|
||||
createMany: notificationCreateMany,
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
});
|
||||
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
||||
@@ -110,15 +131,18 @@ async function buildApp(passwordHash) {
|
||||
return {
|
||||
app,
|
||||
employeeFindFirst,
|
||||
employeeFindMany,
|
||||
employeeUpdate,
|
||||
authSessionFindMany,
|
||||
authSessionUpdateMany,
|
||||
authSessionCreate,
|
||||
auditCreate,
|
||||
notificationCreateMany,
|
||||
emailLoginCodeCreate,
|
||||
emailLoginCodeFindFirst,
|
||||
emailLoginCodeUpdate,
|
||||
emailLoginCodeUpdateMany,
|
||||
passwordResetCodeCreate,
|
||||
wecomFindFirst,
|
||||
wecomUpsert,
|
||||
};
|
||||
@@ -209,6 +233,78 @@ describe("auth login route", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("reports auth delivery capabilities without exposing SMTP values", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app } = await buildApp(passwordHash);
|
||||
|
||||
const readyResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/capabilities",
|
||||
});
|
||||
|
||||
expect(readyResponse.statusCode).toBe(200);
|
||||
expect(readyResponse.json().data).toEqual({
|
||||
emailDelivery: {
|
||||
ready: true,
|
||||
statusReasons: [],
|
||||
},
|
||||
emailLoginCode: {
|
||||
enabled: true,
|
||||
},
|
||||
passwordReset: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(readyResponse.json())).not.toContain("smtp.hyinda.test");
|
||||
|
||||
app.config.emailEnabled = false;
|
||||
app.config.smtpHost = "";
|
||||
const disabledResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/capabilities",
|
||||
});
|
||||
|
||||
expect(disabledResponse.statusCode).toBe(200);
|
||||
expect(disabledResponse.json().data).toEqual({
|
||||
emailDelivery: {
|
||||
ready: false,
|
||||
statusReasons: ["email_disabled", "smtp_host_missing"],
|
||||
},
|
||||
emailLoginCode: {
|
||||
enabled: false,
|
||||
},
|
||||
passwordReset: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("builds auth verification emails with Beijing-time expiry wording", () => {
|
||||
expect(
|
||||
authRoutes._test.buildAuthVerificationEmail({
|
||||
code: "123456",
|
||||
expiresAt: new Date("2026-05-27T01:12:00.000Z"),
|
||||
purpose: "login",
|
||||
}),
|
||||
).toEqual({
|
||||
subject: "【海音达OA】登录验证码",
|
||||
body: "验证码:123456\n有效期至:2026年05月27日 09:12(北京时间)\n如非本人操作,请忽略并联系管理员。",
|
||||
});
|
||||
|
||||
expect(
|
||||
authRoutes._test.buildAuthVerificationEmail({
|
||||
code: "654321",
|
||||
expiresAt: new Date("2026-05-27T01:12:00.000Z"),
|
||||
purpose: "password_reset",
|
||||
}),
|
||||
).toEqual({
|
||||
subject: "【海音达OA】密码重置验证码",
|
||||
body: "验证码:654321\n有效期至:2026年05月27日 09:12(北京时间)\n如非本人操作,请忽略并联系管理员。",
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-binds pending wecom identity after password login", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
|
||||
@@ -303,6 +399,26 @@ describe("auth login route", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects email login code requests when delivery is not configured before account lookup", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||
app.config.emailEnabled = false;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/email-login/code",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: { email: "[email protected]" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json().error.details).toEqual({ email: "delivery_not_configured" });
|
||||
expect(employeeFindFirst).not.toHaveBeenCalled();
|
||||
expect(emailLoginCodeCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not reveal missing accounts when requesting an email login code", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||
@@ -408,4 +524,56 @@ describe("auth login route", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("sends password reset codes with Beijing-time expiry wording", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.654321);
|
||||
const { app, passwordResetCodeCreate, auditCreate, notificationCreateMany } = await buildApp(passwordHash);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/forgot-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: { email: " [email protected] " },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({});
|
||||
expect(passwordResetCodeCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
employee_id: EMPLOYEE_ID,
|
||||
code_hash: sha256Hex("654321"),
|
||||
}),
|
||||
});
|
||||
expect(auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_PASSWORD_RESET_REQUESTED",
|
||||
actor_id: EMPLOYEE_ID,
|
||||
}),
|
||||
});
|
||||
expect(notificationCreateMany).toHaveBeenCalled();
|
||||
|
||||
randomSpy.mockRestore();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects password reset code requests when delivery is not configured before account lookup", async () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeFindFirst, passwordResetCodeCreate } = await buildApp(passwordHash);
|
||||
app.config.smtpHost = "";
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/forgot-password",
|
||||
headers: { "content-type": "application/json" },
|
||||
payload: { email: "[email protected]" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json().error.details).toEqual({ email: "delivery_not_configured" });
|
||||
expect(employeeFindFirst).not.toHaveBeenCalled();
|
||||
expect(passwordResetCodeCreate).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const { resolveEmailDeliveryStatus } = require("../../../src/services/email");
|
||||
|
||||
describe("email delivery status", () => {
|
||||
it("reports ready only when SMTP delivery has the required public settings", () => {
|
||||
expect(
|
||||
resolveEmailDeliveryStatus({
|
||||
emailEnabled: true,
|
||||
emailFrom: "[email protected]",
|
||||
smtpHost: "smtp.hyinda.test",
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUser: "",
|
||||
smtpPass: "",
|
||||
}),
|
||||
).toEqual({
|
||||
ready: true,
|
||||
reasons: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports non-secret missing SMTP reasons", () => {
|
||||
expect(
|
||||
resolveEmailDeliveryStatus({
|
||||
emailEnabled: true,
|
||||
emailFrom: "",
|
||||
smtpHost: "",
|
||||
smtpPort: 0,
|
||||
smtpSecure: false,
|
||||
smtpUser: "sender",
|
||||
smtpPass: "",
|
||||
}),
|
||||
).toEqual({
|
||||
ready: false,
|
||||
reasons: ["email_from_missing", "smtp_host_missing", "smtp_port_invalid", "smtp_auth_incomplete"],
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户