diff --git a/.gitignore b/.gitignore
index 4d8dd9b7..c7066355 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/assets/admin/src/api/modules/global.js b/assets/admin/src/api/modules/global.js
index a3f26a98..5bbbbecc 100644
--- a/assets/admin/src/api/modules/global.js
+++ b/assets/admin/src/api/modules/global.js
@@ -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') },
diff --git a/assets/admin/src/components/form/AksField.vue b/assets/admin/src/components/form/AksField.vue
index de53f7b3..0169deb4 100644
--- a/assets/admin/src/components/form/AksField.vue
+++ b/assets/admin/src/components/form/AksField.vue
@@ -273,6 +273,17 @@
v-html="schema.content || modelValue || ''"
/>
+
+
+ {{ schema.buttonText || schema.label || '执行' }}
+
+
props.schema.secret && props.modelValue === '__AKSRT_CLEAR_SECRET__')
const secretDisplayValue = computed(() => secretMarkedForClear.value ? '' : props.modelValue)
diff --git a/assets/admin/src/components/form/AksFormRenderer.vue b/assets/admin/src/components/form/AksFormRenderer.vue
index cba3e045..94eda249 100644
--- a/assets/admin/src/components/form/AksFormRenderer.vue
+++ b/assets/admin/src/components/form/AksFormRenderer.vue
@@ -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)"
/>
@@ -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('')
diff --git a/assets/admin/src/pages/SchemaPage.vue b/assets/admin/src/pages/SchemaPage.vue
index a0eff33a..13415f55 100644
--- a/assets/admin/src/pages/SchemaPage.vue
+++ b/assets/admin/src/pages/SchemaPage.vue
@@ -14,6 +14,7 @@
@update:form-data="formData = $event"
@save="handleSave"
@reload="handleReload"
+ @field-action="handleFieldAction"
>
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()
diff --git a/assets/admin/src/schema/sections/global/email.js b/assets/admin/src/schema/sections/global/email.js
deleted file mode 100644
index 388f6231..00000000
--- a/assets/admin/src/schema/sections/global/email.js
+++ /dev/null
@@ -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 },
- ],
- },
- ],
-}
diff --git a/assets/admin/src/schema/sections/global/index.js b/assets/admin/src/schema/sections/global/index.js
index 5a788423..effeee82 100644
--- a/assets/admin/src/schema/sections/global/index.js
+++ b/assets/admin/src/schema/sections/global/index.js
@@ -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,
diff --git a/functions.php b/functions.php
index affae024..552c80f4 100644
--- a/functions.php
+++ b/functions.php
@@ -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();
diff --git a/inc/core-hooks/part-2.php b/inc/core-hooks/part-2.php
index b21ba0c8..d99d4c7c 100644
--- a/inc/core-hooks/part-2.php
+++ b/inc/core-hooks/part-2.php
@@ -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;
});
diff --git a/inc/modules/user-notifications.php b/inc/modules/user-notifications.php
index a727819e..f6d8590e 100644
--- a/inc/modules/user-notifications.php
+++ b/inc/modules/user-notifications.php
@@ -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;
diff --git a/inc/modules/user-parts/account-ajax.php b/inc/modules/user-parts/account-ajax.php
index ffabee0e..ba3ef586 100644
--- a/inc/modules/user-parts/account-ajax.php
+++ b/inc/modules/user-parts/account-ajax.php
@@ -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 = '';
diff --git a/src/Admin/GlobalController.php b/src/Admin/GlobalController.php
index 9d564d1a..d9a2d412 100644
--- a/src/Admin/GlobalController.php
+++ b/src/Admin/GlobalController.php
@@ -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')]);
}
diff --git a/src/Admin/Schema/GlobalSchema.php b/src/Admin/Schema/GlobalSchema.php
index fb2d4084..84a3786d 100644
--- a/src/Admin/Schema/GlobalSchema.php
+++ b/src/Admin/Schema/GlobalSchema.php
@@ -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' => '自定义代码',
diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php
index 0b47b000..6b652b09 100644
--- a/src/Admin/SchemaProvider.php
+++ b/src/Admin/SchemaProvider.php
@@ -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;
diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php
index 240270d7..41c3b2fc 100644
--- a/src/Admin/UserController.php
+++ b/src/Admin/UserController.php
@@ -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();
diff --git a/src/Admin/UserControllerAuthPhoneOperations.php b/src/Admin/UserControllerAuthPhoneOperations.php
index 50b00c7e..a0fc8fab 100644
--- a/src/Admin/UserControllerAuthPhoneOperations.php
+++ b/src/Admin/UserControllerAuthPhoneOperations.php
@@ -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 {
diff --git a/src/Auth/ServicePart1a.php b/src/Auth/ServicePart1a.php
index 84509e8f..5a48215d 100644
--- a/src/Auth/ServicePart1a.php
+++ b/src/Auth/ServicePart1a.php
@@ -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
diff --git a/src/Auth/ServicePart1b.php b/src/Auth/ServicePart1b.php
index 86574552..39f144e2 100644
--- a/src/Auth/ServicePart1b.php
+++ b/src/Auth/ServicePart1b.php
@@ -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'),
];
diff --git a/src/Identity/ApplicationService.php b/src/Identity/ApplicationService.php
index c6a72a98..ba6a053c 100644
--- a/src/Identity/ApplicationService.php
+++ b/src/Identity/ApplicationService.php
@@ -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();
diff --git a/src/Messages/Message.php b/src/Messages/Message.php
index 3b97b10c..e580aabf 100644
--- a/src/Messages/Message.php
+++ b/src/Messages/Message.php
@@ -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;
}
diff --git a/src/Messages/ReportCenter.php b/src/Messages/ReportCenter.php
index f70161d4..01cf1827 100644
--- a/src/Messages/ReportCenter.php
+++ b/src/Messages/ReportCenter.php
@@ -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;
}
diff --git a/src/Payment/Withdrawal.php b/src/Payment/Withdrawal.php
index fe31d27c..2223735d 100644
--- a/src/Payment/Withdrawal.php
+++ b/src/Payment/Withdrawal.php
@@ -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,
diff --git a/src/Settings/FacadeBulk.php b/src/Settings/FacadeBulk.php
index b242b3aa..7ab850b0 100644
--- a/src/Settings/FacadeBulk.php
+++ b/src/Settings/FacadeBulk.php
@@ -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'] ?? '')),
];
}
diff --git a/src/Settings/Mail.php b/src/Settings/Mail.php
index a1b21659..6be372bf 100644
--- a/src/Settings/Mail.php
+++ b/src/Settings/Mail.php
@@ -1,5 +1,7 @@
*/
+ 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 */
+ 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 */
+ 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 */
+ 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 !== '' ? '
' : '' . $site . '';
+ return ''
+ . '
'
+ . '
' . $logo_html . '
'
+ . '
' . $body . '
'
+ . '
'
+ . '
' . $site . ''
+ . '
' . $description . '
'
+ . '
' . $more . '
'
+ . '

'
+ . '
';
+ }
}
diff --git a/src/Settings/MailNotifications.php b/src/Settings/MailNotifications.php
new file mode 100644
index 00000000..9c47d221
--- /dev/null
+++ b/src/Settings/MailNotifications.php
@@ -0,0 +1,223 @@
+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 $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 $vars */
+ private static function send_admin(string $event, array $vars): void {
+ foreach (self::admin_emails() as $email) self::send($event, $email, $vars);
+ }
+
+ /** @return list */
+ 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 */
+ 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, '.', '');
+ }
+}
diff --git a/src/Settings/MailTemplates.php b/src/Settings/MailTemplates.php
new file mode 100644
index 00000000..3c666f4a
--- /dev/null
+++ b/src/Settings/MailTemplates.php
@@ -0,0 +1,124 @@
+> */
+ 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 */
+ 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 */
+ 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('访问网站 | 登录账号', 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 */
+ 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 */
+ 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];
+ }
+}
diff --git a/src/Settings/Settings.php b/src/Settings/Settings.php
index 0f3717a6..94139937 100644
--- a/src/Settings/Settings.php
+++ b/src/Settings/Settings.php
@@ -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
*/
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) {
diff --git a/src/User/Restriction.php b/src/User/Restriction.php
index 1456b7a2..aa3c0eda 100644
--- a/src/User/Restriction.php
+++ b/src/User/Restriction.php
@@ -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;
}
diff --git a/tests/widget-system/email-system-contract.test.cjs b/tests/widget-system/email-system-contract.test.cjs
new file mode 100644
index 00000000..124033f6
--- /dev/null
+++ b/tests/widget-system/email-system-contract.test.cjs
@@ -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)
+ }
+})