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:
2026-07-07 09:54:42 +08:00
parent 2669861af1
commit 3d77733e11
34 changed files with 2818 additions and 1146 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-mail"
version = "0.1.7"
version = "0.1.13"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -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)?;
+3
View File
@@ -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
View File
@@ -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 {
"&amp;" => Some("&"),
"&lt;" => Some("<"),
"&gt;" => Some(">"),
"&quot;" => Some("\""),
"&#39;" | "&apos;" => Some("'"),
"&nbsp;" => 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&nbsp;<strong>world</strong></div>");
assert_eq!(preview_text(&preview, 100), "Hello world");
}
}
+108 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff