feat: 邮件已读/未读状态持久化与 Web 页面增强 (0.2.8)
- 邮件列表/详情页显示已读/未读状态,支持按状态筛选 - 邮件 API GET /v1/mail/messages 支持 seen 过滤 - 数据库 ias_mail_messages 新增 seen 字段 - 新增邮件查询 YAML 工具 query_mail_messages - 新增 /tools 和 /configs Web 页面 - 新增 ias-http shell 和 ias-service console 模块 - 补充历史版本升级日志迁移 SQL
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ias-mail"
|
||||
version = "0.1.7"
|
||||
version = "0.1.13"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -20,7 +20,7 @@ pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
|
||||
let notify_account_id = prompt_required("通知使用的微信 account_id", None)?;
|
||||
let notify_user_id = prompt_required("通知接收人的 from_user_id", None)?;
|
||||
let poll_seconds = prompt_i32("轮询间隔秒数", 60, 10, 86400)?;
|
||||
let fetch_limit = prompt_i32("单次最多抓取邮件数", 10, 1, 100)?;
|
||||
let fetch_limit = prompt_i32("单次最多抓取邮件数", 20, 1, 100)?;
|
||||
let accept_invalid_certs = prompt_bool("是否接受无效 TLS 证书", false)?;
|
||||
let enabled = prompt_bool("是否启用该邮件账户", true)?;
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ pub struct MailMessage {
|
||||
pub content: String,
|
||||
pub content_preview: String,
|
||||
pub raw_headers: Option<String>,
|
||||
pub seen: bool,
|
||||
pub preview_page_id: Option<i64>,
|
||||
pub preview_url: Option<String>,
|
||||
pub received_at: Option<DateTime<Utc>>,
|
||||
@@ -75,6 +76,7 @@ pub struct NewMailMessage {
|
||||
pub content: String,
|
||||
pub content_preview: String,
|
||||
pub raw_headers: Option<String>,
|
||||
pub seen: bool,
|
||||
pub received_at: Option<DateTime<Utc>>,
|
||||
pub raw_size: i64,
|
||||
}
|
||||
@@ -95,4 +97,5 @@ pub struct MailListQuery {
|
||||
pub account_id: Option<i64>,
|
||||
pub mailbox: Option<String>,
|
||||
pub source_key: Option<String>,
|
||||
pub seen: Option<bool>,
|
||||
}
|
||||
|
||||
+157
-31
@@ -16,7 +16,7 @@ use crate::config::MailPollerConfig;
|
||||
use crate::model::{MailMessage, MailNotification, NewMailMessage};
|
||||
use crate::repository::MailRepository;
|
||||
|
||||
const RECENT_MAIL_FETCH_LIMIT: u32 = 20;
|
||||
const AUTOMATIC_POLL_UNREAD_ONLY: bool = false;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FetchedMail {
|
||||
@@ -27,6 +27,7 @@ struct FetchedMail {
|
||||
subject: String,
|
||||
summary: String,
|
||||
content: String,
|
||||
preview_content: String,
|
||||
raw_headers: Option<String>,
|
||||
received_at: Option<DateTime<Utc>>,
|
||||
raw_size: i64,
|
||||
@@ -48,12 +49,13 @@ pub fn start_mail_polling(
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
println!(
|
||||
"邮件轮询已启动: {} {}@{} mailbox={} interval={}s",
|
||||
"邮件轮询已启动: {} {}@{} mailbox={} interval={}s fetch_limit={}",
|
||||
config.name,
|
||||
config.username,
|
||||
config.host,
|
||||
config.mailbox,
|
||||
config.poll_interval.as_secs()
|
||||
config.poll_interval.as_secs(),
|
||||
config.fetch_limit
|
||||
);
|
||||
|
||||
loop {
|
||||
@@ -79,7 +81,13 @@ async fn poll_once(
|
||||
config: &MailPollerConfig,
|
||||
notifications: &Sender<MailNotification>,
|
||||
) -> anyhow::Result<()> {
|
||||
pull_recent_mail_with_notifications(pool, config, Some(notifications), true).await?;
|
||||
pull_recent_mail_with_notifications(
|
||||
pool,
|
||||
config,
|
||||
Some(notifications),
|
||||
AUTOMATIC_POLL_UNREAD_ONLY,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -106,30 +114,31 @@ async fn pull_recent_mail_with_notifications(
|
||||
};
|
||||
|
||||
for fetched in messages {
|
||||
if unread_only && fetched.seen {
|
||||
if should_skip_fetched_message(fetched.seen, unread_only) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(inserted) = MailRepository::insert_if_new(
|
||||
pool,
|
||||
NewMailMessage {
|
||||
mail_account_id: config.mail_account_id,
|
||||
source_key: config.source_key.clone(),
|
||||
mailbox: config.mailbox.clone(),
|
||||
uid: fetched.uid.clone(),
|
||||
message_id: fetched.message_id.clone(),
|
||||
from_label: fetched.from_label.clone(),
|
||||
subject: fetched.subject.clone(),
|
||||
summary: fetched.summary.clone(),
|
||||
content: fetched.content.clone(),
|
||||
content_preview: preview_text(&fetched.content, 4000),
|
||||
raw_headers: fetched.raw_headers.clone(),
|
||||
received_at: fetched.received_at,
|
||||
raw_size: fetched.raw_size,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
let message = NewMailMessage {
|
||||
mail_account_id: config.mail_account_id,
|
||||
source_key: config.source_key.clone(),
|
||||
mailbox: config.mailbox.clone(),
|
||||
uid: fetched.uid.clone(),
|
||||
message_id: fetched.message_id.clone(),
|
||||
from_label: fetched.from_label.clone(),
|
||||
subject: fetched.subject.clone(),
|
||||
summary: fetched.summary.clone(),
|
||||
content: fetched.content.clone(),
|
||||
content_preview: preview_text(&fetched.preview_content, 4000),
|
||||
raw_headers: fetched.raw_headers.clone(),
|
||||
seen: fetched.seen,
|
||||
received_at: fetched.received_at,
|
||||
raw_size: fetched.raw_size,
|
||||
};
|
||||
|
||||
let Some(inserted) = MailRepository::insert_if_new(pool, message.clone()).await? else {
|
||||
if let Err(error) = MailRepository::refresh_cached_message(pool, message).await {
|
||||
eprintln!("刷新已缓存邮件失败 uid={}: {error:#}", fetched.uid);
|
||||
}
|
||||
result.duplicates += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -189,7 +198,8 @@ async fn fetch_recent_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
|
||||
.await
|
||||
.with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?;
|
||||
|
||||
let Some(sequence_set) = recent_sequence_set(mailbox.exists, RECENT_MAIL_FETCH_LIMIT) else {
|
||||
let fetch_limit = config.fetch_limit.clamp(1, 100) as u32;
|
||||
let Some(sequence_set) = recent_sequence_set(mailbox.exists, fetch_limit) else {
|
||||
let _ = session.logout().await;
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
@@ -232,6 +242,10 @@ fn recent_sequence_set(exists: u32, limit: u32) -> Option<String> {
|
||||
Some(format!("{start}:{exists}"))
|
||||
}
|
||||
|
||||
fn should_skip_fetched_message(seen: bool, unread_only: bool) -> bool {
|
||||
unread_only && seen
|
||||
}
|
||||
|
||||
fn is_seen(fetch: &Fetch) -> bool {
|
||||
fetch.flags().any(|flag| matches!(flag, Flag::Seen))
|
||||
}
|
||||
@@ -277,12 +291,21 @@ fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedM
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("(无主题)")
|
||||
.to_string();
|
||||
let content = parsed
|
||||
let text_body = parsed
|
||||
.body_text(0)
|
||||
.or_else(|| parsed.body_html(0))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| !value.is_empty());
|
||||
let html_body = parsed
|
||||
.body_html(0)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let content = html_body
|
||||
.clone()
|
||||
.or_else(|| text_body.clone())
|
||||
.unwrap_or_else(|| "(邮件正文为空或暂不支持解析)".to_string());
|
||||
let preview_content = text_body
|
||||
.clone()
|
||||
.unwrap_or_else(|| html_to_preview_text(&content));
|
||||
let from_label = header_value(raw, "From").unwrap_or_else(|| "(未知发件人)".to_string());
|
||||
let message_id = header_value(raw, "Message-ID");
|
||||
let raw_headers = raw_headers(raw);
|
||||
@@ -290,7 +313,7 @@ fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedM
|
||||
.date()
|
||||
.and_then(|date| DateTime::parse_from_rfc3339(&date.to_rfc3339()).ok())
|
||||
.map(|time| time.with_timezone(&Utc));
|
||||
let summary = summarize_mail(&from_label, &subject, &content);
|
||||
let summary = summarize_mail(&from_label, &subject, &preview_content);
|
||||
|
||||
Ok(FetchedMail {
|
||||
uid,
|
||||
@@ -300,6 +323,7 @@ fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedM
|
||||
subject,
|
||||
summary,
|
||||
content,
|
||||
preview_content,
|
||||
raw_headers,
|
||||
received_at,
|
||||
raw_size: raw.len() as i64,
|
||||
@@ -370,6 +394,61 @@ fn preview_text(content: &str, max_chars: usize) -> String {
|
||||
value
|
||||
}
|
||||
|
||||
fn html_to_preview_text(content: &str) -> String {
|
||||
let mut text = String::with_capacity(content.len());
|
||||
let mut in_tag = false;
|
||||
let mut entity = String::new();
|
||||
|
||||
for ch in content.chars() {
|
||||
if in_tag {
|
||||
if ch == '>' {
|
||||
in_tag = false;
|
||||
text.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '<' {
|
||||
in_tag = true;
|
||||
text.push(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
if !entity.is_empty() {
|
||||
entity.push(ch);
|
||||
if ch == ';' || entity.len() > 12 {
|
||||
text.push_str(decode_html_entity(&entity).unwrap_or(&entity));
|
||||
entity.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '&' {
|
||||
entity.push(ch);
|
||||
} else {
|
||||
text.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
if !entity.is_empty() {
|
||||
text.push_str(&entity);
|
||||
}
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
fn decode_html_entity(entity: &str) -> Option<&'static str> {
|
||||
match entity {
|
||||
"&" => Some("&"),
|
||||
"<" => Some("<"),
|
||||
">" => Some(">"),
|
||||
""" => Some("\""),
|
||||
"'" | "'" => Some("'"),
|
||||
" " => Some(" "),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn header_value(raw: &[u8], name: &str) -> Option<String> {
|
||||
let raw = String::from_utf8_lossy(raw);
|
||||
let header_block = split_headers(raw.as_ref());
|
||||
@@ -424,7 +503,10 @@ fn split_headers(raw: &str) -> &str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{header_value, preview_text, recent_sequence_set, summarize_mail};
|
||||
use super::{
|
||||
AUTOMATIC_POLL_UNREAD_ONLY, header_value, html_to_preview_text, parse_message,
|
||||
preview_text, recent_sequence_set, should_skip_fetched_message, summarize_mail,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn builds_recent_sequence_set_for_latest_messages() {
|
||||
@@ -432,6 +514,18 @@ mod tests {
|
||||
assert_eq!(recent_sequence_set(8, 20).as_deref(), Some("1:8"));
|
||||
assert_eq!(recent_sequence_set(20, 20).as_deref(), Some("1:20"));
|
||||
assert_eq!(recent_sequence_set(25, 20).as_deref(), Some("6:25"));
|
||||
assert_eq!(recent_sequence_set(25, 10).as_deref(), Some("16:25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_polling_processes_new_messages_even_when_seen() {
|
||||
assert!(!AUTOMATIC_POLL_UNREAD_ONLY);
|
||||
assert!(!should_skip_fetched_message(
|
||||
true,
|
||||
AUTOMATIC_POLL_UNREAD_ONLY
|
||||
));
|
||||
assert!(should_skip_fetched_message(true, true));
|
||||
assert!(!should_skip_fetched_message(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -456,4 +550,36 @@ mod tests {
|
||||
assert!(summary.contains("Hello"));
|
||||
assert!(summary.contains("first line second line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_html_body_for_display_and_text_body_for_preview() {
|
||||
let raw = concat!(
|
||||
"From: Facebook <reminders@facebookmail.com>\r\n",
|
||||
"Subject: Friend suggestion\r\n",
|
||||
"Content-Type: multipart/alternative; boundary=\"b\"\r\n",
|
||||
"\r\n",
|
||||
"--b\r\n",
|
||||
"Content-Type: text/plain; charset=utf-8\r\n",
|
||||
"\r\n",
|
||||
"Plain fallback\r\n",
|
||||
"--b\r\n",
|
||||
"Content-Type: text/html; charset=utf-8\r\n",
|
||||
"\r\n",
|
||||
"<html><body><table><tr><td>Rich HTML</td></tr></table></body></html>\r\n",
|
||||
"--b--\r\n"
|
||||
);
|
||||
|
||||
let message = parse_message("1".to_string(), false, raw.as_bytes()).unwrap();
|
||||
|
||||
assert!(message.content.contains("<table>"));
|
||||
assert_eq!(message.preview_content, "Plain fallback");
|
||||
assert!(message.summary.contains("Plain fallback"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_html_for_preview_when_plain_body_is_missing() {
|
||||
let preview = html_to_preview_text("<div>Hello <strong>world</strong></div>");
|
||||
|
||||
assert_eq!(preview_text(&preview, 100), "Hello world");
|
||||
}
|
||||
}
|
||||
|
||||
+108
-2
@@ -209,10 +209,11 @@ impl MailRepository {
|
||||
content,
|
||||
content_preview,
|
||||
raw_headers,
|
||||
seen,
|
||||
received_at,
|
||||
raw_size
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
ON CONFLICT (source_key, mailbox, uid) DO NOTHING
|
||||
RETURNING
|
||||
id,
|
||||
@@ -227,6 +228,7 @@ impl MailRepository {
|
||||
content,
|
||||
content_preview,
|
||||
raw_headers,
|
||||
seen,
|
||||
preview_page_id,
|
||||
preview_url,
|
||||
received_at,
|
||||
@@ -245,6 +247,68 @@ impl MailRepository {
|
||||
.bind(message.content)
|
||||
.bind(message.content_preview)
|
||||
.bind(message.raw_headers)
|
||||
.bind(message.seen)
|
||||
.bind(message.received_at)
|
||||
.bind(message.raw_size)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn refresh_cached_message(
|
||||
pool: &PgPool,
|
||||
message: NewMailMessage,
|
||||
) -> anyhow::Result<Option<MailMessage>> {
|
||||
let row = sqlx::query_as::<_, MailMessage>(
|
||||
r#"
|
||||
UPDATE ias_mail_messages
|
||||
SET
|
||||
mail_account_id = $4,
|
||||
message_id = $5,
|
||||
from_label = $6,
|
||||
subject = $7,
|
||||
summary = $8,
|
||||
content = $9,
|
||||
content_preview = $10,
|
||||
raw_headers = $11,
|
||||
seen = $12,
|
||||
received_at = $13,
|
||||
raw_size = $14
|
||||
WHERE source_key = $1 AND mailbox = $2 AND uid = $3
|
||||
RETURNING
|
||||
id,
|
||||
mail_account_id,
|
||||
source_key,
|
||||
mailbox,
|
||||
uid,
|
||||
message_id,
|
||||
from_label,
|
||||
subject,
|
||||
summary,
|
||||
content,
|
||||
content_preview,
|
||||
raw_headers,
|
||||
seen,
|
||||
preview_page_id,
|
||||
preview_url,
|
||||
received_at,
|
||||
raw_size,
|
||||
created_at
|
||||
"#,
|
||||
)
|
||||
.bind(message.source_key)
|
||||
.bind(message.mailbox)
|
||||
.bind(message.uid)
|
||||
.bind(message.mail_account_id)
|
||||
.bind(message.message_id)
|
||||
.bind(message.from_label)
|
||||
.bind(message.subject)
|
||||
.bind(message.summary)
|
||||
.bind(message.content)
|
||||
.bind(message.content_preview)
|
||||
.bind(message.raw_headers)
|
||||
.bind(message.seen)
|
||||
.bind(message.received_at)
|
||||
.bind(message.raw_size)
|
||||
.fetch_optional(pool)
|
||||
@@ -277,6 +341,7 @@ impl MailRepository {
|
||||
content,
|
||||
content_preview,
|
||||
raw_headers,
|
||||
seen,
|
||||
preview_page_id,
|
||||
preview_url,
|
||||
received_at,
|
||||
@@ -309,6 +374,7 @@ impl MailRepository {
|
||||
content,
|
||||
content_preview,
|
||||
raw_headers,
|
||||
seen,
|
||||
preview_page_id,
|
||||
preview_url,
|
||||
received_at,
|
||||
@@ -345,6 +411,7 @@ impl MailRepository {
|
||||
($1::BIGINT IS NULL OR mail_account_id = $1)
|
||||
AND ($2::TEXT IS NULL OR mailbox = $2)
|
||||
AND ($3::TEXT IS NULL OR source_key = $3)
|
||||
AND ($5::BOOLEAN IS NULL OR seen = $5)
|
||||
AND (
|
||||
$4::TEXT IS NULL
|
||||
OR from_label ILIKE ('%' || $4 || '%')
|
||||
@@ -359,6 +426,7 @@ impl MailRepository {
|
||||
.bind(mailbox.as_deref())
|
||||
.bind(source_key.as_deref())
|
||||
.bind(keyword.as_deref())
|
||||
.bind(query.seen)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
@@ -377,6 +445,7 @@ impl MailRepository {
|
||||
content,
|
||||
content_preview,
|
||||
raw_headers,
|
||||
seen,
|
||||
preview_page_id,
|
||||
preview_url,
|
||||
received_at,
|
||||
@@ -387,6 +456,7 @@ impl MailRepository {
|
||||
($1::BIGINT IS NULL OR mail_account_id = $1)
|
||||
AND ($2::TEXT IS NULL OR mailbox = $2)
|
||||
AND ($3::TEXT IS NULL OR source_key = $3)
|
||||
AND ($5::BOOLEAN IS NULL OR seen = $5)
|
||||
AND (
|
||||
$4::TEXT IS NULL
|
||||
OR from_label ILIKE ('%' || $4 || '%')
|
||||
@@ -396,13 +466,14 @@ impl MailRepository {
|
||||
OR message_id ILIKE ('%' || $4 || '%')
|
||||
)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $5 OFFSET $6
|
||||
LIMIT $6 OFFSET $7
|
||||
"#,
|
||||
)
|
||||
.bind(query.account_id)
|
||||
.bind(mailbox.as_deref())
|
||||
.bind(source_key.as_deref())
|
||||
.bind(keyword.as_deref())
|
||||
.bind(query.seen)
|
||||
.bind(per_page)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
@@ -427,6 +498,7 @@ impl MailRepository {
|
||||
content,
|
||||
content_preview,
|
||||
raw_headers,
|
||||
seen,
|
||||
preview_page_id,
|
||||
preview_url,
|
||||
received_at,
|
||||
@@ -617,6 +689,7 @@ impl MailRepository {
|
||||
content,
|
||||
content_preview,
|
||||
raw_headers,
|
||||
seen,
|
||||
preview_page_id,
|
||||
preview_url,
|
||||
received_at,
|
||||
@@ -634,6 +707,39 @@ impl MailRepository {
|
||||
|
||||
Ok((rows, total.0))
|
||||
}
|
||||
|
||||
pub async fn find_adjacent_ids(
|
||||
pool: &PgPool,
|
||||
id: i64,
|
||||
) -> anyhow::Result<(Option<i64>, Option<i64>)> {
|
||||
let prev: Option<(i64,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id
|
||||
FROM ias_mail_messages
|
||||
WHERE id < $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let next: Option<(i64,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id
|
||||
FROM ias_mail_messages
|
||||
WHERE id > $1
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok((prev.map(|r| r.0), next.map(|r| r.0)))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_filter(value: Option<&str>) -> Option<String> {
|
||||
|
||||
+703
-660
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user