@claudelaw/taichu 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +208 -0
  5. package/docker-compose.yml +42 -0
  6. package/docs/ROADMAP.md +101 -0
  7. package/docs/api/README.md +102 -0
  8. package/docs/architecture/001-zero-dependency-core.md +61 -0
  9. package/docs/architecture/002-structured-content-model.md +70 -0
  10. package/docs/architecture/003-hook-based-extension.md +82 -0
  11. package/docs/architecture/004-api-first-architecture.md +122 -0
  12. package/docs/architecture/README.md +24 -0
  13. package/docs/logo.svg +40 -0
  14. package/docs/research/ai-era-cms-user-research.md +247 -0
  15. package/docs/zh/README.md +81 -0
  16. package/docs/zh/guides/deploy.md +75 -0
  17. package/docs/zh/guides/mcp.md +84 -0
  18. package/docs/zh/guides/promotion.md +51 -0
  19. package/marketplace.json +78 -0
  20. package/package.json +60 -0
  21. package/packages/core/src/auth.js +158 -0
  22. package/packages/core/src/content-type.js +244 -0
  23. package/packages/core/src/core.test.js +406 -0
  24. package/packages/core/src/errors.js +60 -0
  25. package/packages/core/src/hooks.js +104 -0
  26. package/packages/core/src/index.js +16 -0
  27. package/packages/core/src/server.test.js +149 -0
  28. package/packages/core/src/sm-crypto.js +31 -0
  29. package/packages/core/src/sqlite-store.js +354 -0
  30. package/packages/core/src/store.js +174 -0
  31. package/packages/core/src/tokenizer.js +89 -0
  32. package/packages/core/src/vector-index.js +131 -0
  33. package/packages/llm-providers/src/index.js +181 -0
  34. package/packages/mcp/src/index.js +355 -0
  35. package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
  36. package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
  37. package/packages/server/public/admin/index.html +28 -0
  38. package/packages/server/public/aurora/style.css +1173 -0
  39. package/packages/server/public/favicon.svg +46 -0
  40. package/packages/server/public/theme/index.html +288 -0
  41. package/packages/server/public/theme/style.css +133 -0
  42. package/packages/server/public/theme-minimal/index.html +223 -0
  43. package/packages/server/public/theme-minimal/style.css +109 -0
  44. package/packages/server/public/ws-test.html +106 -0
  45. package/packages/server/src/activitypub.js +228 -0
  46. package/packages/server/src/audit.js +104 -0
  47. package/packages/server/src/auth-provider.js +76 -0
  48. package/packages/server/src/body-parser.js +52 -0
  49. package/packages/server/src/bootstrap.js +272 -0
  50. package/packages/server/src/collab.js +154 -0
  51. package/packages/server/src/config.js +136 -0
  52. package/packages/server/src/context.js +86 -0
  53. package/packages/server/src/email.js +317 -0
  54. package/packages/server/src/index.js +195 -0
  55. package/packages/server/src/logger.js +78 -0
  56. package/packages/server/src/media-store.js +213 -0
  57. package/packages/server/src/middleware/auth.js +203 -0
  58. package/packages/server/src/middleware/cors.js +15 -0
  59. package/packages/server/src/middleware/error-handler.js +49 -0
  60. package/packages/server/src/middleware/rate-limit.js +118 -0
  61. package/packages/server/src/multipart.js +150 -0
  62. package/packages/server/src/notify.js +126 -0
  63. package/packages/server/src/pipeline.js +206 -0
  64. package/packages/server/src/plugin-installer.js +139 -0
  65. package/packages/server/src/plugin-manager.js +165 -0
  66. package/packages/server/src/relationships.js +217 -0
  67. package/packages/server/src/revisions.js +114 -0
  68. package/packages/server/src/router.js +194 -0
  69. package/packages/server/src/routes/activitypub.js +140 -0
  70. package/packages/server/src/routes/api.js +363 -0
  71. package/packages/server/src/routes/audit.js +222 -0
  72. package/packages/server/src/routes/auth.js +205 -0
  73. package/packages/server/src/routes/collab.js +90 -0
  74. package/packages/server/src/routes/export.js +77 -0
  75. package/packages/server/src/routes/graphql.js +344 -0
  76. package/packages/server/src/routes/media.js +169 -0
  77. package/packages/server/src/routes/plugin-marketplace.js +171 -0
  78. package/packages/server/src/routes/relationships.js +133 -0
  79. package/packages/server/src/routes/rss.js +92 -0
  80. package/packages/server/src/routes/sso.js +211 -0
  81. package/packages/server/src/routes/theme.js +119 -0
  82. package/packages/server/src/routes/webhook.js +94 -0
  83. package/packages/server/src/routes/wechat.js +115 -0
  84. package/packages/server/src/routes/workflow.js +157 -0
  85. package/packages/server/src/scheduler.js +96 -0
  86. package/packages/server/src/search.js +100 -0
  87. package/packages/server/src/server.test.js +295 -0
  88. package/packages/server/src/sso-analytics.js +78 -0
  89. package/packages/server/src/static.js +70 -0
  90. package/packages/server/src/theme-engine.js +119 -0
  91. package/packages/server/src/webhook.js +192 -0
  92. package/packages/server/src/websocket.js +308 -0
  93. package/scripts/cli.js +90 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Config — 集中式配置管理
