fix(email): complete mail settings and templates #19

Merged
kate522 merged 1 commits from worktree/email-system into main 2026-09-04 04:09:47 +00:00
29 changed files with 716 additions and 426 deletions
+1
View File
@@ -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
-2
View File
@@ -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') },
+12 -1
View File
@@ -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('')
+24 -5
View File
@@ -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,
+3
View File
@@ -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
View File
@@ -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;
});
+8 -1
View File
@@ -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;
+6 -24
View File
@@ -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 = '';
+4 -31
View File
@@ -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')]);
}
+1 -58
View File
@@ -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' => '自定义代码',
+13 -3
View File
@@ -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;
-41
View File
@@ -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 {
+12 -4
View File
@@ -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
+4 -2
View File
@@ -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'),
];
+7 -3
View File
@@ -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();
+3 -1
View File
@@ -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;
}
+15 -2
View File
@@ -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;
}
+15 -7
View File
@@ -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,
+5 -13
View File
@@ -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
View File
@@ -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>';
}
}
+223
View File
@@ -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, '.', '');
}
}
+124
View File
@@ -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];
}
}
+3 -2
View File
@@ -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) {
+2
View File
@@ -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)
}
})