Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/core/src/modules/comment/comment.email.default.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HitokotoData } from '~/processors/helper/helper.hitokoto.service'
import dayjs from 'dayjs'
import type { OwnerModel, OwnerModelSecurityKeys } from '../owner/owner.model'
import type { CommentModel } from './comment.model'
Expand Down Expand Up @@ -58,6 +59,8 @@ export const baseRenderProps = Object.freeze({
parent: null as CommentModel | null,
owner: {} as Omit<OwnerModel, OwnerModelSecurityKeys>,
},

hitokoto: null as HitokotoData | null,
})
type Writeable<T> = { -readonly [P in keyof T]: T[P] }

Expand Down
7 changes: 7 additions & 0 deletions apps/core/src/modules/comment/comment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,8 @@ export class CommentService implements OnModuleInit {
CommentReplyMailType.Owner === type ? comment.mail : ownerInfo.mail,
ip: comment.ip || '',

hitokoto: null,

aggregate: {
owner: ownerInfo,
commentor: {
Expand Down Expand Up @@ -632,6 +634,11 @@ export class CommentService implements OnModuleInit {
: `[${seo.title || 'Mx Space'}] 有新回复了耶~`

source.ip ??= ''

// 获取一言数据
const { hitokoto } = await this.mailService.getHitokotoForTemplate()
source.hitokoto = hitokoto

const options = {
from: sendfrom,
subject,
Expand Down
4 changes: 4 additions & 0 deletions apps/core/src/modules/configs/configs.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export const generateDefaultConfig: () => IConfig = () => ({
resend: {
apiKey: '',
},
hitokoto: {
enable: false,
api: 'https://v1.hitokoto.cn',
},
},
commentOptions: {
antiSpam: false,
Expand Down
9 changes: 9 additions & 0 deletions apps/core/src/modules/configs/configs.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ export const MailOptionsSchema = section('邮件通知设置', {
}),
smtp: SmtpConfigSchema,
resend: ResendConfigSchema,
hitokoto: withMeta(
z
.object({
enable: field.toggle(z.boolean().optional(), '启用一言'),
api: field.plain(z.string().url().optional(), '一言 API 地址'),
})
.optional(),
{ title: '一言设置' },
),
})
export class MailOptionsDto extends createZodDto(MailOptionsSchema) {}
export type MailOptionsConfig = z.infer<typeof MailOptionsSchema>
Expand Down
3 changes: 3 additions & 0 deletions apps/core/src/modules/subscribe/subscribe.email.default.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HitokotoData } from '~/processors/helper/helper.hitokoto.service'
import type { OwnerModel, OwnerModelSecurityKeys } from '../owner/owner.model'
import { SubscribeAllBit } from './subscribe.constant'

Expand Down Expand Up @@ -26,6 +27,8 @@ export const defaultSubscribeForRenderProps = {
created: '2023-06-04T15:02:09.179Z',
},
},

hitokoto: null as HitokotoData | null,
}

export type SubscribeTemplateRenderProps = typeof defaultSubscribeForRenderProps
6 changes: 6 additions & 0 deletions apps/core/src/modules/subscribe/subscribe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export class SubscribeService implements OnModuleInit, OnModuleDestroy {
unsubscribe_link: unsubscribeLink,
owner: owner.name,

hitokoto: null,

aggregate: {
owner,
subscriber: {
Expand Down Expand Up @@ -282,6 +284,10 @@ export class SubscribeService implements OnModuleInit, OnModuleDestroy {
this.lruCache.set(cacheKey, finalTemplate)
}

// 获取一言数据
const { hitokoto } = await this.emailService.getHitokotoForTemplate()
source.hitokoto = hitokoto

const options: Mail.Options = {
from: sendfrom,
subject: `[${seo.title || 'Mx Space'}] 发布了新内容~`,
Expand Down
12 changes: 12 additions & 0 deletions apps/core/src/processors/helper/helper.email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type Mail from 'nodemailer/lib/mailer'
import { Resend } from 'resend'
import { SubPubBridgeService } from '../redis/subpub.service'
import { AssetService } from './helper.asset.service'
import type { HitokotoData } from './helper.hitokoto.service'
import { HitokotoService } from './helper.hitokoto.service'

type MailProvider = 'smtp' | 'resend'
type MailClient = {
Expand All @@ -30,6 +32,7 @@ export class EmailService implements OnModuleInit, OnModuleDestroy {
private readonly assetService: AssetService,
private readonly subpub: SubPubBridgeService,
private readonly ownerService: OwnerService,
private readonly hitokotoService: HitokotoService,
) {
this.logger = new Logger(EmailService.name)
}
Expand Down Expand Up @@ -285,4 +288,13 @@ export class EmailService implements OnModuleInit, OnModuleDestroy {
}
return undefined
}

/**
* 获取一言数据,用于邮件模板渲染
* @returns 一言数据对象,如果未启用或获取失败则返回默认值
*/
async getHitokotoForTemplate(): Promise<{ hitokoto: HitokotoData | null }> {
const hitokoto = await this.hitokotoService.getHitokoto()
return { hitokoto }
}
}
69 changes: 69 additions & 0 deletions apps/core/src/processors/helper/helper.hitokoto.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigsService } from '~/modules/configs/configs.service'
import { HttpService } from './helper.http.service'

export interface HitokotoResponse {
id: number
uuid: string
hitokoto: string
type: string
from: string
from_who: string | null
creator: string
creator_uid: number
reviewer: number
commit_from: string
created_at: string
length: number
}

export interface HitokotoData {
text: string
from: string
author?: string
}

@Injectable()
export class HitokotoService {
private readonly logger = new Logger(HitokotoService.name)

constructor(
private readonly configsService: ConfigsService,
private readonly httpService: HttpService,
) {}

/**
* 获取随机一言
* @returns 一言数据,如果获取失败或未启用则返回 null
*/
async getHitokoto(): Promise<HitokotoData | null> {
try {
const mailOptions = await this.configsService.get('mailOptions')
const hitokotoConfig = mailOptions?.hitokoto

if (!hitokotoConfig?.enable) {
return null
}

const apiUrl = hitokotoConfig.api || 'https://v1.hitokoto.cn'

const response = await this.httpService.axiosRef.get<HitokotoResponse>(
apiUrl,
{
timeout: 5000, // 5秒超时
},
)

const data = response.data

return {
text: data.hitokoto,
from: data.from,
author: data.from_who || undefined,
}
} catch (error) {
this.logger.warn(`获取一言失败: ${error.message}`)
return null
}
}
}
2 changes: 2 additions & 0 deletions apps/core/src/processors/helper/helper.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { BarkPushService } from './helper.bark.service'
import { CountingService } from './helper.counting.service'
import { EmailService } from './helper.email.service'
import { EventManagerService } from './helper.event.service'
import { HitokotoService } from './helper.hitokoto.service'
import { HttpService } from './helper.http.service'
import { ImageMigrationService } from './helper.image-migration.service'
import { ImageService } from './helper.image.service'
Expand All @@ -30,6 +31,7 @@ const providers: Provider<any>[] = [
CountingService,
EmailService,
EventManagerService,
HitokotoService,
HttpService,
ImageMigrationService,
ImageService,
Expand Down
9 changes: 9 additions & 0 deletions apps/core/test/mock/processors/email.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,14 @@ export const emailProvider = defineProvider({
sendTestEmail() {
return Promise.resolve()
},
async getHitokotoForTemplate() {
return {
hitokoto: {
text: '测试一言',
from: '测试出处',
author: '测试作者',
},
}
},
},
})
15 changes: 15 additions & 0 deletions apps/core/test/mock/processors/hitokoto.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { HitokotoService } from '~/processors/helper/helper.hitokoto.service'
import { defineProvider } from 'test/helper/defineProvider'

export const hitokotoProvider = defineProvider({
provide: HitokotoService,
useValue: {
async getHitokoto() {
return {
text: '那些看似不起波澜的日复一日,会突然在某一天让你看到坚持的意义。',
from: '测试出处',
author: '测试作者',
}
},
},
})
152 changes: 152 additions & 0 deletions docs/email-hitokoto.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# 邮件一言功能

## 概述

MX Space 现在支持在发送邮件时自动获取并插入随机一言(Hitokoto)。一言是一个提供随机名言警句的服务,可以为你的邮件增添文化气息。

## 配置

### 1. 启用一言功能

在管理后台的 **设置** → **邮件通知设置** → **一言设置** 中:

- **启用一言**: 开启/关闭一言功能
- **一言 API 地址**: 配置一言 API 的地址,默认为 `https://v1.hitokoto.cn`

### 2. 配置示例

```json
{
"mailOptions": {
"enable": true,
"hitokoto": {
"enable": true,
"api": "https://v1.hitokoto.cn"
}
}
}
```

## 在邮件模板中使用

一言数据会自动注入到所有邮件模板的渲染上下文中,你可以在自定义邮件模板中使用以下变量:

### 可用变量

- `hitokoto`: 一言对象,包含以下属性:
- `text`: 一言内容(字符串)
- `from`: 一言出处(字符串)
- `author`: 一言作者(字符串,可选)

### 模板示例

在 EJS 邮件模板中使用一言:

```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>邮件通知</title>
</head>
<body>
<!-- 你的邮件内容 -->
<div>
<h2>你好,<%= owner %></h2>
<p>这是一封来自 MX Space 的通知邮件。</p>
</div>

<!-- 一言部分 -->
<% if (hitokoto) { %>
<div style="margin-top: 30px; padding: 15px; background: #f5f5f5; border-left: 4px solid #1890ff;">
<p style="margin: 0; font-style: italic; color: #333;">
「<%= hitokoto.text %>」
</p>
<p style="margin: 10px 0 0; font-size: 12px; color: #999; text-align: right;">
——
<% if (hitokoto.author) { %>
<%= hitokoto.author %>《<%= hitokoto.from %>》
<% } else { %>
《<%= hitokoto.from %>》
<% } %>
</p>
</div>
<% } %>

<!-- 邮件底部 -->
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #999;">
<p>此邮件由系统自动发送,请勿直接回复。</p>
</div>
</body>
</html>
```

### 简单示例

如果只想在邮件底部添加一言:

```html
<!-- 邮件内容 -->

<% if (hitokoto) { %>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="font-style: italic; color: #666;">
<%= hitokoto.text %>
<% if (hitokoto.author) { %>
—— <%= hitokoto.author %>
<% } %>
</p>
<% } %>
```

## 注意事项

1. **网络请求**: 一言数据通过网络请求获取,设置了 5 秒超时时间
2. **失败处理**: 如果获取一言失败(网络问题、超时等),`hitokoto` 变量会是 `null`,不会影响邮件发送
3. **性能考虑**: 一言 API 调用是异步的,不会阻塞邮件发送流程
4. **自定义 API**: 你可以使用自己搭建的一言 API 服务,只需在配置中修改 API 地址

## API 返回格式

一言 API 返回的数据格式示例:

```json
{
"id": 1,
"uuid": "abc123",
"hitokoto": "那些看似不起波澜的日复一日,会突然在某一天让你看到坚持的意义。",
"type": "a",
"from": "你的名字",
"from_who": "新海诚",
"creator": "hitokoto",
"creator_uid": 1,
"reviewer": 0,
"commit_from": "web",
"created_at": "2023-01-01",
"length": 30
}
```

实际在模板中可用的数据经过简化处理:

```javascript
{
text: "那些看似不起波澜的日复一日,会突然在某一天让你看到坚持的意义。",
from: "你的名字",
author: "新海诚" // 可选
}
```

## 自定义一言源

你可以使用其他兼容的一言 API,只要返回格式包含以下字段:

- `hitokoto`: 一言内容
- `from`: 出处
- `from_who`: 作者(可选)

推荐的一言 API:

- [官方 API](https://v1.hitokoto.cn)
- [Hitokoto 国际版](https://international.v1.hitokoto.cn)
- 自建服务: 参考 [Hitokoto API 文档](https://hitokoto.cn/api)
Loading