Merge pull request 'fix(email): complete mail settings and templates' (#19) from worktree/email-system into main
Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
!/tests/widget-system/icon-card-contract.test.cjs
|
||||
!/tests/widget-system/page-display-save-harness.php
|
||||
!/tests/widget-system/admin-ui-contract.test.cjs
|
||||
!/tests/widget-system/email-system-contract.test.cjs
|
||||
!/tests/widget-system/auth-source-contract.test.cjs
|
||||
!/tests/widget-system/captcha-business-contract.test.cjs
|
||||
!/tests/widget-system/captcha-security-harness.php
|
||||
|
||||
@@ -3,8 +3,6 @@ export const globalApiMethods = {
|
||||
* 全局&功能 (Global & Functions)
|
||||
* ================================================================ */
|
||||
|
||||
getMailSettings() { return this.request('/settings/mail') },
|
||||
saveMailSettings(data) { return this.request('/settings/mail', { method: 'POST', body: JSON.stringify(data) }) },
|
||||
testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) },
|
||||
|
||||
getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') },
|
||||
|
||||
@@ -273,6 +273,17 @@
|
||||
v-html="schema.content || modelValue || ''"
|
||||
/>
|
||||
|
||||
<!-- Action button -->
|
||||
<n-button
|
||||
v-else-if="schema.type === 'button'"
|
||||
:type="schema.buttonType || 'default'"
|
||||
:loading="schema.loading"
|
||||
:disabled="schema.disabled"
|
||||
@click="emit('action', schema.key)"
|
||||
>
|
||||
{{ schema.buttonText || schema.label || '执行' }}
|
||||
</n-button>
|
||||
|
||||
<!-- Date picker -->
|
||||
<n-date-picker
|
||||
v-else-if="schema.type === 'date'"
|
||||
@@ -645,7 +656,7 @@ const props = defineProps({
|
||||
errorMessage: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const emit = defineEmits(['update:modelValue', 'action'])
|
||||
|
||||
const secretMarkedForClear = computed(() => props.schema.secret && props.modelValue === '__AKSRT_CLEAR_SECRET__')
|
||||
const secretDisplayValue = computed(() => secretMarkedForClear.value ? '' : props.modelValue)
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
:model-value="getFieldValue(field.key)"
|
||||
:error-message="errors?.[field.key]"
|
||||
@update:model-value="setFieldValue(field.key, $event)"
|
||||
@action="emit('field-action', $event)"
|
||||
/>
|
||||
</slot>
|
||||
<AksField
|
||||
@@ -74,6 +75,7 @@
|
||||
:model-value="getFieldValue(field.key)"
|
||||
:error-message="errors?.[field.key]"
|
||||
@update:model-value="setFieldValue(field.key, $event)"
|
||||
@action="emit('field-action', $event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -161,7 +163,7 @@ const props = defineProps({
|
||||
errors: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:formData', 'save', 'reload'])
|
||||
const emit = defineEmits(['update:formData', 'save', 'reload', 'field-action'])
|
||||
const slots = useSlots()
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
@update:form-data="formData = $event"
|
||||
@save="handleSave"
|
||||
@reload="handleReload"
|
||||
@field-action="handleFieldAction"
|
||||
>
|
||||
<template
|
||||
v-if="isPermissionsLogin"
|
||||
@@ -142,6 +143,7 @@ const captchaProviderTab = ref('tencent')
|
||||
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
|
||||
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
|
||||
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
|
||||
const isEmailPage = computed(() => resolvedDomain.value === 'global' && resolvedSection.value === 'email')
|
||||
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
|
||||
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
|
||||
|
||||
@@ -158,7 +160,7 @@ function resolveSectionFromPath(path) {
|
||||
|
||||
/** Load schema from the Vue-side registry */
|
||||
const pageMeta = computed(() => {
|
||||
const schema = resolvedDomain.value === 'page_display'
|
||||
const schema = (isEmailPage.value || resolvedDomain.value === 'page_display')
|
||||
? serverSchema.value
|
||||
: getSchema(resolvedDomain.value, resolvedSection.value)
|
||||
if (schema && schema.groups && schema.groups.length > 0) {
|
||||
@@ -180,7 +182,7 @@ const pageMeta = computed(() => {
|
||||
|
||||
/** Default values from schema */
|
||||
const defaults = computed(() => {
|
||||
const schema = resolvedDomain.value === 'page_display'
|
||||
const schema = (isEmailPage.value || resolvedDomain.value === 'page_display')
|
||||
? serverSchema.value
|
||||
: getSchema(resolvedDomain.value, resolvedSection.value)
|
||||
if (!schema) return {}
|
||||
@@ -202,9 +204,7 @@ const {
|
||||
} = useSectionForm({
|
||||
fetch: async () => {
|
||||
const response = await api.getSchemaPage(resolvedDomain.value, resolvedSection.value)
|
||||
if (resolvedDomain.value === 'page_display') {
|
||||
serverSchema.value = response?.schema || null
|
||||
}
|
||||
serverSchema.value = response?.schema || serverSchema.value
|
||||
return response
|
||||
},
|
||||
submit: async (data) => {
|
||||
@@ -231,6 +231,25 @@ const {
|
||||
},
|
||||
})
|
||||
|
||||
async function handleFieldAction(key) {
|
||||
if (key !== 'email_test_send' || !isEmailPage.value) return
|
||||
const to = String(formData.value?.email_test_to || '').trim()
|
||||
if (!to) {
|
||||
message.warning('请先填写测试收件地址')
|
||||
return
|
||||
}
|
||||
if (dirty.value) {
|
||||
message.warning('请先保存当前配置,再发送测试邮件')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await api.testMail(to)
|
||||
message.success(response?.message || '测试邮件已发送')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '测试邮件发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await doSave()
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
export const emailSchema = {
|
||||
key: 'global.email',
|
||||
title: '邮件配置',
|
||||
icon: 'fa fas fa-envelope',
|
||||
description: 'SMTP 发信配置、邮件事件开关与模板自定义。',
|
||||
defaults: {
|
||||
email_footer_text: '',
|
||||
email_site_branding: true,
|
||||
email_template_mode: 'plain',
|
||||
email_reg_verify_link_enabled: true,
|
||||
email_reg_verify_code_enabled: true,
|
||||
email_reg_success_enabled: true,
|
||||
email_password_reset_enabled: true,
|
||||
email_change_code_enabled: true,
|
||||
email_change_notice_enabled: true,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'global',
|
||||
title: '全局设置',
|
||||
icon: 'fa fas fa-cog',
|
||||
fields: [
|
||||
{ key: 'email_footer_text', label: '邮件页脚文字', type: 'textarea', rows: 2, placeholder: '留空则使用站点名称', help: '附加在每封邮件底部的文字' },
|
||||
{ key: 'email_site_branding', label: '显示站点品牌', type: 'switch', default: true, help: '在邮件开头显示「[站点名]」前缀' },
|
||||
{ key: 'email_template_mode', label: '邮件格式', type: 'radio', default: 'plain', options: [
|
||||
{ label: '纯文本', value: 'plain' },
|
||||
{ label: 'HTML', value: 'html' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'branding',
|
||||
title: '邮件品牌',
|
||||
icon: 'fa fas fa-pen',
|
||||
fields: [
|
||||
{ key: 'mail_showname', label: '发件人名称', type: 'input', default: '', help: '覆盖系统默认发件人名称' },
|
||||
{ key: 'mail_title_prefix', label: '主题前缀', type: 'input', default: '', help: '自动添加在所有邮件主题前面,如【站点名】' },
|
||||
{ key: 'mail_description', label: '邮件底部说明', type: 'textarea', rows: 3, default: '', help: '附加在邮件页脚文字之后,留空不显示' },
|
||||
{ key: 'mail_more_content', label: '邮件附加内容', type: 'textarea', rows: 4, default: '', help: '附加在邮件内容末尾的补充说明' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'reg_verify_link',
|
||||
title: '注册验证链接',
|
||||
icon: 'fa fas fa-link',
|
||||
fields: [
|
||||
{ key: 'email_reg_verify_link_enabled', label: '启用', type: 'switch', default: true, help: '注册后发送验证链接邮件' },
|
||||
{ key: 'email_reg_verify_link_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '验证你的邮箱地址' },
|
||||
{ key: 'email_reg_verify_link_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {url}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{url} 验证链接' },
|
||||
{ key: 'email_reg_verify_link_html', label: 'HTML 格式', type: 'switch', default: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'reg_verify_code',
|
||||
title: '注册验证码',
|
||||
icon: 'fa fas fa-keyboard',
|
||||
fields: [
|
||||
{ key: 'email_reg_verify_code_enabled', label: '启用', type: 'switch', default: true, help: '注册后发送 6 位验证码邮件' },
|
||||
{ key: 'email_reg_verify_code_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证码' },
|
||||
{ key: 'email_reg_verify_code_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {code}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{code} 验证码' },
|
||||
{ key: 'email_reg_verify_code_html', label: 'HTML 格式', type: 'switch', default: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'reg_success',
|
||||
title: '注册成功通知',
|
||||
icon: 'fa fas fa-check-circle',
|
||||
fields: [
|
||||
{ key: 'email_reg_success_enabled', label: '启用', type: 'switch', default: true, help: '邮箱验证成功后发送通知邮件' },
|
||||
{ key: 'email_reg_success_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证成功' },
|
||||
{ key: 'email_reg_success_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name}', help: '可用占位符:{username} 用户名、{site_name} 站点名' },
|
||||
{ key: 'email_reg_success_html', label: 'HTML 格式', type: 'switch', default: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'password_reset',
|
||||
title: '密码重置',
|
||||
icon: 'fa fas fa-unlock-alt',
|
||||
fields: [
|
||||
{ key: 'email_password_reset_enabled', label: '启用', type: 'switch', default: true, help: '发送密码重置链接邮件' },
|
||||
{ key: 'email_password_reset_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '重置密码' },
|
||||
{ key: 'email_password_reset_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {url}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{url} 重置链接' },
|
||||
{ key: 'email_password_reset_html', label: 'HTML 格式', type: 'switch', default: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'change_code',
|
||||
title: '邮箱变更验证码',
|
||||
icon: 'fa fas fa-shield-alt',
|
||||
fields: [
|
||||
{ key: 'email_change_code_enabled', label: '启用', type: 'switch', default: true, help: '变更邮箱时发送验证码邮件' },
|
||||
{ key: 'email_change_code_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证码' },
|
||||
{ key: 'email_change_code_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {code}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{code} 验证码' },
|
||||
{ key: 'email_change_code_html', label: 'HTML 格式', type: 'switch', default: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'change_notice',
|
||||
title: '邮箱变更通知',
|
||||
icon: 'fa fas fa-bell',
|
||||
fields: [
|
||||
{ key: 'email_change_notice_enabled', label: '启用', type: 'switch', default: true, help: '邮箱变更成功后向旧邮箱发送通知' },
|
||||
{ key: 'email_change_notice_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '账号邮箱已变更' },
|
||||
{ key: 'email_change_notice_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {new_email}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{new_email} 新邮箱' },
|
||||
{ key: 'email_change_notice_html', label: 'HTML 格式', type: 'switch', default: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { logoSchema } from './logo.js'
|
||||
import { commonSchema } from './common.js'
|
||||
import { layoutSchema } from './layout.js'
|
||||
import { searchSchema } from './search.js'
|
||||
import { emailSchema } from './email.js'
|
||||
// Email schema is served by the PHP template catalog to keep defaults and fields canonical.
|
||||
import { tabbarSchema } from './tabbar.js'
|
||||
import { floatingSchema } from './floating.js'
|
||||
import { i18nSchema } from './i18n.js'
|
||||
@@ -21,7 +21,7 @@ export const globalSchemas = {
|
||||
common: commonSchema,
|
||||
layout: layoutSchema,
|
||||
search: searchSchema,
|
||||
email: emailSchema,
|
||||
email: null,
|
||||
tabbar: tabbarSchema,
|
||||
floating: floatingSchema,
|
||||
i18n: i18nSchema,
|
||||
|
||||
@@ -161,6 +161,9 @@ if ($aksrt_proxy_mode === 'server' && class_exists('\AKSRT\Auth\OAuthProxyServer
|
||||
}
|
||||
|
||||
\AKSRT\Settings\Settings::get_instance()->init();
|
||||
if (class_exists('\AKSRT\Settings\MailNotifications')) {
|
||||
\AKSRT\Settings\MailNotifications::init();
|
||||
}
|
||||
\AKSRT\Auth\Router::get_instance()->init();
|
||||
\AKSRT\Core\Templates::get_instance()->init();
|
||||
\AKSRT\Auth\Moderation::get_instance()->init();
|
||||
|
||||
+23
-33
@@ -18,50 +18,40 @@ add_filter('show_admin_bar', '__return_false');
|
||||
// ============================================================
|
||||
|
||||
add_action('phpmailer_init', function ($phpmailer) {
|
||||
$mail = get_option('aksrt_mail_smtp', array());
|
||||
$mail = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_smtp()
|
||||
: [];
|
||||
if (empty($mail['enabled']) || empty($mail['host'])) return;
|
||||
|
||||
$phpmailer->isSMTP();
|
||||
$phpmailer->Host = $mail['host'];
|
||||
$phpmailer->Port = (int) (isset($mail['port']) ? $mail['port'] : 465);
|
||||
$phpmailer->SMTPAuth = !empty($mail['username']);
|
||||
$phpmailer->Username = $mail['username'];
|
||||
$phpmailer->Password = $mail['password'];
|
||||
$phpmailer->SMTPSecure = isset($mail['encryption']) ? $mail['encryption'] : 'ssl';
|
||||
$phpmailer->Port = (int) ($mail['port'] ?? 465);
|
||||
$phpmailer->SMTPAuth = !empty($mail['auth']) && !empty($mail['username']);
|
||||
$phpmailer->Username = (string) ($mail['username'] ?? '');
|
||||
$phpmailer->Password = (string) ($mail['password'] ?? '');
|
||||
$phpmailer->SMTPSecure = (string) ($mail['encryption'] ?? 'ssl');
|
||||
$phpmailer->Timeout = 10;
|
||||
$phpmailer->Timelimit = 15;
|
||||
|
||||
// 性能优化:超时 + 连接保持
|
||||
$phpmailer->Timeout = 10; // 连接/读取超时 10 秒
|
||||
$phpmailer->Timelimit = 15; // 总执行上限 15 秒
|
||||
|
||||
if (!empty($mail['from_address'])) {
|
||||
$from_name = isset($mail['from_name']) ? $mail['from_name'] : '';
|
||||
// mail_showname 覆盖 from_name
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$branding = \AKSRT\Settings\Settings::get_mail_branding();
|
||||
if ($branding['showname'] !== '') {
|
||||
$from_name = $branding['showname'];
|
||||
}
|
||||
}
|
||||
$phpmailer->setFrom($mail['from_address'], $from_name);
|
||||
}
|
||||
if (!empty($mail['from_name'])) {
|
||||
$phpmailer->FromName = $mail['from_name'];
|
||||
}
|
||||
// mail_showname 全局覆盖 FromName
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$branding = \AKSRT\Settings\Settings::get_mail_branding();
|
||||
if ($branding['showname'] !== '') {
|
||||
$phpmailer->FromName = $branding['showname'];
|
||||
}
|
||||
$from_address = sanitize_email((string) ($mail['from_address'] ?? ''));
|
||||
if ($from_address !== '') {
|
||||
$from_name = (string) ($mail['from_name'] ?? get_bloginfo('name'));
|
||||
$phpmailer->setFrom($from_address, $from_name);
|
||||
}
|
||||
$phpmailer->FromName = (string) ($mail['from_name'] ?? get_bloginfo('name'));
|
||||
});
|
||||
|
||||
// mail_title_prefix:为所有邮件主题添加前缀
|
||||
// mail_title_prefix is applied by the canonical renderer and by third-party wp_mail callers.
|
||||
add_filter('wp_mail', function ($atts) {
|
||||
if (!class_exists('\AKSRT\Settings\Settings') || empty($atts['subject'])) return $atts;
|
||||
$branding = \AKSRT\Settings\Settings::get_mail_branding();
|
||||
if ($branding['title_prefix'] !== '' && strpos($atts['subject'], $branding['title_prefix']) !== 0) {
|
||||
$atts['subject'] = $branding['title_prefix'] . ' ' . $atts['subject'];
|
||||
$subject = (string) $atts['subject'];
|
||||
$site = wp_specialchars_decode(get_option('blogname'), ENT_QUOTES);
|
||||
$subject = preg_replace('/^\[' . preg_quote($site, '/') . '\]\s*/u', '', $subject);
|
||||
if ($branding['title_prefix'] !== '' && strpos($subject, $branding['title_prefix']) !== 0) {
|
||||
$atts['subject'] = $branding['title_prefix'] . ' ' . $subject;
|
||||
} else {
|
||||
$atts['subject'] = $subject;
|
||||
}
|
||||
return $atts;
|
||||
});
|
||||
|
||||
@@ -219,7 +219,7 @@ function aksrt_add_user_notification(int $user_id, array $payload): void {
|
||||
|
||||
if ($table === '') return;
|
||||
|
||||
$wpdb->insert($table, [
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'user_id' => $user_id,
|
||||
'type' => $type,
|
||||
'title' => sanitize_text_field((string) ($payload['title'] ?? '')),
|
||||
@@ -229,6 +229,13 @@ function aksrt_add_user_notification(int $user_id, array $payload): void {
|
||||
'created_at' => $created_at,
|
||||
'read_at' => !empty($payload['read']) ? current_time('mysql', true) : null,
|
||||
], ['%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s']);
|
||||
if ($inserted !== false) {
|
||||
do_action('aksrt_notification_sent', $user_id, $type, [
|
||||
'title' => (string) ($payload['title'] ?? ''),
|
||||
'description' => (string) ($payload['desc'] ?? ''),
|
||||
'target_url' => (string) ($payload['url'] ?? ''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
endif;
|
||||
|
||||
|
||||
@@ -151,18 +151,10 @@ add_action('wp_ajax_aksrt_send_email_code', function () {
|
||||
$code = str_pad( (string) random_int( 0, 999999 ), 6, '0', STR_PAD_LEFT );
|
||||
|
||||
$site_name = get_bloginfo( 'name' );
|
||||
$default_subject = '[' . $site_name . '] ' . __( '邮箱验证码', 'aksrt' );
|
||||
$default_content = sprintf(
|
||||
__( "您好 %s,\n\n您正在进行敏感操作验证,验证码为:\n\n【%s】\n\n验证码有效期为 5 分钟。如非本人操作,请忽略此邮件。", 'aksrt' ),
|
||||
$current_user->display_name,
|
||||
$code
|
||||
$sent = \AKSRT\Settings\Settings::send_event_email(
|
||||
'change_code', $target_email, '', '',
|
||||
[ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{code}' => $code ]
|
||||
);
|
||||
$sent = class_exists( '\AKSRT\Settings\Settings' )
|
||||
? \AKSRT\Settings\Settings::send_event_email(
|
||||
'change_code', $target_email, $default_subject, $default_content,
|
||||
[ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{code}' => $code ]
|
||||
)
|
||||
: wp_mail( $target_email, $default_subject, $default_content, [ 'Content-Type: text/plain; charset=UTF-8' ] );
|
||||
|
||||
if ( ! $sent ) {
|
||||
delete_transient( $rate_key );
|
||||
@@ -301,20 +293,10 @@ add_action('wp_ajax_aksrt_change_email', function () {
|
||||
|
||||
if ( is_email( $old_email ) ) {
|
||||
$site_name = get_bloginfo( 'name' );
|
||||
$default_subject = '[' . $site_name . '] ' . __( '账号邮箱已变更', 'aksrt' );
|
||||
$default_content = sprintf(
|
||||
__( "您好 %s,\n\n您的账号邮箱已成功修改为:%s\n\n如果这不是您的操作,请立即联系站点管理员。", 'aksrt' ),
|
||||
$current_user->display_name,
|
||||
$new_email
|
||||
\AKSRT\Settings\Settings::send_event_email(
|
||||
'change_notice', $old_email, '', '',
|
||||
[ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{new_email}' => $new_email ]
|
||||
);
|
||||
if ( class_exists( '\AKSRT\Settings\Settings' ) ) {
|
||||
\AKSRT\Settings\Settings::send_event_email(
|
||||
'change_notice', $old_email, $default_subject, $default_content,
|
||||
[ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{new_email}' => $new_email ]
|
||||
);
|
||||
} else {
|
||||
wp_mail( $old_email, $default_subject, $default_content, [ 'Content-Type: text/plain; charset=UTF-8' ] );
|
||||
}
|
||||
}
|
||||
|
||||
$verify_url = '';
|
||||
|
||||
@@ -23,12 +23,6 @@ class GlobalController extends ControllerBase {
|
||||
* Register all routes for the global domain.
|
||||
*/
|
||||
public function register_routes(): void {
|
||||
// Email / SMTP
|
||||
register_rest_route(self::API_NAMESPACE, '/settings/mail', [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'callback' => [$this, 'rest_handle_mail'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
register_rest_route(self::API_NAMESPACE, '/mail/test', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_test_mail'],
|
||||
@@ -175,30 +169,6 @@ class GlobalController extends ControllerBase {
|
||||
return $this->success(['message' => 'CMS 功能已保存', 'cms_features' => \AKSRT\Settings\Settings::get_cms_features()]);
|
||||
}
|
||||
|
||||
public function rest_handle_mail(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$option_key = 'aksrt_mail_config';
|
||||
|
||||
if ($request->get_method() === 'GET') {
|
||||
$config = get_option($option_key, [
|
||||
'smtp_host' => '',
|
||||
'smtp_port' => 465,
|
||||
'smtp_user' => '',
|
||||
'smtp_pass' => '',
|
||||
'smtp_secure' => 'ssl',
|
||||
'from_email' => '',
|
||||
'from_name' => '',
|
||||
]);
|
||||
return $this->success(['data' => $config, 'code' => 'success']);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$current = get_option($option_key, []);
|
||||
$merged = array_merge(is_array($current) ? $current : [], $params);
|
||||
update_option($option_key, $merged);
|
||||
|
||||
return $this->success(['message' => '邮件配置已保存', 'code' => 'success']);
|
||||
}
|
||||
|
||||
public function rest_test_mail(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$to = sanitize_email($params['to'] ?? '');
|
||||
@@ -206,7 +176,9 @@ class GlobalController extends ControllerBase {
|
||||
return $this->error(__('请输入有效的邮箱地址', 'aksrt'));
|
||||
}
|
||||
|
||||
$sent = wp_mail($to, __('AKSRT 邮件测试', 'aksrt'), __('如果你收到这封邮件,说明 SMTP 配置正确。', 'aksrt'));
|
||||
$sent = class_exists('\\AKSRT\\Settings\\Mail')
|
||||
? \AKSRT\Settings\Mail::send_test_email($to)
|
||||
: false;
|
||||
if ($sent) {
|
||||
return $this->success(['message' => '测试邮件已发送']);
|
||||
}
|
||||
@@ -388,6 +360,7 @@ class GlobalController extends ControllerBase {
|
||||
return ($a['sort_order'] ?? 0) <=> ($b['sort_order'] ?? 0);
|
||||
});
|
||||
update_option('aksrt_friend_links', $links);
|
||||
do_action('aksrt_friend_link_submitted', $new_link);
|
||||
|
||||
return rest_ensure_response(['code' => 'success', 'data' => $new_link, 'message' => __('Friend link added.', 'aksrt')]);
|
||||
}
|
||||
|
||||
@@ -369,64 +369,7 @@ class GlobalSchema {
|
||||
]],
|
||||
],
|
||||
],
|
||||
'email' => [
|
||||
'key' => 'email',
|
||||
'title' => '邮件事件',
|
||||
'version' => '2.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'global', 'title' => '全局设置', 'fields' => [
|
||||
['key' => 'email_footer_text', 'label' => '邮件页脚文字', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'email_site_branding', 'label' => '显示站点品牌', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'email_template_mode', 'label' => '邮件格式', 'type' => 'radio', 'default' => 'plain', 'options' => [
|
||||
['label' => '纯文本', 'value' => 'plain'],
|
||||
['label' => 'HTML', 'value' => 'html'],
|
||||
]],
|
||||
]],
|
||||
['key' => 'branding', 'title' => '邮件品牌', 'fields' => [
|
||||
['key' => 'mail_showname', 'label' => '发件人名称', 'type' => 'input', 'default' => '', 'help' => '覆盖系统默认发件人名称'],
|
||||
['key' => 'mail_title_prefix', 'label' => '主题前缀', 'type' => 'input', 'default' => '', 'help' => '自动添加在所有邮件主题前面,如【站点名】'],
|
||||
['key' => 'mail_description', 'label' => '邮件底部说明', 'type' => 'textarea', 'default' => '', 'help' => '附加在邮件页脚文字之后,留空不显示'],
|
||||
['key' => 'mail_more_content', 'label' => '邮件附加内容', 'type' => 'textarea', 'default' => '', 'help' => '附加在邮件内容末尾的补充说明'],
|
||||
]],
|
||||
['key' => 'reg_verify_link', 'title' => '注册验证链接', 'fields' => [
|
||||
['key' => 'email_reg_verify_link_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'email_reg_verify_link_subject', 'label' => '主题', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'email_reg_verify_link_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'email_reg_verify_link_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false],
|
||||
]],
|
||||
['key' => 'reg_verify_code', 'title' => '注册验证码', 'fields' => [
|
||||
['key' => 'email_reg_verify_code_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'email_reg_verify_code_subject', 'label' => '主题', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'email_reg_verify_code_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'email_reg_verify_code_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false],
|
||||
]],
|
||||
['key' => 'reg_success', 'title' => '注册成功通知', 'fields' => [
|
||||
['key' => 'email_reg_success_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'email_reg_success_subject', 'label' => '主题', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'email_reg_success_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'email_reg_success_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false],
|
||||
]],
|
||||
['key' => 'password_reset', 'title' => '密码重置', 'fields' => [
|
||||
['key' => 'email_password_reset_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'email_password_reset_subject', 'label' => '主题', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'email_password_reset_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'email_password_reset_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false],
|
||||
]],
|
||||
['key' => 'change_code', 'title' => '邮箱变更验证码', 'fields' => [
|
||||
['key' => 'email_change_code_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'email_change_code_subject', 'label' => '主题', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'email_change_code_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'email_change_code_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false],
|
||||
]],
|
||||
['key' => 'change_notice', 'title' => '邮箱变更通知', 'fields' => [
|
||||
['key' => 'email_change_notice_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'email_change_notice_subject', 'label' => '主题', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'email_change_notice_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'email_change_notice_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false],
|
||||
]],
|
||||
],
|
||||
],
|
||||
'email' => \AKSRT\Settings\MailTemplates::build_schema(),
|
||||
'custom-code' => [
|
||||
'key' => 'custom-code',
|
||||
'title' => '自定义代码',
|
||||
|
||||
@@ -540,9 +540,15 @@ class SchemaProvider {
|
||||
|
||||
$data = $this->collect_defaults($schema);
|
||||
foreach ($this->get_all_fields($schema) as $field) {
|
||||
if (array_key_exists($field['key'], $stored)) {
|
||||
$data[$field['key']] = $stored[$field['key']];
|
||||
}
|
||||
if (array_key_exists($field['key'], $stored)) {
|
||||
$stored_value = $stored[$field['key']];
|
||||
$is_mail_template = $option_key === 'aksrt_settings'
|
||||
&& preg_match('/^email_.+_(subject|content)$/', (string) $field['key']);
|
||||
if (!$is_mail_template || trim((string) $stored_value) !== '') {
|
||||
$data[$field['key']] = $stored_value;
|
||||
}
|
||||
}
|
||||
|
||||
$stored_value = array_key_exists($field['key'], $stored) ? $stored[$field['key']] : ($data[$field['key']] ?? '');
|
||||
if (!empty($field['secret'])) {
|
||||
$data[$field['key']] = trim((string) $stored_value) !== '' ? '***' : '';
|
||||
@@ -732,6 +738,10 @@ class SchemaProvider {
|
||||
if (!is_array($field) || empty($field['key'])) {
|
||||
continue;
|
||||
}
|
||||
// UI-only controls never become persisted settings keys.
|
||||
if (!empty($field['ui_only'])) {
|
||||
continue;
|
||||
}
|
||||
// Notice/help-only rows are UI chrome, not stored settings keys.
|
||||
if (($field['type'] ?? '') === 'notice') {
|
||||
continue;
|
||||
|
||||
@@ -87,13 +87,6 @@ class UserController extends ControllerBase {
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Mail templates (email content templates)
|
||||
register_rest_route(self::API_NAMESPACE, '/settings/mail-templates', [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'callback' => [$this, 'handle_mail_templates'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/assets/settings', [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'callback' => [$this, 'rest_handle_credit_settings'],
|
||||
@@ -325,40 +318,6 @@ class UserController extends ControllerBase {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* GET/POST /settings/mail-templates — 邮件内容模板
|
||||
*/
|
||||
public function handle_mail_templates(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$option_key = 'aksrt_mail_templates';
|
||||
$fields = [
|
||||
'welcome_subject', 'welcome_body',
|
||||
'comment_notify_subject', 'comment_notify_body',
|
||||
'comment_reply_subject', 'comment_reply_body',
|
||||
'password_reset_subject', 'password_reset_body',
|
||||
'email_verify_subject', 'email_verify_body',
|
||||
];
|
||||
|
||||
if ($request->get_method() === 'GET') {
|
||||
$stored = get_option($option_key, []);
|
||||
if (!is_array($stored)) $stored = [];
|
||||
$result = [];
|
||||
foreach ($fields as $f) {
|
||||
$result[$f] = sanitize_text_field((string) ($stored[$f] ?? ''));
|
||||
}
|
||||
return $this->success(['data' => $result]);
|
||||
}
|
||||
|
||||
// POST
|
||||
$params = $this->get_json_params($request);
|
||||
$clean = [];
|
||||
foreach ($fields as $f) {
|
||||
$clean[$f] = wp_kses_post((string) ($params[$f] ?? ''));
|
||||
}
|
||||
update_option($option_key, $clean);
|
||||
|
||||
return $this->success(['message' => __('邮件模板已保存', 'aksrt')]);
|
||||
}
|
||||
|
||||
public function rest_handle_credit_settings(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if ($request->get_method() === 'GET') {
|
||||
$config = \AKSRT\Settings\CreditsRuntime::get_credit_settings();
|
||||
|
||||
@@ -191,17 +191,33 @@ trait UserControllerAuthPhoneOperations {
|
||||
}
|
||||
|
||||
$code = (string) random_int(100000, 999999);
|
||||
$this->auth_store_email_code($scene, $target, $code);
|
||||
$subject = sprintf(__('[%s] 登录注册验证码', 'aksrt'), get_bloginfo('name'));
|
||||
$message = sprintf(__('您的验证码是:%s,10 分钟内有效。', 'aksrt'), $code);
|
||||
if (!wp_mail($target, $subject, $message)) {
|
||||
$scene_event = [
|
||||
'login' => 'login_code',
|
||||
'register' => 'reg_verify_code',
|
||||
'resetpassword' => 'password_reset',
|
||||
][$scene] ?? '';
|
||||
$user = $this->auth_find_user($target, $channel);
|
||||
$display_name = $user instanceof \WP_User ? $user->display_name : $target;
|
||||
$site_name = get_bloginfo('name');
|
||||
$placeholders = [
|
||||
'{username}' => $display_name,
|
||||
'{site_name}' => $site_name,
|
||||
'{code}' => $code,
|
||||
];
|
||||
$sent = $scene_event !== '' && class_exists('\\AKSRT\\Settings\\Settings')
|
||||
? \AKSRT\Settings\Settings::send_event_email($scene_event, $target, '', '', $placeholders)
|
||||
|
||||
: false;
|
||||
if (!$sent) {
|
||||
delete_transient($this->auth_code_key($scene, $target));
|
||||
if ($scene !== 'register') {
|
||||
return $this->success(['message' => $generic, 'countdown' => 60]);
|
||||
}
|
||||
return $this->error(__('验证码发送失败,请稍后再试', 'aksrt'), 500);
|
||||
}
|
||||
$this->auth_store_email_code($scene, $target, $code);
|
||||
return $this->success(['message' => $generic, 'countdown' => 60]);
|
||||
|
||||
}
|
||||
|
||||
public function auth_login(\WP_REST_Request $request): \WP_REST_Response {
|
||||
|
||||
@@ -131,10 +131,18 @@ trait ServicePart1a {
|
||||
}
|
||||
$ref_notice = $ref_service ? $ref_service->take_registration_notice() : '';
|
||||
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return ['success' => false, 'error' => __('注册成功,但读取用户信息失败,请联系管理员', 'aksrt')];
|
||||
}
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || is_wp_error($user)) {
|
||||
return ['success' => false, 'error' => __('注册成功,但读取用户信息失败,请联系管理员', 'aksrt')];
|
||||
}
|
||||
if ($user instanceof \WP_User && is_email($user->user_email)) {
|
||||
\AKSRT\Settings\Mail::send_event_email('reg_success', $user->user_email, '', '', [
|
||||
'{username}' => $user->display_name,
|
||||
'{site_name}' => get_bloginfo('name'),
|
||||
'{url}' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
!$defer_login
|
||||
|
||||
@@ -168,9 +168,11 @@ trait ServicePart1b {
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
update_user_meta($user_id, 'aksrt_phone', $phone);
|
||||
update_user_meta($user_id, 'aksrt_phone_verified', '1');
|
||||
update_user_meta($user_id, 'aksrt_phone_verified', '1');
|
||||
do_action('aksrt_user_phone_changed', $user_id, $phone);
|
||||
|
||||
return [
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => __('手机号绑定成功', 'aksrt'),
|
||||
];
|
||||
|
||||
@@ -78,6 +78,7 @@ final class ApplicationService {
|
||||
}
|
||||
$public_name = $type === 'enterprise' ? $this->truncate($company_name, 64) : __('个人认证', 'aksrt');
|
||||
|
||||
$is_new_application = !$existing;
|
||||
if ($existing) {
|
||||
$application = $existing;
|
||||
} else {
|
||||
@@ -110,9 +111,12 @@ final class ApplicationService {
|
||||
'attempt_ordinal' => $attempt + 1,
|
||||
]);
|
||||
}
|
||||
if (is_wp_error($application)) {
|
||||
return $application;
|
||||
}
|
||||
if (is_wp_error($application)) {
|
||||
return $application;
|
||||
}
|
||||
if ($is_new_application) {
|
||||
do_action('aksrt_user_auth_submitted', (int) $application['user_id'], $application);
|
||||
}
|
||||
|
||||
$provider = ProviderFactory::make((string) $application['provider']);
|
||||
$valid = $provider->validate_config();
|
||||
|
||||
@@ -103,7 +103,9 @@ class Message {
|
||||
if ($inserted === false) return false;
|
||||
|
||||
// 触发通知钩子
|
||||
do_action('aksrt_notification_sent', $to_user_id, $type, $data);
|
||||
do_action('aksrt_notification_sent', $to_user_id, $type, array_merge($data, [
|
||||
'description' => $content,
|
||||
]));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,17 @@ class ReportCenter {
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%s', '%d', '%s', '%s', '%s', '%s']);
|
||||
|
||||
return $wpdb->insert_id ? (int) $wpdb->insert_id : 0;
|
||||
$id = $wpdb->insert_id ? (int) $wpdb->insert_id : 0;
|
||||
if ($id > 0) {
|
||||
do_action('aksrt_report_submitted', $id, [
|
||||
'reporter_id' => $reporter_id,
|
||||
'target_type' => $target_type,
|
||||
'target_id' => $target_id,
|
||||
'reason' => $reason,
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,7 +128,7 @@ class ReportCenter {
|
||||
global $wpdb;
|
||||
|
||||
$existing = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT id, notes_json FROM {$table} WHERE id = %d",
|
||||
"SELECT id, reporter_id, notes_json FROM {$table} WHERE id = %d",
|
||||
$report_id
|
||||
));
|
||||
|
||||
@@ -143,6 +153,9 @@ class ReportCenter {
|
||||
}
|
||||
|
||||
$result = $wpdb->update($table, $update_data, ['id' => $report_id], $format, ['%d']);
|
||||
if ($result !== false) {
|
||||
do_action('aksrt_report_status_changed', $report_id, (int) ($existing->reporter_id ?? 0), $new_status);
|
||||
}
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,8 +208,12 @@ class Withdrawal {
|
||||
$usage['remaining_count'] = max(0, $usage['remaining_count'] - 1);
|
||||
$usage['remaining_minor'] = max(0, $usage['remaining_minor'] - $gross_minor);
|
||||
}
|
||||
return [
|
||||
'withdrawal_id' => $withdrawal_id,
|
||||
if ($withdrawal_id > 0 && !$result->replayed) {
|
||||
do_action('aksrt_withdrawal_submitted', $user_id, $this->get($withdrawal_id) ?: []);
|
||||
}
|
||||
return [
|
||||
'withdrawal_id' => $withdrawal_id,
|
||||
|
||||
'withdrawal_number' => $withdrawal_number,
|
||||
'net_amount' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $net_minor)->decimal(),
|
||||
'wallet_balance' => \AKSRT\Ledger\AssetAmount::from_minor(\AKSRT\Ledger\AssetType::BALANCE, $result->balance($user_id, \AKSRT\Ledger\AssetType::BALANCE))->decimal(),
|
||||
@@ -452,11 +456,15 @@ class Withdrawal {
|
||||
$title = __('提现已完成', 'aksrt');
|
||||
$body = sprintf(__('您申请提现 %s 元已处理完成。', 'aksrt'), $amount_txt);
|
||||
}
|
||||
\AKSRT\Messages\Message::get_instance()->send($user_id, 'withdraw', $body, [
|
||||
'title' => $title,
|
||||
'target_url' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '',
|
||||
]);
|
||||
}
|
||||
\AKSRT\Messages\Message::get_instance()->send($user_id, 'withdraw', $body, [
|
||||
'title' => $title,
|
||||
'target_url' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '',
|
||||
]);
|
||||
}
|
||||
if (!$replayed && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
|
||||
do_action('aksrt_withdrawal_status_changed', $user_id, $row, $new_status, $note);
|
||||
}
|
||||
|
||||
|
||||
return [
|
||||
'transaction_id' => $ledger_result instanceof \AKSRT\Ledger\LedgerResult ? $ledger_result->transaction_id : 0,
|
||||
|
||||
@@ -365,16 +365,7 @@ trait FacadeBulk {
|
||||
|
||||
/** 获取SMTP配置 */
|
||||
public static function get_smtp(): array {
|
||||
return get_option('aksrt_mail_smtp', [
|
||||
'enabled' => false,
|
||||
'host' => '',
|
||||
'port' => 465,
|
||||
'username' => '',
|
||||
'password' => '',
|
||||
'encryption' => 'ssl',
|
||||
'from_name' => '',
|
||||
'from_address' => '',
|
||||
]);
|
||||
return Mail::get_smtp();
|
||||
}
|
||||
|
||||
|
||||
@@ -382,11 +373,12 @@ trait FacadeBulk {
|
||||
/** 获取邮件品牌设置(发件人昵称/标题前缀/底部内容) */
|
||||
public static function get_mail_branding(): array {
|
||||
$s = self::get_all();
|
||||
$site = (string) get_bloginfo('name');
|
||||
return [
|
||||
'showname' => trim((string) ($s['mail_showname'] ?? '')),
|
||||
'title_prefix' => trim((string) ($s['mail_title_prefix'] ?? '')),
|
||||
'showname' => trim((string) ($s['mail_showname'] ?? $site)) ?: $site,
|
||||
'title_prefix' => trim((string) ($s['mail_title_prefix'] ?? '[' . $site . ']')) ?: '[' . $site . ']',
|
||||
'description' => trim((string) ($s['mail_description'] ?? '')),
|
||||
'more_content' => trim((string) ($s['mail_more_content'] ?? '')),
|
||||
'more_content' => wp_kses_post((string) ($s['mail_more_content'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+116
-77
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* Canonical email settings and delivery service.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
@@ -7,112 +9,149 @@ namespace AKSRT\Settings;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Mail {
|
||||
final class Mail {
|
||||
/** @return list<string> */
|
||||
public static function events(): array {
|
||||
return array_keys(MailTemplates::definitions());
|
||||
}
|
||||
|
||||
/** 所有邮件事件类型常量 */
|
||||
public const EMAIL_EVENTS = [
|
||||
'reg_verify_link',
|
||||
'reg_verify_code',
|
||||
'reg_success',
|
||||
'password_reset',
|
||||
'change_code',
|
||||
'change_notice',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取邮件事件配置
|
||||
*
|
||||
* @param string $event 事件类型,留空返回全部
|
||||
* @return array
|
||||
*/
|
||||
public static function get_email_event_config(string $event = ''): array {
|
||||
/** @return array<string, mixed> */
|
||||
public static function get_smtp(): array {
|
||||
$s = Settings::get_all();
|
||||
|
||||
$global = [
|
||||
'footer_text' => sanitize_text_field((string) ($s['email_footer_text'] ?? '')),
|
||||
'site_branding' => !isset($s['email_site_branding']) || !empty($s['email_site_branding']),
|
||||
'template_mode' => in_array($s['email_template_mode'] ?? 'plain', ['plain', 'html'], true)
|
||||
? ($s['email_template_mode'] ?? 'plain') : 'plain',
|
||||
return [
|
||||
'enabled' => !empty($s['mail_smtps']),
|
||||
'host' => trim((string) ($s['mail_host'] ?? '')),
|
||||
'port' => max(1, min(65535, (int) ($s['mail_port'] ?? 465))),
|
||||
'username' => trim((string) ($s['mail_smtp_name'] ?? '')) ?: sanitize_email((string) ($s['mail_name'] ?? '')),
|
||||
'password' => (string) ($s['mail_passwd'] ?? ''),
|
||||
'encryption' => in_array(($s['mail_smtpsecure'] ?? 'ssl'), ['ssl', 'tls', ''], true) ? (string) ($s['mail_smtpsecure'] ?? 'ssl') : 'ssl',
|
||||
'from_name' => trim((string) ($s['mail_showname'] ?? '')) ?: get_bloginfo('name'),
|
||||
'from_address' => sanitize_email((string) ($s['mail_name'] ?? '')),
|
||||
'auth' => !empty($s['mail_smtpauth']),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function get_email_event_config(string $event = ''): array {
|
||||
$definitions = MailTemplates::definitions();
|
||||
$defaults = MailTemplates::defaults();
|
||||
$settings = Settings::get_all();
|
||||
$global = [
|
||||
'description' => (string) ($defaults['mail_description'] ?? ''),
|
||||
'more_content' => (string) ($defaults['mail_more_content'] ?? ''),
|
||||
];
|
||||
foreach (['mail_description', 'mail_more_content'] as $key) {
|
||||
if (array_key_exists($key, $settings) && trim((string) $settings[$key]) !== '') {
|
||||
$global[str_replace('mail_', '', $key)] = $key === 'mail_more_content'
|
||||
? wp_kses_post((string) $settings[$key])
|
||||
: sanitize_textarea_field((string) $settings[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($event === '') {
|
||||
$events = [];
|
||||
foreach (self::EMAIL_EVENTS as $ev) {
|
||||
$events[$ev] = self::build_email_event_entry($s, $ev);
|
||||
foreach (array_keys($definitions) as $key) {
|
||||
$events[$key] = self::event_config($key, $settings, $defaults);
|
||||
}
|
||||
return ['global' => $global, 'events' => $events];
|
||||
}
|
||||
|
||||
if (!in_array($event, self::EMAIL_EVENTS, true)) {
|
||||
return ['enabled' => true, 'subject' => '', 'content' => '', 'html' => false, 'global' => $global];
|
||||
if (!isset($definitions[$event])) {
|
||||
return ['enabled' => false, 'subject' => '', 'content' => '', 'placeholders' => [], 'global' => $global];
|
||||
}
|
||||
|
||||
return array_merge(self::build_email_event_entry($s, $event), ['global' => $global]);
|
||||
return array_merge(self::event_config($event, $settings, $defaults), ['global' => $global]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件事件是否启用
|
||||
*/
|
||||
public static function is_email_event_enabled(string $event): bool {
|
||||
if (!in_array($event, self::EMAIL_EVENTS, true)) {
|
||||
return true;
|
||||
$definitions = MailTemplates::definitions();
|
||||
if (!isset($definitions[$event])) {
|
||||
return false;
|
||||
}
|
||||
$s = Settings::get_all();
|
||||
return !isset($s["email_{$event}_enabled"]) || !empty($s["email_{$event}_enabled"]);
|
||||
$settings = Settings::get_all();
|
||||
return !array_key_exists("email_{$event}_enabled", $settings) || !empty($settings["email_{$event}_enabled"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过邮件事件体系发送邮件
|
||||
* Send a configured event. The optional defaults keep existing callers source-compatible.
|
||||
*/
|
||||
public static function send_event_email(
|
||||
string $event,
|
||||
string $to,
|
||||
string $default_subject,
|
||||
string $default_content,
|
||||
string $default_subject = '',
|
||||
string $default_content = '',
|
||||
array $placeholders = []
|
||||
): bool {
|
||||
if (!self::is_email_event_enabled($event)) {
|
||||
$to = sanitize_email($to);
|
||||
if ($to === '' || !is_email($to) || !isset(MailTemplates::definitions()[$event]) || !self::is_email_event_enabled($event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cfg = self::get_email_event_config($event);
|
||||
|
||||
$subject = ($cfg['subject'] !== '') ? $cfg['subject'] : $default_subject;
|
||||
$subject = strtr($subject, $placeholders);
|
||||
|
||||
$content = ($cfg['content'] !== '') ? $cfg['content'] : $default_content;
|
||||
$content = strtr($content, $placeholders);
|
||||
|
||||
$branding = Settings::get_mail_branding();
|
||||
$footer = $cfg['global']['footer_text'];
|
||||
if ($footer === '' && $branding['description'] !== '') {
|
||||
$footer = $branding['description'];
|
||||
}
|
||||
if ($footer === '') {
|
||||
$footer = get_bloginfo('name');
|
||||
}
|
||||
if ($footer !== '') {
|
||||
$content .= "\n\n— " . $footer;
|
||||
if ($branding['description'] !== '' && $footer !== $branding['description']) {
|
||||
$content .= "\n" . $branding['description'];
|
||||
}
|
||||
}
|
||||
|
||||
$is_html = $cfg['html'] || ($cfg['global']['template_mode'] === 'html');
|
||||
$headers = [$is_html ? 'Content-Type: text/html; charset=UTF-8' : 'Content-Type: text/plain; charset=UTF-8'];
|
||||
|
||||
return wp_mail($to, $subject, $content, $headers);
|
||||
$definition = MailTemplates::definitions()[$event];
|
||||
$subject = (string) ($cfg['subject'] ?? $default_subject ?: $definition['title']);
|
||||
$content = (string) ($cfg['content'] ?? $default_content ?: $definition['content']);
|
||||
$subject = self::replace_placeholders($subject, $placeholders, false);
|
||||
$content = self::replace_placeholders($content, $placeholders, false);
|
||||
$content = self::wrap_html($content, $cfg['global'] ?? []);
|
||||
$headers = ['Content-Type: text/html; charset=UTF-8'];
|
||||
return (bool) wp_mail($to, $subject, $content, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单个邮件事件条目
|
||||
*/
|
||||
private static function build_email_event_entry(array $s, string $event): array {
|
||||
public static function send_test_email(string $to): bool {
|
||||
$to = sanitize_email($to);
|
||||
if ($to === '' || !is_email($to)) return false;
|
||||
$site = esc_html((string) get_bloginfo('name'));
|
||||
$subject = '邮件配置测试';
|
||||
$content = "您好,\n\n这是一封来自 {$site} 的邮件配置测试。";
|
||||
$content = self::wrap_html($content, [
|
||||
'description' => '此信为系统邮件,请不要直接回复。',
|
||||
'more_content' => '',
|
||||
]);
|
||||
return (bool) wp_mail($to, $subject, $content, ['Content-Type: text/html; charset=UTF-8']);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function event_config(string $event, array $settings, array $defaults): array {
|
||||
$definition = MailTemplates::definitions()[$event];
|
||||
$subject_key = "email_{$event}_subject";
|
||||
$content_key = "email_{$event}_content";
|
||||
return [
|
||||
'enabled' => !isset($s["email_{$event}_enabled"]) || !empty($s["email_{$event}_enabled"]),
|
||||
'subject' => sanitize_text_field((string) ($s["email_{$event}_subject"] ?? '')),
|
||||
'content' => sanitize_textarea_field((string) ($s["email_{$event}_content"] ?? '')),
|
||||
'html' => !empty($s["email_{$event}_html"]),
|
||||
'enabled' => !array_key_exists("email_{$event}_enabled", $settings) || !empty($settings["email_{$event}_enabled"]),
|
||||
'subject' => trim((string) ($settings[$subject_key] ?? '')) ?: (string) ($defaults[$subject_key] ?? $definition['title']),
|
||||
'content' => (string) ($settings[$content_key] ?? '') !== '' ? (string) $settings[$content_key] : (string) ($defaults[$content_key] ?? $definition['content']),
|
||||
'placeholders' => $definition['placeholders'],
|
||||
];
|
||||
}
|
||||
|
||||
private static function replace_placeholders(string $value, array $placeholders, bool $html): string {
|
||||
$replace = [];
|
||||
foreach ($placeholders as $key => $replacement) {
|
||||
$replacement = (string) $replacement;
|
||||
if ($html) {
|
||||
$replacement = str_contains($key, 'url') ? esc_url($replacement) : esc_html($replacement);
|
||||
} else {
|
||||
$replacement = wp_strip_all_tags($replacement);
|
||||
}
|
||||
$replace[$key] = $replacement;
|
||||
}
|
||||
return strtr($value, $replace);
|
||||
}
|
||||
|
||||
private static function wrap_html(string $content, array $global): string {
|
||||
$logo = method_exists(Settings::class, 'logo_url') ? Settings::logo_url() : '';
|
||||
$background = esc_url(AKSRT_URI . '/assets/img/mail-bg.png');
|
||||
$body = preg_match('/<[^>]+>/', $content) ? wp_kses_post($content) : nl2br(esc_html($content));
|
||||
$description = esc_html((string) ($global['description'] ?? ''));
|
||||
$more = wp_kses_post((string) ($global['more_content'] ?? ''));
|
||||
$site = esc_html((string) get_bloginfo('name'));
|
||||
$logo_html = $logo !== '' ? '<img src="' . esc_url($logo) . '" alt="' . $site . '" style="max-width:220px;height:auto;">' : '<strong style="font-size:24px;">' . $site . '</strong>';
|
||||
return '<div style="background:#ecf1f3;padding:2%;font-family:Arial,sans-serif;color:#55798d;line-height:1.7;">'
|
||||
. '<div style="max-width:701px;margin:0 auto;background:#fff;box-shadow:0 3px 12px rgba(0,0,0,.08);">'
|
||||
. '<div style="padding:42px 10%;text-align:center;">' . $logo_html . '</div>'
|
||||
. '<div style="padding:0 10% 42px;overflow-wrap:break-word;">' . $body . '</div>'
|
||||
. '<div style="border-top:2px dashed rgba(125,136,140,.15);padding:34px 10% 0;">'
|
||||
. '<strong style="font-size:20px;">' . $site . '</strong>'
|
||||
. '<div style="margin:16px 0;white-space:pre-wrap;">' . $description . '</div>'
|
||||
. '<div>' . $more . '</div></div>'
|
||||
. '<img src="' . $background . '" alt="" style="display:block;width:100%;height:auto;margin-top:26px;">'
|
||||
. '</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
/**
|
||||
* Registers all non-forum email notifications.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Settings;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class MailNotifications {
|
||||
public static function init(): void {
|
||||
add_action('aksrt_order_paid', [self::class, 'order_paid'], 20, 2);
|
||||
add_action('aksrt_order_status_changed', [self::class, 'order_status'], 20, 3);
|
||||
add_action('aksrt_asset_transaction_committed', [self::class, 'asset_transaction'], 20, 2);
|
||||
add_action('aksrt_notification_sent', [self::class, 'notification'], 20, 3);
|
||||
add_action('aksrt_withdrawal_submitted', [self::class, 'withdrawal_submitted'], 20, 2);
|
||||
add_action('aksrt_withdrawal_status_changed', [self::class, 'withdrawal_status'], 20, 4);
|
||||
add_action('aksrt_user_auth_submitted', [self::class, 'auth_submitted'], 20, 2);
|
||||
add_action('aksrt_user_auth_reviewed', [self::class, 'auth_reviewed'], 20, 4);
|
||||
add_action('aksrt_ban_appeal_submitted', [self::class, 'ban_appeal_submitted'], 20, 2);
|
||||
add_action('aksrt_ban_appeal_processed', [self::class, 'ban_appeal_processed'], 20, 3);
|
||||
add_action('aksrt_new_posts_pending', [self::class, 'post_pending'], 20, 1);
|
||||
add_action('transition_post_status', [self::class, 'post_status_changed'], 20, 3);
|
||||
add_action('aksrt_report_submitted', [self::class, 'report_submitted'], 20, 2);
|
||||
add_action('aksrt_report_status_changed', [self::class, 'report_status_changed'], 20, 3);
|
||||
add_action('aksrt_friend_link_submitted', [self::class, 'friend_link_submitted'], 20, 1);
|
||||
add_action('aksrt_user_phone_changed', [self::class, 'phone_changed'], 20, 2);
|
||||
add_action('aksrt_user_restriction_changed', [self::class, 'restriction_changed'], 20, 4);
|
||||
}
|
||||
|
||||
public static function restriction_changed(int $user_id, string $type, string $action, string $reason): void {
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user instanceof \WP_User) return;
|
||||
self::send('user_ban', $user->user_email, [
|
||||
'username' => $user->display_name,
|
||||
'status' => $action === 'removed' ? __('限制已解除', 'aksrt') : $type,
|
||||
'note' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function phone_changed(int $user_id, string $phone): void {
|
||||
$user = get_userdata($user_id);
|
||||
if ($user instanceof \WP_User) self::send('phone_changed', $user->user_email, ['username' => $user->display_name, 'site_name' => get_bloginfo('name')]);
|
||||
}
|
||||
|
||||
public static function withdrawal_submitted(int $user_id, array $row): void {
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user instanceof \WP_User) return;
|
||||
$vars = [
|
||||
'username' => $user->display_name,
|
||||
'withdrawal_number' => (string) ($row['withdrawal_number'] ?? ''),
|
||||
'amount' => (string) ($row['gross_amount'] ?? $row['amount'] ?? ''),
|
||||
'method' => (string) ($row['method'] ?? ''),
|
||||
];
|
||||
self::send('withdraw_submitted', $user->user_email, $vars);
|
||||
self::send_admin('withdraw_submitted_admin', $vars);
|
||||
}
|
||||
|
||||
public static function withdrawal_status(int $user_id, array $row, string $status, string $note): void {
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user instanceof \WP_User) return;
|
||||
self::send('withdraw_completed', $user->user_email, [
|
||||
'username' => $user->display_name,
|
||||
'withdrawal_number' => (string) ($row['withdrawal_number'] ?? ''),
|
||||
'status' => $status,
|
||||
'amount' => (string) ($row['gross_amount'] ?? $row['amount'] ?? ''),
|
||||
'note' => $note,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function order_paid(string $order_number, array $order): void {
|
||||
$user = get_userdata((int) ($order['user_id'] ?? 0));
|
||||
if (!$user instanceof \WP_User) return;
|
||||
$vars = self::order_vars($order_number, $order, $user);
|
||||
self::send('order_paid', $user->user_email, $vars);
|
||||
self::send_admin('order_paid_admin', $vars);
|
||||
}
|
||||
|
||||
|
||||
public static function order_status(string $order_number, string $status, array $order): void {
|
||||
if ($status === 'paid') return;
|
||||
$user = get_userdata((int) ($order['user_id'] ?? 0));
|
||||
if (!$user instanceof \WP_User) return;
|
||||
self::send('order_status', $user->user_email, self::order_vars($order_number, $order, $user) + ['status' => $status]);
|
||||
}
|
||||
|
||||
public static function asset_transaction($result, $command): void {
|
||||
if (!$result instanceof \AKSRT\Ledger\LedgerResult || !$command instanceof \AKSRT\Ledger\LedgerCommand || $result->replayed) return;
|
||||
$reason = $command->reason;
|
||||
if (!in_array($reason, ['affiliate_commission', 'creator_income', 'transfer', 'tip', 'referral'], true)) return;
|
||||
foreach ($result->entries as $entry) {
|
||||
if ((int) ($entry['delta_minor'] ?? 0) <= 0) continue;
|
||||
$user = get_userdata((int) ($entry['user_id'] ?? 0));
|
||||
if (!$user instanceof \WP_User) continue;
|
||||
$amount = self::minor_amount((string) ($entry['asset'] ?? 'balance'), (int) $entry['delta_minor']);
|
||||
$url = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : home_url('/');
|
||||
if ($reason === 'affiliate_commission') {
|
||||
self::send('affiliate_commission', $user->user_email, ['username' => $user->display_name, 'amount' => $amount, 'order_number' => $command->ref_key, 'url' => $url]);
|
||||
} elseif ($reason === 'creator_income') {
|
||||
self::send('creator_income', $user->user_email, ['username' => $user->display_name, 'amount' => $amount, 'order_number' => $command->ref_key, 'url' => $url]);
|
||||
} elseif ($reason === 'referral') {
|
||||
self::send('referral_reward', $user->user_email, ['username' => $user->display_name, 'amount' => $amount, 'url' => $url]);
|
||||
} elseif ($reason === 'transfer' && (string) ($entry['entry_type'] ?? '') === 'transfer_in') {
|
||||
$sender = get_userdata((int) ($entry['counterparty_user_id'] ?? 0));
|
||||
self::send('transfer_received', $user->user_email, [
|
||||
'username' => $user->display_name,
|
||||
'sender' => $sender instanceof \WP_User ? $sender->display_name : __('其他用户', 'aksrt'),
|
||||
'asset' => ((string) ($entry['asset'] ?? '') === 'points') ? __('积分', 'aksrt') : __('余额', 'aksrt'),
|
||||
'amount' => $amount,
|
||||
'note' => (string) ($entry['remark'] ?? ''),
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function notification(int $user_id, string $type, array $data): void {
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user instanceof \WP_User) return;
|
||||
$url = (string) ($data['target_url'] ?? $data['url'] ?? home_url('/'));
|
||||
$title = (string) ($data['title'] ?? '');
|
||||
$content = (string) ($data['description'] ?? $data['desc'] ?? '');
|
||||
$map = [
|
||||
'comment_reply' => 'comment_reply',
|
||||
'comment_approved' => 'comment_approved',
|
||||
];
|
||||
if (!isset($map[$type])) return;
|
||||
$event = $map[$type];
|
||||
$vars = ['username' => $user->display_name, 'site_name' => get_bloginfo('name'), 'title' => $title, 'content' => $content, 'status' => $title, 'note' => $content, 'url' => $url, 'amount' => '', 'withdrawal_number' => ''];
|
||||
self::send($event, $user->user_email, $vars);
|
||||
}
|
||||
|
||||
public static function auth_submitted(int $user_id, array $application): void {
|
||||
$user = get_userdata($user_id);
|
||||
self::send_admin('auth_apply_admin', [
|
||||
'username' => $user instanceof \WP_User ? $user->display_name : (string) ($application['public_name'] ?? __('用户', 'aksrt')),
|
||||
'url' => admin_url('users.php?page=aksrt_users_verify'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function auth_reviewed(int $user_id, string $status, string $note, int $reviewer_id): void {
|
||||
$user = get_userdata($user_id);
|
||||
if ($user instanceof \WP_User) self::send('auth_apply_result', $user->user_email, ['username' => $user->display_name, 'status' => $status, 'note' => $note, 'url' => function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('auth') : home_url('/')]);
|
||||
}
|
||||
|
||||
public static function ban_appeal_submitted(int $user_id, array $payload): void {
|
||||
$user = get_userdata($user_id);
|
||||
if ($user instanceof \WP_User) self::send_admin('ban_appeal_admin', ['username' => $user->display_name, 'url' => admin_url('users.php?page=aksrt_user_ban&tab=appeals')]);
|
||||
}
|
||||
|
||||
public static function ban_appeal_processed(int $user_id, string $decision, string $note): void {
|
||||
$user = get_userdata($user_id);
|
||||
if ($user instanceof \WP_User) self::send('user_ban', $user->user_email, ['username' => $user->display_name, 'status' => $decision, 'note' => $note]);
|
||||
}
|
||||
|
||||
public static function post_pending($post): void {
|
||||
if (!$post instanceof \WP_Post) return;
|
||||
$author = get_userdata((int) $post->post_author);
|
||||
if ($author instanceof \WP_User) self::send_admin('post_pending_admin', ['username' => $author->display_name, 'title' => get_the_title($post), 'url' => get_edit_post_link($post->ID, '') ?: admin_url('edit.php')]);
|
||||
}
|
||||
|
||||
public static function post_status_changed(string $new_status, string $old_status, \WP_Post $post): void {
|
||||
if ($old_status === $new_status || !in_array($new_status, ['publish', 'draft', 'pending'], true)) return;
|
||||
$author = get_userdata((int) $post->post_author);
|
||||
if ($author instanceof \WP_User) {
|
||||
self::send('post_result', $author->user_email, [
|
||||
'username' => $author->display_name,
|
||||
'title' => get_the_title($post),
|
||||
'status' => $new_status,
|
||||
'url' => get_permalink($post),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function report_submitted(int $report_id, array $report): void {
|
||||
$reporter = get_userdata((int) ($report['reporter_id'] ?? 0));
|
||||
self::send_admin('report_admin', ['username' => $reporter instanceof \WP_User ? $reporter->display_name : __('用户', 'aksrt'), 'title' => (string) ($report['target_type'] ?? ''), 'url' => admin_url('users.php?page=aksrt_reports')]);
|
||||
}
|
||||
|
||||
public static function report_status_changed(int $report_id, int $reporter_id, string $status): void {
|
||||
$reporter = get_userdata($reporter_id);
|
||||
if ($reporter instanceof \WP_User) self::send('report_result', $reporter->user_email, ['username' => $reporter->display_name, 'status' => $status, 'note' => '', 'url' => home_url('/')]);
|
||||
}
|
||||
|
||||
public static function friend_link_submitted(array $link): void {
|
||||
self::send_admin('links_submit_admin', ['title' => (string) ($link['name'] ?? ''), 'username' => __('访客', 'aksrt'), 'url' => (string) ($link['url'] ?? '')]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $vars */
|
||||
private static function send(string $event, string $to, array $vars): void {
|
||||
$placeholders = [];
|
||||
foreach ($vars as $key => $value) $placeholders['{' . $key . '}'] = (string) $value;
|
||||
Mail::send_event_email($event, $to, '', '', $placeholders);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $vars */
|
||||
private static function send_admin(string $event, array $vars): void {
|
||||
foreach (self::admin_emails() as $email) self::send($event, $email, $vars);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function admin_emails(): array {
|
||||
$emails = [];
|
||||
foreach (get_users(['role' => 'administrator', 'fields' => ['user_email'], 'number' => 100]) as $user) {
|
||||
$email = sanitize_email((string) ($user->user_email ?? ''));
|
||||
if ($email !== '' && is_email($email)) $emails[$email] = true;
|
||||
}
|
||||
return array_keys($emails);
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private static function order_vars(string $order_number, array $order, \WP_User $user): array {
|
||||
$product = (string) ($order['product_type'] ?? 'order');
|
||||
return ['username' => $user->display_name, 'order_number' => $order_number, 'product' => $product, 'amount' => (string) ($order['amount'] ?? ''), 'currency' => (string) ($order['currency'] ?? 'CNY'), 'payment_method' => (string) ($order['payment_method'] ?? ''), 'url' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('orders') : home_url('/')];
|
||||
}
|
||||
|
||||
private static function minor_amount(string $asset, int $minor): string {
|
||||
if ($asset === 'points') return (string) $minor;
|
||||
return number_format($minor / 100, 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
/**
|
||||
* Canonical email event catalog and admin schema.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Settings;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class MailTemplates {
|
||||
/** @return array<string, array<string, mixed>> */
|
||||
public static function definitions(): array {
|
||||
return [
|
||||
'links_submit_admin' => self::event('友情链接提交通知', '新友情链接提交:{title}\n提交人:{username}\n链接:{url}\n请登录后台审核。', ['title', 'username', 'url']),
|
||||
'order_paid_admin' => self::event('新订单通知(管理员)', '收到一笔新订单。\n订单号:{order_number}\n用户:{username}\n商品:{product}\n金额:{amount} {currency}\n支付方式:{payment_method}', ['order_number', 'username', 'product', 'amount', 'currency', 'payment_method']),
|
||||
'withdraw_submitted_admin' => self::event('提现申请通知(管理员)', '用户 {username} 提交了提现申请。\n提现单号:{withdrawal_number}\n金额:{amount}\n方式:{method}', ['username', 'withdrawal_number', 'amount', 'method']),
|
||||
'post_pending_admin' => self::event('投稿待审核通知', '用户 {username} 提交了待审核内容:{title}\n查看地址:{url}', ['username', 'title', 'url']),
|
||||
'auth_apply_admin' => self::event('身份认证申请通知', '用户 {username} 提交了身份认证申请,请登录后台审核。\n查看地址:{url}', ['username', 'url']),
|
||||
'ban_appeal_admin' => self::event('账号申诉通知', '用户 {username} 提交了账号申诉。\n查看地址:{url}', ['username', 'url']),
|
||||
'report_admin' => self::event('举报提交通知', '收到新的举报。\n举报人:{username}\n对象:{title}\n查看地址:{url}', ['username', 'title', 'url']),
|
||||
|
||||
'reg_verify_code' => self::event('注册验证码', '您好 {username},\n\n您正在注册 {site_name},验证码为:\n\n【{code}】\n\n验证码 10 分钟内有效。如非本人操作,请忽略此邮件。', ['username', 'site_name', 'code']),
|
||||
'reg_success' => self::event('注册成功', '您好 {username},\n\n欢迎加入 {site_name}!您的账号已注册成功。\n\n登录地址:{url}', ['username', 'site_name', 'url']),
|
||||
'login_code' => self::event('登录验证码', '您好 {username},\n\n您正在登录 {site_name},验证码为:\n\n【{code}】\n\n验证码 10 分钟内有效。如非本人操作,请立即修改密码。', ['username', 'site_name', 'code']),
|
||||
'password_reset' => self::event('密码重置验证码', '您好 {username},\n\n您正在重置 {site_name} 的账号密码,验证码为:\n\n【{code}】\n\n验证码 10 分钟内有效。如非本人操作,请忽略此邮件。', ['username', 'site_name', 'code']),
|
||||
'change_code' => self::event('敏感操作验证码', '您好 {username},\n\n您正在进行敏感操作,验证码为:\n\n【{code}】\n\n验证码 5 分钟内有效。如非本人操作,请忽略此邮件。', ['username', 'site_name', 'code']),
|
||||
'change_notice' => self::event('账号邮箱已变更', '您好 {username},\n\n您的账号邮箱已成功修改为:{new_email}\n\n如果这不是您的操作,请立即联系站点管理员。', ['username', 'site_name', 'new_email']),
|
||||
'order_paid' => self::event('订单支付成功', '您好 {username},\n\n您的订单已支付成功。\n订单号:{order_number}\n商品:{product}\n金额:{amount} {currency}\n\n查看订单:{url}', ['username', 'order_number', 'product', 'amount', 'currency', 'url']),
|
||||
'order_status' => self::event('订单状态更新', '您好 {username},\n\n您的订单 {order_number} 状态已更新为:{status}。\n商品:{product}\n查看详情:{url}', ['username', 'order_number', 'status', 'product', 'url']),
|
||||
'withdraw_submitted' => self::event('提现申请已提交', '您好 {username},\n\n您的提现申请已提交。\n提现单号:{withdrawal_number}\n金额:{amount}\n方式:{method}', ['username', 'withdrawal_number', 'amount', 'method']),
|
||||
'withdraw_completed' => self::event('提现状态更新', '您好 {username},\n\n您的提现申请 {withdrawal_number} 当前状态为:{status}。\n金额:{amount}\n{note}', ['username', 'withdrawal_number', 'status', 'amount', 'note']),
|
||||
'affiliate_commission' => self::event('返佣到账通知', '您好 {username},\n\n您的推广返佣已到账。\n金额:{amount}\n来源订单:{order_number}\n查看收益:{url}', ['username', 'amount', 'order_number', 'url']),
|
||||
'creator_income' => self::event('创作分成到账通知', '您好 {username},\n\n您的创作分成已到账。\n金额:{amount}\n来源订单:{order_number}\n查看收益:{url}', ['username', 'amount', 'order_number', 'url']),
|
||||
'transfer_received' => self::event('收到转账通知', '您好 {username},\n\n您收到来自 {sender} 的{asset}转账:{amount}\n备注:{note}\n查看钱包:{url}', ['username', 'sender', 'asset', 'amount', 'note', 'url']),
|
||||
'referral_reward' => self::event('推广奖励到账通知', '您好 {username},\n\n您获得了一笔推广奖励:{amount}\n查看收益:{url}', ['username', 'amount', 'url']),
|
||||
'comment_approved' => self::event('评论已通过审核', '您好 {username},\n\n您在《{title}》下发布的评论已通过审核。\n查看评论:{url}', ['username', 'title', 'url']),
|
||||
'comment_reply' => self::event('评论回复通知', '您好 {username},\n\n您的评论收到了新的回复。\n文章:{title}\n回复内容:{content}\n查看评论:{url}', ['username', 'title', 'content', 'url']),
|
||||
'post_result' => self::event('投稿审核结果', '您好 {username},\n\n您的内容《{title}》审核结果:{status}。\n查看内容:{url}', ['username', 'title', 'status', 'url']),
|
||||
'phone_changed' => self::event('手机号变更通知', '您好 {username},\n\n您的账号绑定手机号已变更。如非本人操作,请立即联系站点管理员。', ['username', 'site_name']),
|
||||
'auth_apply_result' => self::event('身份认证审核结果', '您好 {username},\n\n您的身份认证申请处理结果:{status}。\n备注:{note}\n查看账号:{url}', ['username', 'status', 'note', 'url']),
|
||||
'user_ban' => self::event('账号状态通知', '您好 {username},\n\n您的账号状态已更新为:{status}。\n说明:{note}', ['username', 'status', 'note']),
|
||||
'report_result' => self::event('举报处理结果', '您好 {username},\n\n您提交的举报已有处理结果:{status}。\n说明:{note}\n查看详情:{url}', ['username', 'status', 'note', 'url']),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function event(string $title, string $content, array $placeholders, bool $enabled = true): array {
|
||||
$content = str_replace('\\n', "\n", $content);
|
||||
return compact('title', 'content', 'placeholders', 'enabled');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function defaults(): array {
|
||||
$site = (string) get_bloginfo('name');
|
||||
$definitions = self::definitions();
|
||||
$defaults = [
|
||||
'mail_smtps' => false,
|
||||
'mail_name' => '',
|
||||
'mail_passwd' => '',
|
||||
'mail_smtp_name' => '',
|
||||
'mail_host' => '',
|
||||
'mail_port' => 465,
|
||||
'mail_smtpauth' => true,
|
||||
'mail_smtpsecure' => 'ssl',
|
||||
'mail_showname' => $site,
|
||||
'mail_title_prefix' => '[' . $site . ']',
|
||||
'mail_description' => '此信为系统邮件,请不要直接回复。',
|
||||
'mail_more_content' => sprintf('<a href="%s">访问网站</a> | <a href="%s">登录账号</a>', esc_url(home_url('/')), esc_url(wp_login_url())),
|
||||
];
|
||||
foreach ($definitions as $event => $definition) {
|
||||
$defaults["email_{$event}_enabled"] = (bool) $definition['enabled'];
|
||||
$defaults["email_{$event}_subject"] = $definition['title'];
|
||||
$defaults["email_{$event}_content"] = $definition['content'];
|
||||
}
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function build_schema(): array {
|
||||
$defaults = self::defaults();
|
||||
$definitions = self::definitions();
|
||||
$groups = [
|
||||
['key' => 'smtp', 'title' => '邮件 SMTP', 'description' => 'WordPress 邮件由此配置发送;不要与其它 SMTP 插件同时启用。', 'fields' => [
|
||||
['key' => 'mail_smtps', 'label' => '启用 SMTP', 'type' => 'switch', 'default' => $defaults['mail_smtps']],
|
||||
['key' => 'mail_name', 'label' => '发信人邮箱账号', 'type' => 'input', 'default' => $defaults['mail_name'], 'dependsOn' => ['key' => 'mail_smtps', 'value' => true]],
|
||||
['key' => 'mail_passwd', 'label' => 'SMTP 服务邮箱密码', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => '', 'dependsOn' => ['key' => 'mail_smtps', 'value' => true]],
|
||||
['key' => 'mail_smtp_name', 'label' => 'SMTP 用户名', 'type' => 'input', 'default' => $defaults['mail_smtp_name'], 'dependsOn' => ['key' => 'mail_smtps', 'value' => true]],
|
||||
['key' => 'mail_host', 'label' => '邮件服务器地址', 'type' => 'input', 'default' => $defaults['mail_host'], 'dependsOn' => ['key' => 'mail_smtps', 'value' => true]],
|
||||
['key' => 'mail_port', 'label' => 'SMTP 服务器端口', 'type' => 'number', 'default' => $defaults['mail_port'], 'min' => 1, 'max' => 65535, 'dependsOn' => ['key' => 'mail_smtps', 'value' => true]],
|
||||
['key' => 'mail_smtpauth', 'label' => '启用 SMTPAuth', 'type' => 'switch', 'default' => $defaults['mail_smtpauth'], 'dependsOn' => ['key' => 'mail_smtps', 'value' => true]],
|
||||
['key' => 'mail_smtpsecure', 'label' => '加密方式', 'type' => 'select', 'default' => $defaults['mail_smtpsecure'], 'options' => [['label' => 'SSL', 'value' => 'ssl'], ['label' => 'TLS', 'value' => 'tls'], ['label' => '不加密', 'value' => '']], 'dependsOn' => ['key' => 'mail_smtps', 'value' => true]],
|
||||
]],
|
||||
['key' => 'branding', 'title' => '邮件品牌', 'fields' => [
|
||||
['key' => 'mail_showname', 'label' => '发件人名称', 'type' => 'input', 'default' => $defaults['mail_showname']],
|
||||
['key' => 'mail_title_prefix', 'label' => '主题前缀', 'type' => 'input', 'default' => $defaults['mail_title_prefix']],
|
||||
['key' => 'mail_description', 'label' => '邮件底部说明', 'type' => 'textarea', 'rows' => 3, 'default' => $defaults['mail_description']],
|
||||
['key' => 'mail_more_content', 'label' => '邮件附加内容(支持基础 HTML)', 'type' => 'textarea', 'rows' => 3, 'sanitize_type' => 'html', 'default' => $defaults['mail_more_content']],
|
||||
]],
|
||||
];
|
||||
$admin = ['links_submit_admin', 'order_paid_admin', 'withdraw_submitted_admin', 'post_pending_admin', 'auth_apply_admin', 'ban_appeal_admin', 'report_admin'];
|
||||
$user = array_values(array_diff(array_keys($definitions), $admin));
|
||||
$groups[] = self::event_group('admin', '管理员邮件推送', $admin, $definitions, $defaults);
|
||||
$groups[] = self::event_group('user', '用户邮件推送', $user, $definitions, $defaults);
|
||||
$groups[] = ['key' => 'test', 'title' => '测试邮件', 'description' => '保存 SMTP 配置后再发送测试邮件。', 'fields' => [
|
||||
['key' => 'email_test_to', 'label' => '测试收件地址', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'email_test_send', 'label' => '发送测试邮件', 'type' => 'button', 'buttonType' => 'primary', 'buttonText' => '发送测试邮件', 'ui_only' => true],
|
||||
]];
|
||||
return ['key' => 'email', 'title' => '邮件事件与 SMTP', 'version' => '3.0.0', 'option_key' => 'aksrt_settings', 'groups' => $groups];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private static function event_group(string $key, string $title, array $events, array $definitions, array $defaults): array {
|
||||
$fields = [];
|
||||
foreach ($events as $event) {
|
||||
$definition = $definitions[$event];
|
||||
$fields[] = ['key' => "email_{$event}_enabled", 'label' => "{$definition['title']}:启用", 'type' => 'switch', 'default' => $defaults["email_{$event}_enabled"]];
|
||||
$fields[] = ['key' => "email_{$event}_subject", 'label' => "{$definition['title']}:主题模板", 'type' => 'input', 'default' => $defaults["email_{$event}_subject"]];
|
||||
$fields[] = ['key' => "email_{$event}_content", 'label' => "{$definition['title']}:正文模板", 'type' => 'textarea', 'rows' => 6, 'sanitize_type' => 'html', 'default' => $defaults["email_{$event}_content"], 'help' => '可用占位符:' . implode('、', array_map(static fn(string $p): string => '{' . $p . '}', $definition['placeholders']))];
|
||||
}
|
||||
return ['key' => $key, 'title' => $title, 'fields' => $fields];
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ class Settings {
|
||||
* 用于导入导出完整性检查。
|
||||
*/
|
||||
public const STANDALONE_OPTION_KEYS = [
|
||||
'aksrt_mail_smtp',
|
||||
'aksrt_captcha_config',
|
||||
'aksrt_moderation_config',
|
||||
'aksrt_download_config',
|
||||
@@ -55,7 +54,6 @@ class Settings {
|
||||
* 包含 secret 字段的 option key — 永不进入独立导入导出。
|
||||
*/
|
||||
public const SECRET_OPTION_KEYS = [
|
||||
'aksrt_mail_smtp',
|
||||
'aksrt_captcha_config',
|
||||
'aksrt_identity_secrets',
|
||||
];
|
||||
@@ -617,6 +615,9 @@ class Settings {
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function redact_sensitive_settings(array $payload): array {
|
||||
if (isset($payload['domains']['global']['data']) && is_array($payload['domains']['global']['data'])) {
|
||||
unset($payload['domains']['global']['data']['mail_passwd']);
|
||||
}
|
||||
if (isset($payload['standalone']) && is_array($payload['standalone'])) {
|
||||
$blocked = array_merge(self::SCHEMA_MANAGED_STANDALONE_OPTION_KEYS, self::SECRET_OPTION_KEYS);
|
||||
foreach ($blocked as $option_key) {
|
||||
|
||||
@@ -77,6 +77,7 @@ class Restriction {
|
||||
|
||||
// Update user meta for quick lookup
|
||||
$this->sync_user_restriction_meta($user_id);
|
||||
do_action('aksrt_user_restriction_changed', $user_id, $type, 'applied', (string) $data['reason']);
|
||||
|
||||
return ['success' => true, 'id' => $wpdb->insert_id];
|
||||
}
|
||||
@@ -92,6 +93,7 @@ class Restriction {
|
||||
$result = $wpdb->delete($this->table(), ['id' => $restriction_id]);
|
||||
if ($result) {
|
||||
$this->sync_user_restriction_meta((int) $restriction['user_id']);
|
||||
do_action('aksrt_user_restriction_changed', (int) $restriction['user_id'], (string) ($restriction['type'] ?? ''), 'removed', '');
|
||||
}
|
||||
return (bool) $result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..')
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
|
||||
|
||||
const templates = read('src/Settings/MailTemplates.php')
|
||||
const mail = read('src/Settings/Mail.php')
|
||||
const notifications = read('src/Settings/MailNotifications.php')
|
||||
const globalController = read('src/Admin/GlobalController.php')
|
||||
const userController = read('src/Admin/UserController.php')
|
||||
const settings = read('src/Settings/Settings.php')
|
||||
|
||||
test('email catalog includes required non-forum and financial events', () => {
|
||||
const required = [
|
||||
'reg_verify_code', 'reg_success', 'login_code', 'password_reset', 'change_code', 'change_notice',
|
||||
'order_paid', 'order_status', 'withdraw_submitted', 'withdraw_completed', 'affiliate_commission',
|
||||
'creator_income', 'transfer_received', 'comment_approved', 'comment_reply', 'post_result',
|
||||
'phone_changed', 'auth_apply_result', 'user_ban', 'report_result',
|
||||
'order_paid_admin', 'withdraw_submitted_admin',
|
||||
]
|
||||
for (const event of required) {
|
||||
assert.match(templates, new RegExp(`'${event}'\\s*=>\\s*self::event`), event)
|
||||
}
|
||||
})
|
||||
|
||||
test('forum-only events stay out of the email catalog', () => {
|
||||
for (const forbidden of ['bbs', 'moderator', 'answer_adopted', 'answer-adopted']) {
|
||||
assert.doesNotMatch(templates, new RegExp(forbidden, 'i'))
|
||||
assert.doesNotMatch(notifications, new RegExp(forbidden, 'i'))
|
||||
}
|
||||
})
|
||||
|
||||
test('email templates and SMTP use one aksrt_settings schema', () => {
|
||||
assert.match(templates, /'option_key'\s*=>\s*'aksrt_settings'/)
|
||||
assert.match(templates, /'mail_passwd'.*'secret'\s*=>\s*true/)
|
||||
assert.match(mail, /Settings::get_all\(\)/)
|
||||
assert.doesNotMatch(settings, /aksrt_mail_smtp/)
|
||||
assert.doesNotMatch(globalController, /aksrt_mail_config|\/settings\/mail'/)
|
||||
assert.doesNotMatch(userController, /aksrt_mail_templates|mail-templates/)
|
||||
})
|
||||
|
||||
test('only the canonical mail service calls wp_mail', () => {
|
||||
const phpFiles = []
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(full)
|
||||
else if (entry.name.endsWith('.php')) phpFiles.push(full)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
const callers = phpFiles.filter((file) => /wp_mail\s*\(/.test(fs.readFileSync(file, 'utf8')))
|
||||
assert.deepEqual(callers.map((file) => path.relative(root, file).replaceAll('\\', '/')), ['src/Settings/Mail.php'])
|
||||
})
|
||||
|
||||
test('financial and account hooks are wired to the notification service', () => {
|
||||
for (const hook of [
|
||||
'aksrt_order_paid', 'aksrt_order_status_changed', 'aksrt_asset_transaction_committed',
|
||||
'aksrt_withdrawal_submitted', 'aksrt_withdrawal_status_changed',
|
||||
'aksrt_user_phone_changed',
|
||||
]) {
|
||||
assert.match(notifications, new RegExp(`add_action\\('${hook}'`), hook)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user