3
+ *
4
+ * 替代散落的 process.env.TAICHU_* 读取,提供:
5
+ * - Schema 验证(类型、必填、默认值、枚举)
6
+ * - 启动时一次性校验,拒绝无效配置
7
+ * - 不可变配置对象
8
+ */
9
+
10
+ const schema = [
11
+ // Server
12
+ { key: 'port', env: 'TAICHU_PORT', type: 'number', default: 3120, min: 1, max: 65535 },
13
+ { key: 'host', env: 'TAICHU_HOST', type: 'string', default: '0.0.0.0' },
14
+ { key: 'version', env: 'TAICHU_VERSION', type: 'string', default: '0.6.0' },
15
+
16
+ // Storage
17
+ { key: 'storage', env: 'TAICHU_STORAGE', type: 'enum', default: 'memory', values: ['memory', 'sqlite'] },
18
+ { key: 'dataDir', env: 'TAICHU_DATA_DIR', type: 'string', default: null },
19
+ { key: 'sqliteFlushMs', env: 'TAICHU_SQLITE_FLUSH_MS',type: 'number', default: 5000, min: 1000, max: 60000 },
20
+
21
+ // Auth
22
+ { key: 'jwtSecret', env: 'TAICHU_JWT_SECRET', type: 'string', default: '__AUTO__' },
23
+ { key: 'jwtExpiresIn', env: 'TAICHU_JWT_EXPIRES_IN',type: 'string', default: '7d' },
24
+
25
+ // Security
26
+ { key: 'publicRead', env: 'TAICHU_PUBLIC_READ', type: 'boolean', default: false },
27
+ { key: 'maxBodySize', env: 'TAICHU_MAX_BODY_SIZE', type: 'number', default: 5 * 1024 * 1024, min: 1024 },
28
+ { key: 'maxFileSize', env: 'TAICHU_MAX_FILE_SIZE', type: 'number', default: 50 * 1024 * 1024, min: 1024 },
29
+
30
+ // Uploads
31
+ { key: 'uploadDir', env: 'TAICHU_UPLOAD_DIR', type: 'string', default: null },
32
+
33
+ // Logging
34
+ { key: 'logLevel', env: 'TAICHU_LOG_LEVEL', type: 'enum', default: 'info', values: ['debug', 'info', 'warn', 'error'] },
35
+ { key: 'logFormat', env: 'TAICHU_LOG_FORMAT', type: 'enum', default: 'pretty', values: ['pretty', 'json'] },
36
+
37
+ // Environment
38
+ { key: 'nodeEnv', env: 'NODE_ENV', type: 'enum', default: 'development', values: ['development', 'production', 'test'] },
39
+ ];
40
+
41
+ /**
42
+ * @typedef {object} TaichuConfig
43
+ */
44
+
45
+ let _config = null;
46
+ let _warnings = [];
47
+
48
+ /**
49
+ * Load and validate configuration.
50
+ * Call once at startup. Returns frozen config object.
51
+ *
52
+ * @returns {TaichuConfig}
53
+ */
54
+ export function loadConfig() {
55
+ if (_config) return _config;
56
+
57
+ const config = {};
58
+ const issues = [];
59
+
60
+ for (const field of schema) {
61
+ let value = process.env[field.env];
62
+
63
+ if (value === undefined || value === '') {
64
+ value = field.default;
65
+ }
66
+
67
+ // Type coercion & validation
68
+ switch (field.type) {
69
+ case 'number': {
70
+ const num = parseInt(value, 10);
71
+ if (isNaN(num)) {
72
+ issues.push(`${field.env}: "${value}" is not a valid number, using default ${field.default}`);
73
+ value = field.default;
74
+ } else if (field.min !== undefined && num < field.min) {
75
+ issues.push(`${field.env}: ${num} < min ${field.min}, using ${field.min}`);
76
+ value = field.min;
77
+ } else if (field.max !== undefined && num > field.max) {
78
+ issues.push(`${field.env}: ${num} > max ${field.max}, using ${field.max}`);
79
+ value = field.max;
80
+ } else {
81
+ value = num;
82
+ }
83
+ break;
84
+ }
85
+ case 'boolean': {
86
+ value = value === '1' || value === 'true' || value === 'yes';
87
+ break;
88
+ }
89
+ case 'enum': {
90
+ if (!field.values.includes(value)) {
91
+ issues.push(`${field.env}: "${value}" not in [${field.values.join(', ')}], using default "${field.default}"`);
92
+ value = field.default;
93
+ }
94
+ break;
95
+ }
96
+ default:
97
+ value = String(value);
98
+ }
99
+
100
+ config[field.key] = value;
101
+ }
102
+
103
+ _warnings = issues;
104
+ _config = Object.freeze(config);
105
+
106
+ return _config;
107
+ }
108
+
109
+ /**
110
+ * Get warnings from config loading (for logging on startup).
111
+ */
112
+ export function getConfigWarnings() {
113
+ return _warnings;
114
+ }
115
+
116
+ /**
117
+ * Get current config (must call loadConfig() first).
118
+ */
119
+ export function getConfig() {
120
+ if (!_config) throw new Error('Config not loaded. Call loadConfig() first.');
121
+ return _config;
122
+ }
123
+
124
+ /**
125
+ * Print config summary for startup banner.
126
+ */
127
+ export function configSummary() {
128
+ const c = getConfig();
129
+ return [
130
+ ` Storage: ${c.storage === 'sqlite' ? `sqlite (${c.dataDir || '.taichu/data/taichu.db'})` : 'memory'}`,
131
+ ` Log: ${c.logLevel} / ${c.logFormat}`,
132
+ ` Public: ${c.publicRead ? 'read enabled' : 'auth required'}`,
133
+ ` Uploads: ${c.maxFileSize / 1024 / 1024}MB max`,
134
+ ''
135
+ ].join('\n');
136
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Context — 请求上下文
3
+ *
4
+ * 贯穿整个请求生命周期的上下文对象。
5
+ * 包含:
6
+ * - req/res — 原始 Node.js 请求/响应对象
7
+ * - url — 解析后的 URL 对象
8
+ * - body — 解析后的请求体
9
+ * - config — 全局配置
10
+ * - state — 中间件/路由之间传递数据
11
+ * - tenantId — 租户 ID(默认 'default',可通过 API Key 或 X-Taichu-Tenant 头覆盖)
12
+ * - multiTenant — 是否启用多租户模式
13
+ */
14
+
15
+ import { createStore as createCoreStore, createHookSystem } from '../../core/src/index.js';
16
+
17
+ let _store = null;
18
+ let _storePromise = null;
19
+ let _hooks = null;
20
+
21
+ /** Check if multi-tenant mode is enabled */
22
+ const MULTI_TENANT = process.env.TAICHU_MULTI_TENANT === '1';
23
+
24
+ /**
25
+ * Ensure the store singleton is initialized (async-safe).
26
+ * @param {object} config
27
+ */
28
+ async function ensureStore(config) {
29
+ if (_store) return _store;
30
+ if (_storePromise) return _storePromise;
31
+
32
+ _storePromise = createCoreStore({ engine: config.storage || 'memory', dataDir: config.dataDir });
33
+ _store = await _storePromise;
34
+ return _store;
35
+ }
36
+
37
+ /**
38
+ * Extract tenant ID from request context.
39
+ * Priority: API Key scope > X-Taichu-Tenant header > default
40
+ */
41
+ function extractTenantId(req) {
42
+ if (!MULTI_TENANT) return 'default';
43
+
44
+ // Check X-Taichu-Tenant header (admin override)
45
+ const header = req.headers['x-taichu-tenant'];
46
+ if (header && typeof header === 'string') return header;
47
+
48
+ // Default tenant
49
+ return 'default';
50
+ }
51
+
52
+ /**
53
+ * @param {object} params
54
+ * @returns {Promise<Context>}
55
+ */
56
+ export async function createContext({ req, res, url, body, config = {} }) {
57
+ if (!_store) {
58
+ await ensureStore(config);
59
+ }
60
+ if (!_hooks) {
61
+ _hooks = createHookSystem();
62
+ }
63
+
64
+ const tenantId = extractTenantId(req);
65
+
66
+ return {
67
+ req,
68
+ res,
69
+ url,
70
+ body,
71
+ config,
72
+ store: _store,
73
+ hooks: _hooks,
74
+ state: {},
75
+ /** @type {object|null} authenticated actor (human user or agent) */
76
+ actor: null,
77
+ /** Tenant ID for multi-tenant isolation */
78
+ tenantId,
79
+ /** Whether multi-tenant mode is active */
80
+ multiTenant: MULTI_TENANT
81
+ };
82
+ }
83
+
84
+ export function getStore() { return _store; }
85
+ export function getHooks() { return _hooks; }
86
+ export function isMultiTenant() { return MULTI_TENANT; }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Email Notification — 邮件通知渠道
3
+ *
4
+ * 零外部依赖 SMTP 客户端(基于 node:net + node:tls + node:crypto)。
5
+ * 环境变量:
6
+ * TAICHU_SMTP_HOST — SMTP 服务器地址
7
+ * TAICHU_SMTP_PORT — 端口(默认 587 STARTTLS, 465 SMTPS)
8
+ * TAICHU_SMTP_USER — 认证用户名
9
+ * TAICHU_SMTP_PASS — 认证密码
10
+ * TAICHU_SMTP_FROM — 发件人地址
11
+ * TAICHU_SMTP_TO — 收件人(逗号分隔)
12
+ * TAICHU_SMTP_CC — 抄送(可选,逗号分隔)
13
+ * TAICHU_SMTP_BCC — 密送(可选,逗号分隔)
14
+ * TAICHU_SMTP_SECURE — 设为 '1' 强制 TLS 直连(端口 465 模式)
15
+ * TAICHU_SMTP_TLS_REJECT_UNAUTHORIZED — 设为 '0' 跳过证书验证(仅开发)
16
+ */
17
+
18
+ import { createConnection } from 'node:net';
19
+ import { connect } from 'node:tls';
20
+ import { randomBytes } from 'node:crypto';
21
+ import { createLogger } from './logger.js';
22
+
23
+ const log = createLogger('email');
24
+
25
+ const CONNECT_TIMEOUT = 15000;
26
+
27
+ /**
28
+ * Send an HTML email via SMTP.
29
+ * @param {object} opts
30
+ * @param {string} opts.to — recipient(s), comma-separated
31
+ * @param {string} opts.subject — email subject
32
+ * @param {string} opts.html — HTML body
33
+ * @param {string} [opts.cc] — CC recipient(s)
34
+ * @param {string} [opts.bcc] — BCC recipient(s)
35
+ * @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
36
+ */
37
+ export async function sendEmail(opts) {
38
+ const host = process.env.TAICHU_SMTP_HOST;
39
+ const port = parseInt(process.env.TAICHU_SMTP_PORT) || 587;
40
+ const user = process.env.TAICHU_SMTP_USER;
41
+ const pass = process.env.TAICHU_SMTP_PASS;
42
+ const from = process.env.TAICHU_SMTP_FROM;
43
+
44
+ if (!host || !user || !pass || !from) {
45
+ return { success: false, error: 'SMTP not configured (missing TAICHU_SMTP_HOST/USER/PASS/FROM)' };
46
+ }
47
+
48
+ const to = opts.to || process.env.TAICHU_SMTP_TO;
49
+ if (!to) {
50
+ return { success: false, error: 'No recipients' };
51
+ }
52
+
53
+ const cc = opts.cc || process.env.TAICHU_SMTP_CC || '';
54
+ const bcc = opts.bcc || process.env.TAICHU_SMTP_BCC || '';
55
+ const useTLS = process.env.TAICHU_SMTP_SECURE === '1' || port === 465;
56
+ const rejectUnauthorized = process.env.TAICHU_SMTP_TLS_REJECT_UNAUTHORIZED !== '0';
57
+
58
+ try {
59
+ const messageId = await smtpSend({
60
+ host, port, user, pass, from, to, cc, bcc,
61
+ subject: opts.subject, html: opts.html,
62
+ useTLS, rejectUnauthorized
63
+ });
64
+ return { success: true, messageId };
65
+ } catch (err) {
66
+ log.error(`Email send failed: ${err.message}`);
67
+ return { success: false, error: err.message };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Low-level SMTP transaction.
73
+ */
74
+ function smtpSend(cfg) {
75
+ return new Promise((resolve, reject) => {
76
+ const socket = cfg.useTLS
77
+ ? connect({ host: cfg.host, port: cfg.port, rejectUnauthorized: cfg.rejectUnauthorized, timeout: CONNECT_TIMEOUT })
78
+ : createConnection({ host: cfg.host, port: cfg.port, timeout: CONNECT_TIMEOUT });
79
+
80
+ let buffer = '';
81
+ let authMethod = null;
82
+
83
+ socket.setEncoding('utf8');
84
+
85
+ const readResponse = () => {
86
+ return new Promise((res) => {
87
+ const check = () => {
88
+ const idx = buffer.indexOf('\r\n');
89
+ if (idx >= 0) {
90
+ const line = buffer.substring(0, idx);
91
+ buffer = buffer.substring(idx + 2);
92
+ const code = parseInt(line.substring(0, 3));
93
+ res({ code, line, continued: line[3] === '-' });
94
+ } else {
95
+ socket.once('data', () => check());
96
+ }
97
+ };
98
+ check();
99
+ });
100
+ };
101
+
102
+ const send = (cmd) => {
103
+ socket.write(cmd + '\r\n');
104
+ };
105
+
106
+ const multiLine = async () => {
107
+ const lines = [];
108
+ let resp = await readResponse();
109
+ lines.push(resp.line);
110
+ while (resp.continued) {
111
+ resp = await readResponse();
112
+ lines.push(resp.line);
113
+ }
114
+ if (resp.code >= 400) throw new Error(`SMTP ${resp.code}: ${resp.line}`);
115
+ return { code: resp.code, lines };
116
+ };
117
+
118
+ socket.on('data', (data) => {
119
+ buffer += data;
120
+ });
121
+
122
+ socket.on('timeout', () => {
123
+ socket.destroy();
124
+ reject(new Error('SMTP connection timeout'));
125
+ });
126
+
127
+ socket.on('error', reject);
128
+
129
+ (async () => {
130
+ try {
131
+ // 1. Read banner
132
+ let resp = await multiLine();
133
+
134
+ // 2. EHLO
135
+ const hostname = 'taichu.local';
136
+ send(`EHLO ${hostname}`);
137
+ resp = await multiLine();
138
+
139
+ const capabilities = resp.lines.map(l => l.substring(4)).join(' ');
140
+
141
+ // 3. STARTTLS (if not already secure)
142
+ if (!cfg.useTLS && capabilities.includes('STARTTLS')) {
143
+ send('STARTTLS');
144
+ resp = await readResponse();
145
+ if (resp.code !== 220) throw new Error(`STARTTLS rejected: ${resp.line}`);
146
+
147
+ // Upgrade socket to TLS
148
+ const tlsSocket = connect({
149
+ socket,
150
+ host: cfg.host,
151
+ rejectUnauthorized: cfg.rejectUnauthorized,
152
+ timeout: CONNECT_TIMEOUT
153
+ });
154
+ // Replace socket refs
155
+ tlsSocket.setEncoding('utf8');
156
+ tlsSocket.on('data', (d) => { buffer += d; });
157
+ tlsSocket.on('timeout', () => { tlsSocket.destroy(); reject(new Error('TLS timeout')); });
158
+ tlsSocket.on('error', reject);
159
+
160
+ // Re-EHLO over TLS
161
+ send(`EHLO ${hostname}`);
162
+ resp = await multiLine();
163
+ const tlsCapabilities = resp.lines.map(l => l.substring(4)).join(' ');
164
+ authMethod = detectAuth(tlsCapabilities);
165
+ } else {
166
+ authMethod = detectAuth(capabilities);
167
+ }
168
+
169
+ // 4. AUTH LOGIN
170
+ if (authMethod === 'LOGIN') {
171
+ send('AUTH LOGIN');
172
+ resp = await readResponse();
173
+ if (resp.code !== 334) throw new Error(`AUTH LOGIN not accepted: ${resp.line}`);
174
+
175
+ send(Buffer.from(cfg.user).toString('base64'));
176
+ resp = await readResponse();
177
+ if (resp.code !== 334) throw new Error(`Username rejected: ${resp.line}`);
178
+
179
+ send(Buffer.from(cfg.pass).toString('base64'));
180
+ resp = await readResponse();
181
+ if (resp.code !== 235) throw new Error(`Password rejected: ${resp.line}`);
182
+ } else if (authMethod === 'PLAIN') {
183
+ const token = Buffer.from(`\0${cfg.user}\0${cfg.pass}`).toString('base64');
184
+ send('AUTH PLAIN ' + token);
185
+ resp = await readResponse();
186
+ if (resp.code !== 235) throw new Error(`AUTH PLAIN rejected: ${resp.line}`);
187
+ } else {
188
+ throw new Error('No supported AUTH method (need LOGIN or PLAIN)');
189
+ }
190
+
191
+ // 5. MAIL FROM
192
+ send(`MAIL FROM:<${cfg.from}>`);
193
+ resp = await readResponse();
194
+ if (resp.code !== 250) throw new Error(`MAIL FROM rejected: ${resp.line}`);
195
+
196
+ // 6. RCPT TO
197
+ const allRecipients = [
198
+ ...cfg.to.split(',').map(s => s.trim()).filter(Boolean),
199
+ ...cfg.cc.split(',').map(s => s.trim()).filter(Boolean),
200
+ ...cfg.bcc.split(',').map(s => s.trim()).filter(Boolean)
201
+ ];
202
+ for (const rcpt of allRecipients) {
203
+ send(`RCPT TO:<${rcpt}>`);
204
+ resp = await readResponse();
205
+ if (resp.code >= 400) log.warn(`RCPT TO ${rcpt} rejected: ${resp.line}`);
206
+ }
207
+
208
+ // 7. DATA
209
+ send('DATA');
210
+ resp = await readResponse();
211
+ if (resp.code !== 354) throw new Error(`DATA not accepted: ${resp.line}`);
212
+
213
+ const messageId = `<${Date.now()}.${randomBytes(6).toString('hex')}@taichu>`;
214
+ const date = new Date().toUTCString();
215
+ const boundary = `--_Taichu_${randomBytes(12).toString('hex')}`;
216
+
217
+ const rawEmail = [
218
+ `From: ${cfg.from}`,
219
+ `To: ${cfg.to}`,
220
+ cfg.cc ? `Cc: ${cfg.cc}` : '',
221
+ `Date: ${date}`,
222
+ `Message-ID: ${messageId}`,
223
+ `Subject: =?UTF-8?B?${Buffer.from(cfg.subject).toString('base64')}?=`,
224
+ 'MIME-Version: 1.0',
225
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
226
+ '',
227
+ `--${boundary}`,
228
+ 'Content-Type: text/plain; charset=UTF-8',
229
+ 'Content-Transfer-Encoding: base64',
230
+ '',
231
+ Buffer.from(stripHtml(cfg.html)).toString('base64'),
232
+ '',
233
+ `--${boundary}`,
234
+ 'Content-Type: text/html; charset=UTF-8',
235
+ 'Content-Transfer-Encoding: base64',
236
+ '',
237
+ Buffer.from(cfg.html).toString('base64'),
238
+ '',
239
+ `--${boundary}--`,
240
+ '.'
241
+ ].filter(l => l !== '').join('\r\n');
242
+
243
+ send(rawEmail);
244
+ resp = await readResponse();
245
+ if (resp.code !== 250) throw new Error(`Message rejected: ${resp.line}`);
246
+
247
+ // 8. QUIT
248
+ send('QUIT');
249
+ socket.end();
250
+
251
+ resolve(messageId);
252
+ } catch (err) {
253
+ try { send('QUIT'); socket.end(); } catch (_) {}
254
+ reject(err);
255
+ }
256
+ })();
257
+ });
258
+ }
259
+
260
+ function detectAuth(capabilities) {
261
+ if (capabilities.includes('AUTH LOGIN')) return 'LOGIN';
262
+ if (capabilities.includes('AUTH PLAIN')) return 'PLAIN';
263
+ return null;
264
+ }
265
+
266
+ function stripHtml(html) {
267
+ return html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
268
+ }
269
+
270
+ /**
271
+ * Build notification email HTML.
272
+ */
273
+ export function buildEmailHtml(event, data) {
274
+ const { title, url, actor, summary } = normalizeEmailData(data);
275
+ return `
276
+ <!DOCTYPE html>
277
+ <html><head><meta charset="utf-8"></head>
278
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #1E1B4B;">
279
+ <div style="background: linear-gradient(135deg, #1E1B4B, #312E81); color: white; padding: 24px; border-radius: 12px 12px 0 0;">
280
+ <h2 style="margin: 0; font-size: 18px;">Taichu CMS ${eventLabelEmail(event)}</h2>
281
+ </div>
282
+ <div style="border: 1px solid #E5E7EB; border-top: 0; border-radius: 0 0 12px 12px; padding: 24px;">
283
+ <h3 style="margin: 0 0 12px; color: #1E1B4B;">${escapeHtml(title)}</h3>
284
+ <p style="color: #6B7280; line-height: 1.6;">${escapeHtml(summary)}</p>
285
+ <p style="color: #9CA3AF; font-size: 13px;">${escapeHtml(actor)} · ${new Date().toLocaleString('zh-CN')}</p>
286
+ ${url ? `<a href="${url}" style="display: inline-block; margin-top: 16px; background: #1E1B4B; color: white; padding: 10px 20px; border-radius: 8px; text-decoration: none;">查看详情</a>` : ''}
287
+ </div>
288
+ <p style="color: #D1D5DB; font-size: 12px; text-align: center; margin-top: 16px;">
289
+ Taichu CMS Notification · <a href="https://github.com/Caludelaw/Taichu" style="color: #9CA3AF;">github.com/Caludelaw/Taichu</a>
290
+ </p>
291
+ </body></html>`;
292
+ }
293
+
294
+ function normalizeEmailData(data) {
295
+ return {
296
+ title: data.doc?.data?.title || data.doc?.id || 'Untitled',
297
+ url: data.url || '',
298
+ actor: data.actor || 'System',
299
+ summary: typeof data.doc?.data?.body === 'string'
300
+ ? data.doc.data.body.substring(0, 200)
301
+ : (data.summary || '')
302
+ };
303
+ }
304
+
305
+ function eventLabelEmail(e) {
306
+ const map = {
307
+ content_created: '📝 内容创建', content_updated: '✏️ 内容更新',
308
+ content_deleted: '🗑️ 内容删除', content_published: '🚀 内容发布',
309
+ content_scheduled: '⏰ 定时发布', review_requested: '👀 请求审核',
310
+ agent_action: '🤖 Agent 操作'
311
+ };
312
+ return map[e] || '📌 通知';
313
+ }
314
+
315
+ function escapeHtml(s) {
316
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
317
+ }