Skip to main content

alun_plugin/
notification.rs

1//! 通知插件:邮件发送(基于 lettre),支持纯文本和 HTML 格式
2
3use async_trait::async_trait;
4use alun_core::{Plugin, Result};
5use alun_config::NotificationConfig;
6use lettre::{
7    Transport, Message,
8    message::{Mailbox, MultiPart},
9    transport::smtp::{authentication::Credentials, SmtpTransport},
10};
11use tracing::{info, error};
12
13/// 通知插件:SMTP 邮件发送(基于 lettre)
14///
15/// 从 `NotificationConfig` 读取 SMTP 连接信息,启动时建立连接。
16/// 根据发件人邮箱域名自动选择 `STARTTLS` 或默认 `RELAY` 传输方式。
17/// 若未启用或不可达,`send_text()` / `send_html()` 降级为跳过(不报错)。
18pub struct NotificationPlugin {
19    /// SMTP 配置
20    config: NotificationConfig,
21    /// SMTP transport(未配置时为 None)
22    mailer: Option<SmtpTransport>,
23    /// 发件人邮箱
24    from: Option<Mailbox>,
25}
26
27impl NotificationPlugin {
28    /// 从配置创建通知插件
29    ///
30    /// 若 `smtp_host` 为空或 `enabled = false`,则邮件功能降级为跳过模式。
31    /// iCloud / Swisscows 等邮箱使用 `STARTTLS` 加密传输,其他使用默认 `RELAY`。
32    pub fn from_config(config: &NotificationConfig) -> Self {
33        let (mailer, from) = if config.enabled && !config.smtp_host.is_empty() && !config.smtp_user.is_empty() {
34            let from_email = if config.from_email.is_empty() { &config.smtp_user } else { &config.from_email };
35            let creds = Credentials::new(config.smtp_user.clone(), config.smtp_pass.clone());
36
37            let builder = if from_email.ends_with("@icloud.com") || from_email.ends_with("@swisscows.email") {
38                SmtpTransport::starttls_relay(&config.smtp_host)
39            } else {
40                SmtpTransport::relay(&config.smtp_host)
41            };
42
43            let builder = match builder {
44                Ok(b) => b.port(config.smtp_port).credentials(creds),
45                Err(e) => {
46                    error!("SMTP 传输初始化失败: {}", e);
47                    return Self { config: config.clone(), mailer: None, from: None };
48                }
49            };
50
51            let transport = builder.build();
52
53            let from_name = if config.from_name.is_empty() { "系统通知" } else { &config.from_name };
54            let from_mb = format!("{} <{}>", from_name, from_email)
55                .parse::<Mailbox>()
56                .ok();
57
58            (Some(transport), from_mb)
59        } else {
60            (None, None)
61        };
62
63        Self { config: config.clone(), mailer, from }
64    }
65
66    /// 发送纯文本邮件
67    pub fn send_text(&self, to: &str, subject: &str, body: &str) -> Result<()> {
68        if let (Some(ref mailer), Some(ref from)) = (&self.mailer, &self.from) {
69            let to_mb: Mailbox = to.parse().map_err(|e| {
70                alun_core::Error::Msg(format!("收件人地址无效: {}", e))
71            })?;
72            let email = Message::builder()
73                .from(from.clone())
74                .to(to_mb)
75                .subject(subject)
76                .body(body.to_string())
77                .map_err(|e| alun_core::Error::Msg(format!("邮件构建失败: {}", e)))?;
78
79            mailer.send(&email).map_err(|e| {
80                error!("邮件发送失败: {}", e);
81                alun_core::Error::Msg(format!("邮件发送失败: {}", e))
82            })?;
83            info!("邮件已发送 to={} subject={}", to, subject);
84            Ok(())
85        } else {
86            info!("邮件功能未配置,跳过发送: to={} subject={}", to, subject);
87            Ok(())
88        }
89    }
90
91    /// 发送 HTML 邮件(同时附带纯文本版本,适配不同邮件客户端)
92    ///
93    /// `html_body` 为 HTML 格式的邮件正文,内部自动生成纯文本备用版本。
94    pub fn send_html(&self, to: &str, subject: &str, html_body: &str) -> Result<()> {
95        if let (Some(ref mailer), Some(ref from)) = (&self.mailer, &self.from) {
96            let to_mb: Mailbox = to.parse().map_err(|e| {
97                alun_core::Error::Msg(format!("收件人地址无效: {}", e))
98            })?;
99            let plain_text = Self::html_to_text(html_body);
100            let email = Message::builder()
101                .from(from.clone())
102                .to(to_mb)
103                .subject(subject)
104                .multipart(MultiPart::alternative_plain_html(
105                    plain_text,
106                    html_body.to_string(),
107                ))
108                .map_err(|e| alun_core::Error::Msg(format!("邮件构建失败: {}", e)))?;
109
110            mailer.send(&email).map_err(|e| {
111                error!("邮件发送失败: {}", e);
112                alun_core::Error::Msg(format!("邮件发送失败: {}", e))
113            })?;
114            info!("HTML 邮件已发送 to={} subject={}", to, subject);
115            Ok(())
116        } else {
117            info!("邮件功能未配置,跳过发送: to={} subject={}", to, subject);
118            Ok(())
119        }
120    }
121
122    /// 邮件功能是否已配置
123    pub fn is_configured(&self) -> bool {
124        self.mailer.is_some()
125    }
126
127    /// 将 HTML 正文转为纯文本(去除标签并解码常见实体)
128    fn html_to_text(html: &str) -> String {
129        let mut text = String::with_capacity(html.len());
130        let mut in_tag = false;
131
132        for ch in html.chars() {
133            match ch {
134                '<' => in_tag = true,
135                '>' => in_tag = false,
136                _ if !in_tag => text.push(ch),
137                _ => {}
138            }
139        }
140
141        let text = text
142            .replace("&amp;", "&")
143            .replace("&lt;", "<")
144            .replace("&gt;", ">")
145            .replace("&quot;", "\"")
146            .replace("&#39;", "'")
147            .replace("&nbsp;", " ");
148
149        let mut result = String::with_capacity(text.len());
150        let mut prev_was_whitespace = false;
151        for ch in text.chars() {
152            if ch == '\n' {
153                result.push('\n');
154                prev_was_whitespace = true;
155            } else if ch.is_whitespace() {
156                if !prev_was_whitespace {
157                    result.push(' ');
158                    prev_was_whitespace = true;
159                }
160            } else {
161                result.push(ch);
162                prev_was_whitespace = false;
163            }
164        }
165
166        result.trim().to_string()
167    }
168}
169
170#[async_trait]
171impl Plugin for NotificationPlugin {
172    fn name(&self) -> &str { "notification" }
173
174    async fn start(&self) -> Result<()> {
175        if self.is_configured() {
176            info!("通知插件就绪: SMTP {}:{}", self.config.smtp_host, self.config.smtp_port);
177        } else {
178            info!("通知插件: 未配置(跳过)");
179        }
180        Ok(())
181    }
182
183    async fn stop(&self) -> Result<()> { Ok(()) }
184}