feat: 支持 SMTP 邮件通知 (#747)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2026-09-10 12:48:30 +08:00
committed by GitHub
parent cc9a77aa49
commit c900777e78
7 changed files with 287 additions and 14 deletions
Generated
+82 -7
View File
@@ -220,7 +220,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"axum-macros",
"base64",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -291,6 +291,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -326,7 +332,7 @@ dependencies = [
"async-stream",
"async-tempfile",
"axum",
"base64",
"base64 0.22.1",
"bili_sync_entity",
"bili_sync_migration",
"built",
@@ -345,6 +351,7 @@ dependencies = [
"hex",
"itertools",
"leaky-bucket",
"lettre",
"md5",
"memchr",
"once_cell",
@@ -1123,6 +1130,22 @@ dependencies = [
"serde",
]
[[package]]
name = "email-encoding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
dependencies = [
"base64 0.23.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1641,7 +1664,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
@@ -1903,6 +1926,33 @@ dependencies = [
"tokio",
]
[[package]]
name = "lettre"
version = "0.11.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
dependencies = [
"async-trait",
"base64 0.23.1",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"httpdate",
"idna",
"mime",
"nom",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-rustls",
"url",
"webpki-roots",
]
[[package]]
name = "libc"
version = "0.2.186"
@@ -2095,6 +2145,15 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "ntapi"
version = "0.4.1"
@@ -2646,6 +2705,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -2804,7 +2869,7 @@ version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
@@ -2996,6 +3061,7 @@ version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -3558,7 +3624,7 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"base64 0.22.1",
"bigdecimal",
"bytes",
"chrono",
@@ -3636,7 +3702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bigdecimal",
"bitflags",
"byteorder",
@@ -3683,7 +3749,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bigdecimal",
"bitflags",
"byteorder",
@@ -4566,6 +4632,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "whoami"
version = "1.6.1"
+7
View File
@@ -40,6 +40,13 @@ handlebars = "6.4.3"
hex = "0.4.3"
itertools = "0.14.0"
leaky-bucket = "1.1.2"
lettre = { version = "0.11.23", default-features = false, features = [
"builder",
"smtp-transport",
"tokio1-rustls",
"ring",
"webpki-roots",
] }
md5 = "0.8.1"
memchr = "2.8.3"
once_cell = "1.21.4"
+1
View File
@@ -32,6 +32,7 @@ handlebars = { workspace = true }
hex = { workspace = true }
itertools = { workspace = true }
leaky-bucket = { workspace = true }
lettre = { workspace = true }
md5 = { workspace = true }
memchr = { workspace = true }
once_cell = { workspace = true }
+73 -1
View File
@@ -3,9 +3,12 @@ mod message;
use std::collections::HashMap;
use anyhow::Result;
use anyhow::{Result, anyhow};
use futures::future;
pub use info::DownloadNotifyInfo;
use lettre::message::{Attachment, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
pub use message::Message;
use reqwest::header;
use serde::{Deserialize, Serialize};
@@ -30,6 +33,25 @@ pub enum Notifier {
// 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用
ignore_cache: Option<()>,
},
Smtp {
host: String,
port: u16,
encryption: SmtpEncryption,
#[serde(default)]
username: String,
#[serde(default)]
password: String,
from: String,
to: String,
},
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SmtpEncryption {
None,
Tls,
StartTls,
}
pub fn webhook_template_key(url: &str) -> String {
@@ -110,6 +132,56 @@ impl Notifier {
client.post(url).headers(headers_map).body(payload).send().await?;
}
Notifier::Smtp {
host,
port,
encryption,
username,
password,
from,
to,
} => {
let email = lettre::Message::builder()
.from(from.parse()?)
.to(to.parse()?)
.subject("BiliSync 通知");
let email = if let Some(image_url) = &message.image_url {
let response = client.get(image_url).send().await?.error_for_status()?;
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.ok_or_else(|| anyhow!("Missing image Content-Type"))?
.to_str()?
.parse()?;
let image = response.bytes().await?.to_vec();
let content_id = uuid::Uuid::new_v4().to_string();
let html = format!(
r#"<p>{}</p><img src="cid:{content_id}" style="max-width: 100%;" alt="">"#,
handlebars::html_escape(&message.message).replace('\n', "<br>")
);
email.multipart(
MultiPart::alternative()
.singlepart(SinglePart::plain(message.message.to_string()))
.multipart(
MultiPart::related()
.singlepart(SinglePart::html(html))
.singlepart(Attachment::new_inline(content_id).body(image, content_type)),
),
)?
} else {
email.singlepart(SinglePart::plain(message.message.to_string()))?
};
let mut mailer = match encryption {
SmtpEncryption::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host),
SmtpEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(host)?,
SmtpEncryption::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)?,
}
.port(*port);
if !username.is_empty() || !password.is_empty() {
mailer = mailer.credentials(Credentials::new(username.clone(), password.clone()));
}
mailer.build().send(email).await?;
}
}
Ok(())
}
+12 -1
View File
@@ -333,7 +333,18 @@ export interface WebhookNotifier {
headers?: Record<string, string> | null;
}
export type Notifier = TelegramNotifier | WebhookNotifier;
export interface SmtpNotifier {
type: 'smtp';
host: string;
port: number;
encryption: 'none' | 'tls' | 'starttls';
username: string;
password: string;
from: string;
to: string;
}
export type Notifier = TelegramNotifier | WebhookNotifier | SmtpNotifier;
export type Trigger = number | string;
+8 -1
View File
@@ -767,6 +767,13 @@
{notifier.url}
</span>
</div>
{:else if notifier.type === 'smtp'}
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">SMTP</Badge>
<span class="text-muted-foreground text-sm break-all">
{notifier.host}:{notifier.port} → {notifier.to}
</span>
</div>
{/if}
</div>
<div class="flex gap-2">
@@ -945,7 +952,7 @@
<Dialog.Portal>
<Dialog.Overlay class="bg-background/80 fixed inset-0 z-50 backdrop-blur-sm" />
<Dialog.Content
class="bg-background fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg"
class="bg-background fixed top-[50%] left-[50%] z-50 grid max-h-[90vh] w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto border p-6 shadow-lg duration-200 sm:rounded-lg"
>
<Dialog.Header>
<Dialog.Title>
+104 -4
View File
@@ -1,22 +1,30 @@
<script lang="ts">
import PasswordInput from '$lib/components/custom/password-input.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { toast } from 'svelte-sonner';
import type { Notifier } from '$lib/types';
import type { Notifier, SmtpNotifier } from '$lib/types';
export let notifier: Notifier | null = null;
export let onSave: (notifier: Notifier) => void;
export let onCancel: () => void;
let type: 'telegram' | 'webhook' = 'telegram';
let type: Notifier['type'] = 'telegram';
let botToken = '';
let chatId = '';
let skipImage = false;
let webhookUrl = '';
let webhookTemplate = '';
let webhookHeaders: { key: string; value: string }[] = [];
let smtpHost = '';
let smtpPort: number | undefined = 465;
let smtpEncryption: SmtpNotifier['encryption'] = 'tls';
let smtpUsername = '';
let smtpPassword = '';
let smtpFrom = '';
let smtpTo = '';
// 初始化表单
$: {
@@ -26,7 +34,7 @@
botToken = notifier.bot_token;
chatId = notifier.chat_id;
skipImage = notifier.skip_image;
} else {
} else if (notifier.type === 'webhook') {
type = 'webhook';
webhookUrl = notifier.url;
webhookTemplate = notifier.template || '';
@@ -35,6 +43,15 @@
} else {
webhookHeaders = [];
}
} else {
type = 'smtp';
smtpHost = notifier.host;
smtpPort = notifier.port;
smtpEncryption = notifier.encryption;
smtpUsername = notifier.username;
smtpPassword = notifier.password;
smtpFrom = notifier.from;
smtpTo = notifier.to;
}
} else {
type = 'telegram';
@@ -44,6 +61,13 @@
webhookUrl = '';
webhookTemplate = '';
webhookHeaders = [];
smtpHost = '';
smtpPort = 465;
smtpEncryption = 'tls';
smtpUsername = '';
smtpPassword = '';
smtpFrom = '';
smtpTo = '';
}
}
@@ -65,7 +89,7 @@
skip_image: skipImage
};
onSave(newNotifier);
} else {
} else if (type === 'webhook') {
if (!webhookUrl.trim()) {
toast.error('请输入 Webhook URL');
return;
@@ -94,6 +118,36 @@
headers: Object.keys(headers).length > 0 ? headers : null
};
onSave(newNotifier);
} else {
if (!smtpHost.trim()) {
toast.error('请输入 SMTP 服务器地址');
return;
}
if (
smtpPort === undefined ||
!Number.isInteger(smtpPort) ||
smtpPort < 1 ||
smtpPort > 65535
) {
toast.error('端口必须是 1 到 65535 之间的整数');
return;
}
if (!smtpFrom.trim() || !smtpTo.trim()) {
toast.error('请输入发件人和收件人');
return;
}
const newNotifier: Notifier = {
type: 'smtp',
host: smtpHost.trim(),
port: smtpPort,
encryption: smtpEncryption,
username: smtpUsername.trim(),
password: smtpPassword,
from: smtpFrom.trim(),
to: smtpTo.trim()
};
onSave(newNotifier);
}
}
</script>
@@ -108,6 +162,7 @@
>
<option value="telegram">Telegram Bot</option>
<option value="webhook">Webhook</option>
<option value="smtp">SMTP 邮件</option>
</select>
</div>
@@ -188,6 +243,51 @@
添加自定义请求头,例如:Authorization: Bearer your_token
</p>
</div>
{:else if type === 'smtp'}
<div class="space-y-2">
<Label for="smtp-host">SMTP 服务器</Label>
<Input id="smtp-host" placeholder="smtp.example.com" bind:value={smtpHost} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="smtp-encryption">加密方式</Label>
<select
id="smtp-encryption"
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
bind:value={smtpEncryption}
onchange={(event) => {
smtpPort = { none: 25, tls: 465, starttls: 587 }[
event.currentTarget.value as SmtpNotifier['encryption']
];
}}
>
<option value="tls">TLS</option>
<option value="starttls">STARTTLS</option>
<option value="none">无加密</option>
</select>
</div>
<div class="space-y-2">
<Label for="smtp-port">端口</Label>
<Input id="smtp-port" type="number" min="1" max="65535" bind:value={smtpPort} />
</div>
</div>
<div class="space-y-2">
<Label for="smtp-username">用户名(可选)</Label>
<Input id="smtp-username" placeholder="SMTP 登录用户名" bind:value={smtpUsername} />
</div>
<div class="space-y-2">
<Label for="smtp-password">密码/授权码(可选)</Label>
<PasswordInput id="smtp-password" placeholder="密码或邮箱授权码" bind:value={smtpPassword} />
<p class="text-muted-foreground text-xs">无需认证时,用户名和密码都留空。</p>
</div>
<div class="space-y-2">
<Label for="smtp-from">发件人</Label>
<Input id="smtp-from" placeholder="sender@example.com" bind:value={smtpFrom} />
</div>
<div class="space-y-2">
<Label for="smtp-to">收件人</Label>
<Input id="smtp-to" placeholder="recipient@example.com" bind:value={smtpTo} />
</div>
{/if}
</div>