mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-10-16 21:12:47 +08:00
feat: 支持 SMTP 邮件通知 (#747)
This commit is contained in:
Generated
+82
-7
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user