feat: 统一 JSON API 到 /v1 前缀,移除旧邮件兼容路由

- 删除旧邮件查询兼容路由 GET /mail-api/messages、/mail-api/messages/{id}、/mail-api/search
- 所有 JSON API 统一使用 /v1/...:邮件、上下文记忆、动态页面创建和健康检查接口
- 页面路由保持非 /v1 地址:/mail、/mail/messages/{id}、/mail/{slug}、/web/{page_type}/{slug}、/updates
- AI 内部工具调用同步改用 /v1/web/pages 与 /v1/context/memories...
- 无数据库结构变化,新增 0.1.14 升级日志记录
This commit is contained in:
2026-07-06 21:39:56 +08:00
parent 25dd0daa8b
commit 2669861af1
29 changed files with 1845 additions and 89 deletions
+55
View File
@@ -1,5 +1,60 @@
# 更新日志
## 0.1.14 - 2026-07-06
- 删除旧邮件查询兼容路由 `GET /mail-api/messages``GET /mail-api/messages/{id}``GET /mail-api/search`
- 所有 JSON API 统一使用 `/v1/...`:邮件、上下文记忆、动态页面创建和健康检查接口均迁移到新地址。
- 页面路由继续保持非 `/v1` 地址:`/mail``/mail/messages/{id}``/mail/{slug}``/web/{page_type}/{slug}``/updates`
- AI 内部工具调用同步改用 `/v1/web/pages``/v1/context/memories...`
- 无数据库结构变化;新增 `0.1.14` 升级日志记录。
## 0.1.13 - 2026-07-06
- 新增 `GET /v1/mail/messages``GET /v1/mail/messages/{id}``GET /v1/mail/search`,作为邮件查询 API 的新地址。
- 保留 `GET /mail-api/messages``GET /mail-api/messages/{id}``GET /mail-api/search` 兼容旧调用。
- 新增 `GET /mail` 邮件列表页面,支持关键词、账户、邮箱目录、来源和分页查询。
- 新增 `GET /mail/messages/{id}` 邮件预览页面,可查看邮件正文、元信息和原始头信息。
- 无数据库结构变化;新增 `0.1.13` 升级日志记录。
## 0.1.12 - 2026-07-06
- 增强邮件 HTTP 查询服务:`GET /mail-api/messages` 支持关键词、账户、邮箱目录、来源和分页查询。
- 新增 `GET /mail-api/search` 作为邮件查询别名,便于客户端直接按查询语义调用。
- 邮件列表响应新增 `page``per_page``total` 字段,保留 `limit` 参数的兼容行为。
- 查询关键词 `q` 会匹配发件人、主题、摘要、正文和 `Message-ID`
- 无数据库结构变化;新增 `0.1.12` 升级日志记录。
## 0.1.11 - 2026-07-06
- 修正 `ias mail pull [id]` 的手动拉取语义:现在会抓取最近 20 封邮件并入库,不再只处理未读邮件。
- `ias mail pull` 输出新增扫描数量,便于确认 IMAP 确实返回了最近邮件;同时继续显示其中未读、新增和已存在数量。
- 后台邮件轮询保持只处理未读邮件并发送微信提醒,手动拉取不发送微信提醒。
- 无数据库结构变化;新增 `0.1.11` 升级日志记录。
## 0.1.10 - 2026-07-06
- 新增 `ias mail pull [id]` 命令,可手动拉取最近 20 封中的未读邮件;不传 `id` 时拉取所有启用邮件账户,传 `id` 时拉取指定账户。
- 手动拉取会复用后台轮询的解析、去重、入库和邮件预览页生成逻辑,并输出本次未读、新增和已存在数量。
- 修复服务启动和版本更新通知使用 `ias-service` crate 版本的问题,改为读取根目录 `VERSION` 作为整体项目版本。
- 修复版本更新通知可能链接到不存在的 `/updates/0.1.0` 页面的问题;本版本通知会指向 `/updates/0.1.10`
- 无数据库结构变化;新增 `0.1.10` 升级日志记录。
## 0.1.9 - 2026-07-06
- 调整邮件轮询逻辑:每轮固定读取邮箱最近 20 封邮件,而不是仅搜索 `UNSEEN` 集合。
- 读取最近邮件时同步获取 `FLAGS`,只对未包含 `\Seen` 标记的邮件入库、生成预览页并发送微信提醒。
- 邮件正文抓取改用 `BODY.PEEK[]`,避免检查最近邮件时把未读邮件隐式标记为已读。
- 新增最近 20 封 sequence range 的单元测试,并通过 `cargo test -p ias-mail``cargo check` 验证。
- 无数据库结构变化;新增 `0.1.9` 升级日志记录。
## 0.1.8 - 2026-07-06
- 新增 `ias mail edit <id>` 命令,可交互式修改已保存的 IMAP 邮件账户配置,密码留空时保留原值。
- 新增 `ias mail check <id>` 命令,可使用已保存配置检查 IMAP 服务器连通性和邮箱目录可访问性。
- 邮件账户检查成功会更新 `last_checked_at` 并清空 `last_error`,失败会记录错误信息。
- 复用新增账户时的 IMAP 连接测试逻辑,TLS 证书错误仍支持交互式选择接受无效证书后重试。
- 无数据库结构变化;新增 `0.1.8` 升级日志记录。
## 0.1.7 - 2026-07-06
- 所有 Rust crate 统一加 `ias-` 前缀:`ai``ias-ai``common``ias-common``context``ias-context``mail``ias-mail``service``ias-service``wechat``ias-wechat`
Generated
+6 -6
View File
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "ias-ai"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"chrono",
@@ -1066,7 +1066,7 @@ dependencies = [
[[package]]
name = "ias-context"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"axum",
@@ -1080,7 +1080,7 @@ dependencies = [
[[package]]
name = "ias-http"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"axum",
@@ -1094,7 +1094,7 @@ dependencies = [
[[package]]
name = "ias-mail"
version = "0.1.0"
version = "0.1.7"
dependencies = [
"anyhow",
"async-imap",
@@ -1113,7 +1113,7 @@ dependencies = [
[[package]]
name = "ias-main"
version = "0.1.7"
version = "0.1.14"
dependencies = [
"anyhow",
"clap",
@@ -1128,7 +1128,7 @@ dependencies = [
[[package]]
name = "ias-service"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1 +1 @@
0.1.7
0.1.14
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-ai"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
[dependencies]
+4 -4
View File
@@ -77,7 +77,7 @@ async fn remember_memory_inner(
"metadata": args.metadata.unwrap_or_else(|| json!({})),
}),
);
let value = request_json("POST", "/context/memories", payload).await?;
let value = request_json("POST", "/v1/context/memories", payload).await?;
let memory = value
.get("memory")
.ok_or_else(|| anyhow!("接口响应缺少 memory 字段"))?;
@@ -98,7 +98,7 @@ async fn query_memory_inner(channel: &ChannelMessage, arguments: &str) -> anyhow
"limit": args.limit.unwrap_or(20),
}),
);
let value = request_json("POST", "/context/memories/search", payload).await?;
let value = request_json("POST", "/v1/context/memories/search", payload).await?;
let memories = value
.get("memories")
.and_then(Value::as_array)
@@ -128,7 +128,7 @@ async fn update_memory_inner(channel: &ChannelMessage, arguments: &str) -> anyho
"metadata": args.metadata,
}),
);
let path = format!("/context/memories/{}", args.id);
let path = format!("/v1/context/memories/{}", args.id);
let value = request_json("PATCH", &path, payload).await?;
let memory = value
.get("memory")
@@ -144,7 +144,7 @@ async fn delete_memory_inner(channel: &ChannelMessage, arguments: &str) -> anyho
let args: DeleteMemoryArgs =
serde_json::from_str(arguments).context("解析 delete_memory 参数失败")?;
let payload = json_with_identity(channel, json!({}));
let path = format!("/context/memories/{}", args.id);
let path = format!("/v1/context/memories/{}", args.id);
let value = request_json("DELETE", &path, payload).await?;
let deleted = value
.get("deleted")
+1 -1
View File
@@ -50,7 +50,7 @@ async fn create_web_page_inner(arguments: &str) -> anyhow::Result<String> {
"metadata": args.metadata,
});
let base_url = web_base_url();
let endpoint = format!("{base_url}/web/pages");
let endpoint = format!("{base_url}/v1/web/pages");
let client = reqwest::Client::new();
let response = client
.post(&endpoint)
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-context"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
[dependencies]
+3 -3
View File
@@ -29,10 +29,10 @@ where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/context/memories", post(create_memory))
.route("/context/memories/search", post(search_memories))
.route("/v1/context/memories", post(create_memory))
.route("/v1/context/memories/search", post(search_memories))
.route(
"/context/memories/{id}",
"/v1/context/memories/{id}",
patch(update_memory).delete(delete_memory),
)
.layer(Extension(ContextRouteState::new(db)))
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-http"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -13,7 +13,7 @@ pub async fn create_http(
extra_routes: Router,
) -> anyhow::Result<()> {
let app = Router::new()
.route("/health", get(health))
.route("/v1/health", get(health))
.merge(update_routes::<()>(db.clone()))
.merge(web_routes::routes::<()>(db))
.merge(extra_routes);
+2 -2
View File
@@ -31,8 +31,8 @@ where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/web/pages", post(create_page))
.route("/web/mail", post(create_mail_page))
.route("/v1/web/pages", post(create_page))
.route("/v1/web/mail", post(create_mail_page))
.route("/mail/{slug}", get(show_mail_page))
.route("/web/{page_type}/{slug}", get(show_typed_page))
.layer(Extension(WebRouteState::new(db)))
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-mail"
version = "0.1.0"
version = "0.1.7"
edition = "2024"
[dependencies]
+351 -21
View File
@@ -5,7 +5,7 @@ use sqlx::PgPool;
use crate::config::MailPollerConfig;
use crate::model::NewMailAccount;
use crate::poller::test_connection;
use crate::poller::{pull_recent_mail, test_connection};
use crate::repository::MailRepository;
pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
@@ -24,7 +24,7 @@ pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
let accept_invalid_certs = prompt_bool("是否接受无效 TLS 证书", false)?;
let enabled = prompt_bool("是否启用该邮件账户", true)?;
let account = NewMailAccount {
let mut account = NewMailAccount {
name,
source_key: format!("imap:{host}:{username}"),
imap_host: host,
@@ -41,25 +41,7 @@ pub async fn auth_account(pool: &PgPool) -> anyhow::Result<()> {
};
if prompt_bool("保存前测试 IMAP 连接", true)? {
let config = MailPollerConfig {
mail_account_id: None,
name: account.name.clone(),
host: account.imap_host.clone(),
port: account.imap_port as u16,
username: account.imap_username.clone(),
password: account.imap_password.clone(),
mailbox: account.mailbox.clone(),
source_key: account.source_key.clone(),
notify_account_id: account.notify_account_id.clone(),
notify_user_id: account.notify_user_id.clone(),
poll_interval: Duration::from_secs(account.poll_seconds as u64),
fetch_limit: account.fetch_limit as usize,
accept_invalid_certs: account.accept_invalid_certs,
};
print!("正在测试 IMAP 连接... ");
io::stdout().flush()?;
test_connection(config).await?;
println!("成功");
test_account_interactively(&mut account).await?;
}
let saved = MailRepository::upsert_account(pool, account).await?;
@@ -102,6 +84,68 @@ pub async fn list_accounts(pool: &PgPool) -> anyhow::Result<()> {
Ok(())
}
async fn test_account_interactively(account: &mut NewMailAccount) -> anyhow::Result<()> {
let mut accept_invalid = account.accept_invalid_certs;
loop {
let config = config_from_new_account(account, accept_invalid);
print!("正在测试 IMAP 连接... ");
io::stdout().flush()?;
match test_connection(config).await {
Ok(()) => {
println!("成功");
break;
}
Err(e) => {
let err_msg = format!("{e:#}");
println!("失败");
if is_tls_error(&err_msg) && !accept_invalid {
println!("TLS 证书校验失败,可能是自签名证书或主机名不匹配。");
if prompt_bool("是否接受无效证书后重试", true)? {
accept_invalid = true;
continue;
}
}
println!("跳过连接测试,将继续保存账户。");
break;
}
}
}
if accept_invalid != account.accept_invalid_certs {
account.accept_invalid_certs = true;
}
Ok(())
}
fn config_from_new_account(
account: &NewMailAccount,
accept_invalid_certs: bool,
) -> MailPollerConfig {
MailPollerConfig {
mail_account_id: None,
name: account.name.clone(),
host: account.imap_host.clone(),
port: account.imap_port as u16,
username: account.imap_username.clone(),
password: account.imap_password.clone(),
mailbox: account.mailbox.clone(),
source_key: account.source_key.clone(),
notify_account_id: account.notify_account_id.clone(),
notify_user_id: account.notify_user_id.clone(),
poll_interval: Duration::from_secs(account.poll_seconds as u64),
fetch_limit: account.fetch_limit as usize,
accept_invalid_certs,
}
}
fn is_tls_error(error: &str) -> bool {
error.contains("TLS")
|| error.contains("certificate")
|| error.contains("ssl")
|| error.to_ascii_lowercase().contains("ssl")
}
fn prompt_required(label: &str, default: Option<&str>) -> anyhow::Result<String> {
loop {
let value = prompt_line(label, default)?;
@@ -138,6 +182,292 @@ fn prompt_bool(label: &str, default: bool) -> anyhow::Result<bool> {
}
}
pub async fn show_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(account) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
let last_checked = account
.last_checked_at
.map(|time| time.to_string())
.unwrap_or_else(|| "-".to_string());
let last_error = account.last_error.as_deref().unwrap_or("-");
println!("邮件账户 #{id}:");
println!(" 名称: {}", account.name);
println!(
" IMAP 服务器: {}:{}",
account.imap_host, account.imap_port
);
println!(" 用户名: {}", account.imap_username);
println!(" 邮箱目录: {}", account.mailbox);
println!(" 通知 account_id: {}", account.notify_account_id);
println!(" 通知 user_id: {}", account.notify_user_id);
println!(" 轮询间隔: {}s", account.poll_seconds);
println!(" 抓取上限: {}", account.fetch_limit);
println!(" 接受无效证书: {}", account.accept_invalid_certs);
println!(" 启用状态: {}", account.enabled);
println!(" 上次检查时间: {last_checked}");
println!(" 上次错误: {last_error}");
println!(" 创建时间: {}", account.created_at);
println!(" 更新时间: {}", account.updated_at);
Ok(())
}
pub async fn edit_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(existing) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
println!("修改邮件账户 #{id}。直接回车会保留当前值,密码字段留空不会显示或修改现有密码。");
let host = prompt_required("IMAP 服务器", Some(&existing.imap_host))?;
let port = prompt_i32("IMAP 端口", existing.imap_port, 1, u16::MAX as i32)?;
let username = prompt_required("邮箱账号", Some(&existing.imap_username))?;
let password = prompt_line("IMAP 密码或授权码(留空保留现有)", None)?;
let password = if password.trim().is_empty() {
existing.imap_password.clone()
} else {
password.trim().to_string()
};
let mailbox = prompt_required("邮箱目录", Some(&existing.mailbox))?;
let name = prompt_required("账户名称", Some(&existing.name))?;
let notify_account_id = prompt_required(
"通知使用的微信 account_id",
Some(&existing.notify_account_id),
)?;
let notify_user_id =
prompt_required("通知接收人的 from_user_id", Some(&existing.notify_user_id))?;
let poll_seconds = prompt_i32("轮询间隔秒数", existing.poll_seconds, 10, 86400)?;
let fetch_limit = prompt_i32("单次最多抓取邮件数", existing.fetch_limit, 1, 100)?;
let accept_invalid_certs = prompt_bool("是否接受无效 TLS 证书", existing.accept_invalid_certs)?;
let enabled = prompt_bool("是否启用该邮件账户", existing.enabled)?;
let mut account = NewMailAccount {
name,
source_key: format!("imap:{host}:{username}"),
imap_host: host,
imap_port: port,
imap_username: username,
imap_password: password,
mailbox,
notify_account_id,
notify_user_id,
poll_seconds,
fetch_limit,
accept_invalid_certs,
enabled,
};
if prompt_bool("保存前测试 IMAP 连接", true)? {
test_account_interactively(&mut account).await?;
}
let Some(saved) = MailRepository::update_account(pool, id, account).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
println!(
"邮件账户已更新:#{} {} {}@{} mailbox={} enabled={}",
saved.id, saved.name, saved.imap_username, saved.imap_host, saved.mailbox, saved.enabled
);
println!("服务下次启动或重启后会加载更新后的邮件账户。");
Ok(())
}
pub async fn check_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(account) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
print!(
"正在检查邮件账户 #{} {} {}@{} mailbox={} ... ",
account.id, account.name, account.imap_username, account.imap_host, account.mailbox
);
io::stdout().flush()?;
match test_connection(MailPollerConfig::from_account(account.clone())).await {
Ok(()) => {
MailRepository::record_poll_success(pool, Some(account.id)).await?;
println!("成功");
Ok(())
}
Err(error) => {
let message = format!("{error:#}");
if let Err(update_error) =
MailRepository::record_poll_error(pool, Some(account.id), message.clone()).await
{
eprintln!("记录邮件检查结果失败:{update_error}");
}
println!("失败");
anyhow::bail!("邮件账户 #{} 连通性检查失败:{message}", account.id);
}
}
}
pub async fn pull_mail(pool: &PgPool, id: Option<i64>) -> anyhow::Result<()> {
let mut configs = Vec::new();
if let Some(id) = id {
let Some(account) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
if !account.enabled {
println!("邮件账户 #{id} 当前已禁用,本次仍执行手动拉取。");
}
configs.push(MailPollerConfig::from_account(account));
} else {
configs.extend(
MailRepository::list_enabled_accounts(pool)
.await?
.into_iter()
.map(MailPollerConfig::from_account),
);
if configs.is_empty() {
match MailPollerConfig::from_env()? {
Some(config) => configs.push(config),
None => {
println!("未找到可拉取的邮件账户。可使用 ias mail auth 添加。");
return Ok(());
}
}
}
}
let mut failed = 0usize;
for config in configs {
println!(
"正在拉取邮件账户 {} {}@{} mailbox={} ...",
config.name, config.username, config.host, config.mailbox
);
match pull_recent_mail(pool, &config).await {
Ok(result) => {
println!(
"拉取完成:扫描={} 未读={} 新增={} 已存在={}",
result.scanned, result.unread_found, result.inserted, result.duplicates
);
}
Err(error) => {
failed += 1;
let message = format!("{error:#}");
if let Err(update_error) =
MailRepository::record_poll_error(pool, config.mail_account_id, message.clone())
.await
{
eprintln!("记录邮件拉取错误失败:{update_error}");
}
eprintln!("拉取失败:{message}");
}
}
}
if failed > 0 {
anyhow::bail!("{failed} 个邮件账户拉取失败");
}
Ok(())
}
pub async fn delete_account(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(account) = MailRepository::find_account(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
print!(
"确认删除邮件账户 #{} {} ({}@{})? [y/N]: ",
account.id, account.name, account.imap_username, account.imap_host
);
io::stdout().flush()?;
let mut confirm = String::new();
io::stdin().read_line(&mut confirm)?;
let confirm = confirm.trim().to_ascii_lowercase();
if !matches!(confirm.as_str(), "y" | "yes") {
println!("已取消删除。");
return Ok(());
}
MailRepository::delete_account(pool, id).await?;
println!("已删除邮件账户 #{}", account.id);
Ok(())
}
pub async fn enable_account(pool: &PgPool, id: i64, enabled: bool) -> anyhow::Result<()> {
let Some(account) = MailRepository::update_account_enabled(pool, id, enabled).await? else {
println!("未找到 ID 为 {id} 的邮件账户。");
return Ok(());
};
let status = if enabled { "已启用" } else { "已禁用" };
println!("{status}邮件账户 #{} {}", account.id, account.name);
Ok(())
}
pub async fn list_messages(pool: &PgPool, page: i64, per_page: i64) -> anyhow::Result<()> {
let (messages, total) = MailRepository::list_messages_paginated(pool, page, per_page).await?;
let total_pages = ((total as f64) / (per_page as f64)).ceil() as i64;
if messages.is_empty() {
println!("暂无邮件记录。");
return Ok(());
}
println!("邮件列表 (第 {page}/{total_pages} 页,共 {total} 封):");
for msg in messages {
let received = msg
.received_at
.map(|time| time.to_string())
.unwrap_or_else(|| "-".to_string());
let preview = msg.preview_url.as_deref().unwrap_or("-");
println!(
" #{id} | {from} | {subject} | {received} | {preview}",
id = msg.id,
from = msg.from_label,
subject = msg.subject,
received = received,
preview = preview
);
}
Ok(())
}
pub async fn show_message(pool: &PgPool, id: i64) -> anyhow::Result<()> {
let Some(msg) = MailRepository::find(pool, id).await? else {
println!("未找到 ID 为 {id} 的邮件。");
return Ok(());
};
let received = msg
.received_at
.map(|time| time.to_string())
.unwrap_or_else(|| "-".to_string());
let preview = msg.preview_url.as_deref().unwrap_or("-");
println!("邮件 #{id}:");
println!(" 发件人: {}", msg.from_label);
println!(" 主题: {}", msg.subject);
println!(" 摘要: {}", msg.summary);
println!(" 邮箱目录: {}", msg.mailbox);
println!(" Message-ID: {}", msg.message_id.as_deref().unwrap_or("-"));
println!(" UID: {}", msg.uid);
println!(" 接收时间: {received}");
println!(" 原始大小: {} bytes", msg.raw_size);
println!(" 预览链接: {preview}");
println!(" 入库时间: {}", msg.created_at);
println!();
println!("--- 正文预览 ---");
println!("{}", msg.content_preview);
println!("--- 正文结束 ---");
Ok(())
}
fn prompt_line(label: &str, default: Option<&str>) -> anyhow::Result<String> {
match default {
Some(default) => print!("{label} [{default}]: "),
+6
View File
@@ -89,4 +89,10 @@ pub struct MailNotification {
#[derive(Debug, Deserialize)]
pub struct MailListQuery {
pub limit: Option<i64>,
pub page: Option<i64>,
pub per_page: Option<i64>,
pub q: Option<String>,
pub account_id: Option<i64>,
pub mailbox: Option<String>,
pub source_key: Option<String>,
}
+92 -34
View File
@@ -1,5 +1,5 @@
use anyhow::{Context, anyhow};
use async_imap::types::Fetch;
use async_imap::types::{Fetch, Flag};
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
use ias_http::web::{
@@ -16,9 +16,12 @@ use crate::config::MailPollerConfig;
use crate::model::{MailMessage, MailNotification, NewMailMessage};
use crate::repository::MailRepository;
const RECENT_MAIL_FETCH_LIMIT: u32 = 20;
#[derive(Debug, Clone)]
struct FetchedMail {
uid: String,
seen: bool,
message_id: Option<String>,
from_label: String,
subject: String,
@@ -29,6 +32,15 @@ struct FetchedMail {
raw_size: i64,
}
#[derive(Debug, Clone, Default)]
pub struct MailPullResult {
pub scanned: usize,
pub unread_found: usize,
pub inserted: usize,
pub duplicates: usize,
pub notified: usize,
}
pub fn start_mail_polling(
pool: PgPool,
config: MailPollerConfig,
@@ -67,10 +79,37 @@ async fn poll_once(
config: &MailPollerConfig,
notifications: &Sender<MailNotification>,
) -> anyhow::Result<()> {
let messages = fetch_unseen_messages(config).await?;
pull_recent_mail_with_notifications(pool, config, Some(notifications), true).await?;
Ok(())
}
pub async fn pull_recent_mail(
pool: &PgPool,
config: &MailPollerConfig,
) -> anyhow::Result<MailPullResult> {
pull_recent_mail_with_notifications(pool, config, None, false).await
}
async fn pull_recent_mail_with_notifications(
pool: &PgPool,
config: &MailPollerConfig,
notifications: Option<&Sender<MailNotification>>,
unread_only: bool,
) -> anyhow::Result<MailPullResult> {
let messages = fetch_recent_messages(config).await?;
MailRepository::record_poll_success(pool, config.mail_account_id).await?;
let mut result = MailPullResult {
scanned: messages.len(),
unread_found: messages.iter().filter(|message| !message.seen).count(),
..MailPullResult::default()
};
for fetched in messages {
if unread_only && fetched.seen {
continue;
}
let Some(inserted) = MailRepository::insert_if_new(
pool,
NewMailMessage {
@@ -91,33 +130,39 @@ async fn poll_once(
)
.await?
else {
result.duplicates += 1;
continue;
};
result.inserted += 1;
let message = create_preview_page(pool, inserted, &fetched.content).await?;
let text = notification_text(&message);
if notifications
.send(MailNotification {
account_id: config.notify_account_id.clone(),
from_user_id: config.notify_user_id.clone(),
text,
})
.await
.is_err()
{
eprintln!("邮件通知通道已关闭");
return Ok(());
if let Some(notifications) = notifications {
let text = notification_text(&message);
if notifications
.send(MailNotification {
account_id: config.notify_account_id.clone(),
from_user_id: config.notify_user_id.clone(),
text,
})
.await
.is_err()
{
eprintln!("邮件通知通道已关闭");
return Ok(result);
}
result.notified += 1;
}
}
Ok(())
Ok(result)
}
pub async fn test_connection(config: MailPollerConfig) -> anyhow::Result<()> {
test_connection_async(&config).await
}
async fn fetch_unseen_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<FetchedMail>> {
async fn fetch_recent_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<FetchedMail>> {
let addr = format!("{}:{}", config.host, config.port);
let tcp_stream = TcpStream::connect(&addr)
.await
@@ -139,30 +184,20 @@ async fn fetch_unseen_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
.map_err(|(error, _)| error)
.context("IMAP 登录失败")?;
session
let mailbox = session
.select(&config.mailbox)
.await
.with_context(|| format!("选择邮箱目录失败: {}", config.mailbox))?;
let unseen = session.search("UNSEEN").await.context("查询未读邮件失败")?;
let mut sequence_numbers: Vec<u32> = unseen.into_iter().collect();
sequence_numbers.sort_unstable();
sequence_numbers.truncate(config.fetch_limit);
if sequence_numbers.is_empty() {
let Some(sequence_set) = recent_sequence_set(mailbox.exists, RECENT_MAIL_FETCH_LIMIT) else {
let _ = session.logout().await;
return Ok(Vec::new());
}
};
let sequence_set = sequence_numbers
.into_iter()
.map(|value| value.to_string())
.collect::<Vec<_>>()
.join(",");
let fetches: Vec<Fetch> = session
.fetch(&sequence_set, "(UID RFC822)")
.fetch(&sequence_set, "(UID FLAGS BODY.PEEK[])")
.await
.context("抓取邮件正文失败")?
.context("抓取最近邮件失败")?
.try_collect()
.await
.context("收集抓取结果失败")?;
@@ -173,11 +208,12 @@ async fn fetch_unseen_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
let Some(body) = fetch.body() else {
continue;
};
let seen = is_seen(fetch);
let uid = fetch
.uid
.map(|uid| uid.to_string())
.unwrap_or_else(|| format!("seq-{}", fetch.message));
match parse_message(uid, body) {
match parse_message(uid, seen, body) {
Ok(message) => messages.push(message),
Err(error) => eprintln!("解析邮件失败: {error:#}"),
}
@@ -187,6 +223,19 @@ async fn fetch_unseen_messages(config: &MailPollerConfig) -> anyhow::Result<Vec<
Ok(messages)
}
fn recent_sequence_set(exists: u32, limit: u32) -> Option<String> {
if exists == 0 || limit == 0 {
return None;
}
let start = exists.saturating_sub(limit).saturating_add(1);
Some(format!("{start}:{exists}"))
}
fn is_seen(fetch: &Fetch) -> bool {
fetch.flags().any(|flag| matches!(flag, Flag::Seen))
}
async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()> {
let addr = format!("{}:{}", config.host, config.port);
let tcp_stream = TcpStream::connect(&addr)
@@ -218,7 +267,7 @@ async fn test_connection_async(config: &MailPollerConfig) -> anyhow::Result<()>
Ok(())
}
fn parse_message(uid: String, raw: &[u8]) -> anyhow::Result<FetchedMail> {
fn parse_message(uid: String, seen: bool, raw: &[u8]) -> anyhow::Result<FetchedMail> {
let parsed = MessageParser::default()
.parse(raw)
.ok_or_else(|| anyhow!("mail-parser 未能解析邮件"))?;
@@ -245,6 +294,7 @@ fn parse_message(uid: String, raw: &[u8]) -> anyhow::Result<FetchedMail> {
Ok(FetchedMail {
uid,
seen,
message_id,
from_label,
subject,
@@ -374,7 +424,15 @@ fn split_headers(raw: &str) -> &str {
#[cfg(test)]
mod tests {
use super::{header_value, preview_text, summarize_mail};
use super::{header_value, preview_text, recent_sequence_set, summarize_mail};
#[test]
fn builds_recent_sequence_set_for_latest_messages() {
assert_eq!(recent_sequence_set(0, 20), None);
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"));
}
#[test]
fn reads_folded_header() {
+285
View File
@@ -1,5 +1,6 @@
use sqlx::PgPool;
use crate::model::MailListQuery;
use crate::model::{MailAccount, MailMessage, NewMailAccount, NewMailMessage};
pub struct MailRepository;
@@ -325,6 +326,91 @@ impl MailRepository {
Ok(rows)
}
pub async fn query_messages(
pool: &PgPool,
query: &MailListQuery,
) -> anyhow::Result<(Vec<MailMessage>, i64, i64, i64)> {
let per_page = query.per_page.or(query.limit).unwrap_or(20).clamp(1, 100);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * per_page;
let mailbox = normalized_filter(query.mailbox.as_deref());
let source_key = normalized_filter(query.source_key.as_deref());
let keyword = normalized_filter(query.q.as_deref());
let total: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*)
FROM ias_mail_messages
WHERE
($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 (
$4::TEXT IS NULL
OR from_label ILIKE ('%' || $4 || '%')
OR subject ILIKE ('%' || $4 || '%')
OR summary ILIKE ('%' || $4 || '%')
OR content ILIKE ('%' || $4 || '%')
OR message_id ILIKE ('%' || $4 || '%')
)
"#,
)
.bind(query.account_id)
.bind(mailbox.as_deref())
.bind(source_key.as_deref())
.bind(keyword.as_deref())
.fetch_one(pool)
.await?;
let rows = sqlx::query_as::<_, MailMessage>(
r#"
SELECT
id,
mail_account_id,
source_key,
mailbox,
uid,
message_id,
from_label,
subject,
summary,
content,
content_preview,
raw_headers,
preview_page_id,
preview_url,
received_at,
raw_size,
created_at
FROM ias_mail_messages
WHERE
($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 (
$4::TEXT IS NULL
OR from_label ILIKE ('%' || $4 || '%')
OR subject ILIKE ('%' || $4 || '%')
OR summary ILIKE ('%' || $4 || '%')
OR content ILIKE ('%' || $4 || '%')
OR message_id ILIKE ('%' || $4 || '%')
)
ORDER BY created_at DESC, id DESC
LIMIT $5 OFFSET $6
"#,
)
.bind(query.account_id)
.bind(mailbox.as_deref())
.bind(source_key.as_deref())
.bind(keyword.as_deref())
.bind(per_page)
.bind(offset)
.fetch_all(pool)
.await?;
Ok((rows, total.0, page, per_page))
}
pub async fn find(pool: &PgPool, id: i64) -> anyhow::Result<Option<MailMessage>> {
let row = sqlx::query_as::<_, MailMessage>(
r#"
@@ -356,4 +442,203 @@ impl MailRepository {
Ok(row)
}
pub async fn find_account(pool: &PgPool, id: i64) -> anyhow::Result<Option<MailAccount>> {
let row = sqlx::query_as::<_, MailAccount>(
r#"
SELECT
id,
name,
source_key,
imap_host,
imap_port,
imap_username,
imap_password,
mailbox,
notify_account_id,
notify_user_id,
poll_seconds,
fetch_limit,
accept_invalid_certs,
enabled,
last_checked_at,
last_error,
created_at,
updated_at
FROM ias_mail_accounts
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn delete_account(pool: &PgPool, id: i64) -> anyhow::Result<bool> {
let result = sqlx::query("DELETE FROM ias_mail_accounts WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn update_account_enabled(
pool: &PgPool,
id: i64,
enabled: bool,
) -> anyhow::Result<Option<MailAccount>> {
let row = sqlx::query_as::<_, MailAccount>(
r#"
UPDATE ias_mail_accounts
SET enabled = $2, updated_at = NOW()
WHERE id = $1
RETURNING
id,
name,
source_key,
imap_host,
imap_port,
imap_username,
imap_password,
mailbox,
notify_account_id,
notify_user_id,
poll_seconds,
fetch_limit,
accept_invalid_certs,
enabled,
last_checked_at,
last_error,
created_at,
updated_at
"#,
)
.bind(id)
.bind(enabled)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn update_account(
pool: &PgPool,
id: i64,
account: NewMailAccount,
) -> anyhow::Result<Option<MailAccount>> {
let row = sqlx::query_as::<_, MailAccount>(
r#"
UPDATE ias_mail_accounts
SET
name = $2,
source_key = $3,
imap_host = $4,
imap_port = $5,
imap_username = $6,
imap_password = $7,
mailbox = $8,
notify_account_id = $9,
notify_user_id = $10,
poll_seconds = $11,
fetch_limit = $12,
accept_invalid_certs = $13,
enabled = $14,
last_error = NULL,
updated_at = NOW()
WHERE id = $1
RETURNING
id,
name,
source_key,
imap_host,
imap_port,
imap_username,
imap_password,
mailbox,
notify_account_id,
notify_user_id,
poll_seconds,
fetch_limit,
accept_invalid_certs,
enabled,
last_checked_at,
last_error,
created_at,
updated_at
"#,
)
.bind(id)
.bind(account.name)
.bind(account.source_key)
.bind(account.imap_host)
.bind(account.imap_port)
.bind(account.imap_username)
.bind(account.imap_password)
.bind(account.mailbox)
.bind(account.notify_account_id)
.bind(account.notify_user_id)
.bind(account.poll_seconds)
.bind(account.fetch_limit)
.bind(account.accept_invalid_certs)
.bind(account.enabled)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn list_messages_paginated(
pool: &PgPool,
page: i64,
per_page: i64,
) -> anyhow::Result<(Vec<MailMessage>, i64)> {
let per_page = per_page.clamp(1, 100);
let page = page.max(1);
let offset = (page - 1) * per_page;
let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM ias_mail_messages")
.fetch_one(pool)
.await?;
let rows = sqlx::query_as::<_, MailMessage>(
r#"
SELECT
id,
mail_account_id,
source_key,
mailbox,
uid,
message_id,
from_label,
subject,
summary,
content,
content_preview,
raw_headers,
preview_page_id,
preview_url,
received_at,
raw_size,
created_at
FROM ias_mail_messages
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(per_page)
.bind(offset)
.fetch_all(pool)
.await?;
Ok((rows, total.0))
}
}
fn normalized_filter(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
+714 -4
View File
@@ -1,14 +1,16 @@
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use axum::response::Html;
use axum::routing::get;
use axum::{Extension, Json, Router};
use serde_json::{Value, json};
use sqlx::PgPool;
use crate::model::MailListQuery;
use crate::model::{MailListQuery, MailMessage};
use crate::repository::MailRepository;
type ApiError = (StatusCode, Json<Value>);
type HtmlError = (StatusCode, Html<String>);
#[derive(Clone)]
pub struct MailRouteState {
@@ -26,8 +28,11 @@ where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/mail-api/messages", get(list_messages))
.route("/mail-api/messages/{id}", get(get_message))
.route("/v1/mail/messages", get(list_messages))
.route("/v1/mail/search", get(list_messages))
.route("/v1/mail/messages/{id}", get(get_message))
.route("/mail", get(show_mail_list))
.route("/mail/messages/{id}", get(show_mail_message))
.layer(Extension(MailRouteState::new(db)))
}
@@ -35,12 +40,15 @@ async fn list_messages(
Extension(state): Extension<MailRouteState>,
Query(query): Query<MailListQuery>,
) -> Result<Json<Value>, ApiError> {
let messages = MailRepository::list_recent(&state.db, query.limit.unwrap_or(20))
let (messages, total, page, per_page) = MailRepository::query_messages(&state.db, &query)
.await
.map_err(api_internal_error)?;
Ok(Json(json!({
"success": true,
"page": page,
"per_page": per_page,
"total": total,
"messages": messages,
})))
}
@@ -60,6 +68,648 @@ async fn get_message(
})))
}
async fn show_mail_list(
Extension(state): Extension<MailRouteState>,
Query(query): Query<MailListQuery>,
) -> Result<Html<String>, HtmlError> {
let (messages, total, page, per_page) = MailRepository::query_messages(&state.db, &query)
.await
.map_err(html_internal_error)?;
Ok(Html(render_mail_list_page(
&query, &messages, total, page, per_page,
)))
}
async fn show_mail_message(
Extension(state): Extension<MailRouteState>,
Path(id): Path<i64>,
) -> Result<Html<String>, HtmlError> {
let message = MailRepository::find(&state.db, id)
.await
.map_err(html_internal_error)?
.ok_or_else(|| html_not_found("邮件不存在", "没有找到对应的邮件记录"))?;
Ok(Html(render_mail_message_page(&message)))
}
fn render_mail_list_page(
query: &MailListQuery,
messages: &[MailMessage],
total: i64,
page: i64,
per_page: i64,
) -> String {
let rows = if messages.is_empty() {
r#"<tr><td class="empty" colspan="6">没有匹配的邮件</td></tr>"#.to_string()
} else {
messages
.iter()
.map(render_mail_list_row)
.collect::<Vec<_>>()
.join("\n")
};
let current_start = if total == 0 {
0
} else {
((page - 1) * per_page + 1).min(total)
};
let current_end = ((page - 1) * per_page + messages.len() as i64).min(total);
let pagination = render_pagination(query, total, page, per_page);
format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>邮件列表</title>
<style>
:root {{
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f7fa;
color: #20242a;
}}
body {{
margin: 0;
min-height: 100vh;
background: #f5f7fa;
}}
main {{
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 44px;
}}
header {{
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}}
h1 {{
margin: 0;
font-size: 28px;
line-height: 1.2;
font-weight: 720;
letter-spacing: 0;
}}
.count {{
color: #5e6978;
font-size: 14px;
white-space: nowrap;
}}
form {{
display: grid;
grid-template-columns: minmax(180px, 1.4fr) minmax(120px, .7fr) minmax(120px, .8fr) minmax(120px, .8fr) 92px 84px;
gap: 10px;
margin-bottom: 14px;
align-items: end;
}}
label {{
display: grid;
gap: 5px;
color: #5e6978;
font-size: 12px;
line-height: 1.2;
}}
input {{
width: 100%;
box-sizing: border-box;
border: 1px solid #cfd7e2;
border-radius: 6px;
background: #ffffff;
color: #20242a;
font: inherit;
font-size: 14px;
line-height: 20px;
padding: 8px 10px;
}}
button, .pager a {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
border: 1px solid #245f9f;
border-radius: 6px;
background: #245f9f;
color: #ffffff;
font: inherit;
font-size: 14px;
text-decoration: none;
cursor: pointer;
}}
.table-wrap {{
overflow-x: auto;
border: 1px solid #d7dee8;
border-radius: 8px;
background: #ffffff;
}}
table {{
width: 100%;
border-collapse: collapse;
min-width: 860px;
}}
th, td {{
border-bottom: 1px solid #e5eaf0;
padding: 11px 12px;
text-align: left;
vertical-align: top;
font-size: 14px;
line-height: 1.5;
}}
th {{
color: #5e6978;
background: #fbfcfd;
font-size: 12px;
font-weight: 650;
}}
tr:last-child td {{
border-bottom: 0;
}}
a {{
color: #245f9f;
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
.subject {{
min-width: 260px;
max-width: 460px;
}}
.subject a {{
font-weight: 650;
}}
.summary {{
margin-top: 4px;
color: #697586;
font-size: 13px;
overflow-wrap: anywhere;
}}
.mono {{
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
}}
.empty {{
height: 96px;
text-align: center;
color: #697586;
}}
.pager {{
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-top: 14px;
color: #5e6978;
font-size: 14px;
}}
.pager a {{
min-width: 82px;
background: #ffffff;
color: #245f9f;
}}
.pager .disabled {{
min-width: 82px;
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #d7dee8;
border-radius: 6px;
color: #9aa4b2;
background: #ffffff;
}}
@media (max-width: 860px) {{
main {{
width: min(100% - 24px, 1180px);
padding-top: 22px;
}}
header {{
align-items: flex-start;
flex-direction: column;
gap: 6px;
}}
form {{
grid-template-columns: 1fr 1fr;
}}
button {{
grid-column: span 2;
}}
}}
</style>
</head>
<body>
<main>
<header>
<h1>邮件列表</h1>
<div class="count">共 {total} 封,当前 {current_start}-{current_end}</div>
</header>
<form method="get" action="/mail">
<label>关键词
<input name="q" value="{q}" placeholder="发件人、主题、正文、Message-ID">
</label>
<label>账户 ID
<input name="account_id" value="{account_id}" inputmode="numeric">
</label>
<label>邮箱目录
<input name="mailbox" value="{mailbox}" placeholder="INBOX">
</label>
<label>来源
<input name="source_key" value="{source_key}">
</label>
<label>每页
<input name="per_page" value="{per_page}" inputmode="numeric">
</label>
<button type="submit">查询</button>
</form>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>收件时间</th>
<th>发件人</th>
<th>主题</th>
<th>邮箱目录</th>
<th>来源</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>
{pagination}
</main>
</body>
</html>"#,
total = total,
current_start = current_start,
current_end = current_end,
q = escape_html(query.q.as_deref().unwrap_or("")),
account_id = query
.account_id
.map(|value| value.to_string())
.unwrap_or_default(),
mailbox = escape_html(query.mailbox.as_deref().unwrap_or("")),
source_key = escape_html(query.source_key.as_deref().unwrap_or("")),
per_page = per_page,
rows = rows,
pagination = pagination
)
}
fn render_mail_list_row(message: &MailMessage) -> String {
let subject = display_subject(&message.subject);
let summary = if message.content_preview.trim().is_empty() {
String::new()
} else {
format!(
r#"<div class="summary">{}</div>"#,
escape_html(&message.content_preview)
)
};
let received_at = message
.received_at
.as_ref()
.map(format_mail_time)
.unwrap_or_else(|| "-".to_string());
let account = message
.mail_account_id
.map(|id| format!("账户 #{id}"))
.unwrap_or_else(|| "环境配置".to_string());
format!(
r#"<tr>
<td class="mono">{id}</td>
<td>{received_at}</td>
<td>{from}</td>
<td class="subject"><a href="/mail/messages/{id}">{subject}</a>{summary}</td>
<td>{mailbox}</td>
<td><div>{source_key}</div><div class="summary">{account}</div></td>
</tr>"#,
id = message.id,
received_at = escape_html(&received_at),
from = escape_html(&message.from_label),
subject = escape_html(subject),
summary = summary,
mailbox = escape_html(&message.mailbox),
source_key = escape_html(&message.source_key),
account = escape_html(&account)
)
}
fn render_pagination(query: &MailListQuery, total: i64, page: i64, per_page: i64) -> String {
let last_page = if total == 0 {
1
} else {
((total + per_page - 1) / per_page).max(1)
};
let previous = if page > 1 {
format!(
r#"<a href="{}">上一页</a>"#,
escape_html(&mail_list_path(query, page - 1, per_page))
)
} else {
r#"<span class="disabled">上一页</span>"#.to_string()
};
let next = if page < last_page {
format!(
r#"<a href="{}">下一页</a>"#,
escape_html(&mail_list_path(query, page + 1, per_page))
)
} else {
r#"<span class="disabled">下一页</span>"#.to_string()
};
format!(
r#"<nav class="pager">{previous}<span>第 {page} / {last_page} 页</span>{next}</nav>"#,
previous = previous,
page = page,
last_page = last_page,
next = next
)
}
fn mail_list_path(query: &MailListQuery, page: i64, per_page: i64) -> String {
let mut params = Vec::new();
push_query_param(&mut params, "q", query.q.as_deref());
if let Some(account_id) = query.account_id {
params.push(format!("account_id={account_id}"));
}
push_query_param(&mut params, "mailbox", query.mailbox.as_deref());
push_query_param(&mut params, "source_key", query.source_key.as_deref());
params.push(format!("page={}", page.max(1)));
params.push(format!("per_page={}", per_page.clamp(1, 100)));
format!("/mail?{}", params.join("&"))
}
fn push_query_param(params: &mut Vec<String>, key: &str, value: Option<&str>) {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return;
};
params.push(format!("{key}={}", percent_encode(value)));
}
fn percent_encode(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn render_mail_message_page(message: &MailMessage) -> String {
let title = display_subject(&message.subject);
let received_at = message
.received_at
.as_ref()
.map(format_mail_time)
.unwrap_or_else(|| "-".to_string());
let created_at = format_mail_time(&message.created_at);
let account = message
.mail_account_id
.map(|id| format!("账户 #{id}"))
.unwrap_or_else(|| "环境配置".to_string());
let preview_url = message
.preview_url
.as_deref()
.map(|url| {
format!(
r#"<a href="{url}">{label}</a>"#,
url = escape_html(url),
label = escape_html(url)
)
})
.unwrap_or_else(|| "-".to_string());
let message_id = message.message_id.as_deref().unwrap_or("-");
let headers = message
.raw_headers
.as_deref()
.filter(|headers| !headers.trim().is_empty())
.map(|headers| {
format!(
r#"<details><summary>原始头信息</summary><pre>{}</pre></details>"#,
escape_html(headers)
)
})
.unwrap_or_default();
format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
:root {{
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f7fa;
color: #20242a;
}}
body {{
margin: 0;
min-height: 100vh;
background: #f5f7fa;
}}
main {{
width: min(920px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 48px;
}}
.back {{
display: inline-flex;
align-items: center;
min-height: 32px;
color: #245f9f;
font-size: 14px;
text-decoration: none;
}}
.back:hover {{
text-decoration: underline;
}}
h1 {{
margin: 14px 0 10px;
font-size: 30px;
line-height: 1.2;
font-weight: 720;
letter-spacing: 0;
overflow-wrap: anywhere;
}}
.summary {{
margin: 0 0 18px;
color: #5e6978;
font-size: 15px;
line-height: 1.7;
overflow-wrap: anywhere;
}}
.meta {{
border: 1px solid #d7dee8;
border-radius: 8px;
background: #ffffff;
margin: 0 0 18px;
padding: 16px;
}}
dl {{
display: grid;
grid-template-columns: minmax(112px, 170px) 1fr;
gap: 10px 16px;
margin: 0;
font-size: 14px;
line-height: 1.6;
}}
dt {{
color: #5e6978;
}}
dd {{
margin: 0;
overflow-wrap: anywhere;
}}
a {{
color: #245f9f;
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
article {{
border: 1px solid #d7dee8;
border-radius: 8px;
background: #ffffff;
padding: 22px;
font-size: 15px;
line-height: 1.8;
overflow-wrap: anywhere;
}}
details {{
margin-top: 18px;
border: 1px solid #d7dee8;
border-radius: 8px;
background: #ffffff;
padding: 12px 14px;
}}
summary {{
color: #245f9f;
cursor: pointer;
font-size: 14px;
}}
pre {{
margin: 12px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #2d333b;
}}
@media (max-width: 640px) {{
main {{
width: min(100% - 24px, 920px);
padding-top: 22px;
}}
dl {{
grid-template-columns: 1fr;
gap: 2px 0;
}}
article {{
padding: 18px;
}}
}}
</style>
</head>
<body>
<main>
<a class="back" href="/mail">返回邮件列表</a>
<h1>{title}</h1>
<p class="summary">{summary}</p>
<section class="meta">
<dl>
<dt>发件人</dt><dd>{from}</dd>
<dt>收件时间</dt><dd>{received_at}</dd>
<dt>邮箱目录</dt><dd>{mailbox}</dd>
<dt>来源</dt><dd>{source_key}</dd>
<dt>账户</dt><dd>{account}</dd>
<dt>UID</dt><dd>{uid}</dd>
<dt>Message-ID</dt><dd>{message_id}</dd>
<dt>缓存 ID</dt><dd>{id}</dd>
<dt>原始大小</dt><dd>{raw_size} bytes</dd>
<dt>入库时间</dt><dd>{created_at}</dd>
<dt>提醒预览</dt><dd>{preview_url}</dd>
</dl>
</section>
<article>{content}</article>
{headers}
</main>
</body>
</html>"#,
title = escape_html(title),
summary = escape_html(&message.summary),
from = escape_html(&message.from_label),
received_at = escape_html(&received_at),
mailbox = escape_html(&message.mailbox),
source_key = escape_html(&message.source_key),
account = escape_html(&account),
uid = escape_html(&message.uid),
message_id = escape_html(message_id),
id = message.id,
raw_size = message.raw_size,
created_at = escape_html(&created_at),
preview_url = preview_url,
content = render_text_content(&message.content),
headers = headers
)
}
fn display_subject(subject: &str) -> &str {
let subject = subject.trim();
if subject.is_empty() {
"无主题"
} else {
subject
}
}
fn format_mail_time(time: &chrono::DateTime<chrono::Utc>) -> String {
time.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}
fn render_text_content(content: &str) -> String {
content
.lines()
.map(|line| {
if line.trim().is_empty() {
"<br>".to_string()
} else {
escape_html(line)
}
})
.collect::<Vec<_>>()
.join("<br>\n")
}
fn escape_html(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"),
_ => escaped.push(ch),
}
}
escaped
}
fn api_not_found(message: impl Into<String>) -> ApiError {
(
StatusCode::NOT_FOUND,
@@ -70,6 +720,43 @@ fn api_not_found(message: impl Into<String>) -> ApiError {
)
}
fn html_not_found(title: &str, message: &str) -> HtmlError {
(
StatusCode::NOT_FOUND,
Html(render_error_page(title, message)),
)
}
fn html_internal_error(error: anyhow::Error) -> HtmlError {
eprintln!("读取 mail 页面失败: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Html(render_error_page("邮件页面暂时不可用", "读取邮件内容失败")),
)
}
fn render_error_page(title: &str, message: &str) -> String {
format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
</head>
<body style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f7fa;color:#20242a;">
<main style="width:min(760px,calc(100% - 32px));margin:0 auto;padding:44px 0;">
<h1 style="font-size:30px;line-height:1.2;margin:0 0 10px;">{title}</h1>
<p style="font-size:15px;line-height:1.7;margin:0;color:#5e6978;">{message}</p>
<p style="margin:18px 0 0;"><a href="/mail" style="color:#245f9f;text-decoration:none;">返回邮件列表</a></p>
</main>
</body>
</html>"#,
title = escape_html(title),
message = escape_html(message)
)
}
fn api_internal_error(error: anyhow::Error) -> ApiError {
eprintln!("mail 接口失败: {error}");
(
@@ -80,3 +767,26 @@ fn api_internal_error(error: anyhow::Error) -> ApiError {
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use sqlx::postgres::PgPoolOptions;
#[test]
fn builds_mail_routes_with_web_mail_slug_route() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("create tokio runtime");
runtime.block_on(async {
let db = PgPoolOptions::new()
.connect_lazy("postgres://postgres:postgres@localhost/ias_test")
.expect("create lazy postgres pool");
let _app: Router<()> =
ias_http::web::routes::routes::<()>(db.clone()).merge(routes::<()>(db));
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-main"
version = "0.1.7"
version = "0.1.14"
edition = "2024"
[[bin]]
+49
View File
@@ -86,4 +86,53 @@ pub enum MailAction {
Auth,
/// 查看已经配置的邮件账户
List,
/// 查看单个邮件账户详情
Show {
/// 邮件账户 ID
id: i64,
},
/// 修改邮件账户配置
Edit {
/// 邮件账户 ID
id: i64,
},
/// 检查邮件账户 IMAP 连通性
Check {
/// 邮件账户 ID
id: i64,
},
/// 手动拉取最新邮件
Pull {
/// 邮件账户 ID;不提供时拉取所有启用账户
id: Option<i64>,
},
/// 删除邮件账户
Delete {
/// 邮件账户 ID
id: i64,
},
/// 启用邮件账户
Enable {
/// 邮件账户 ID
id: i64,
},
/// 禁用邮件账户
Disable {
/// 邮件账户 ID
id: i64,
},
/// 分页列出邮件
Messages {
/// 页码 (从 1 开始)
#[arg(short, long, default_value_t = 1)]
page: i64,
/// 每页数量
#[arg(short = 'n', long, default_value_t = 20)]
per_page: i64,
},
/// 查看邮件内容
Message {
/// 邮件 ID
id: i64,
},
}
+18 -3
View File
@@ -2,13 +2,13 @@ mod cli;
use clap::Parser;
use cli::{Channels, Cli, Commands, MailAction, ServiceAction, WechatAction};
use ias_service::supervisor::{
print_logs, restart_service, start_service, status_service, stop_service,
};
use ias_service::{
connect_db, delete_account, init_logging, list_accounts, login_user, rename_account,
run_service,
};
use ias_service::supervisor::{
print_logs, restart_service, start_service, status_service, stop_service,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -48,6 +48,21 @@ async fn main() -> anyhow::Result<()> {
match command {
MailAction::Auth => ias_mail::auth::auth_account(&pool).await?,
MailAction::List => ias_mail::auth::list_accounts(&pool).await?,
MailAction::Show { id } => ias_mail::auth::show_account(&pool, id).await?,
MailAction::Edit { id } => ias_mail::auth::edit_account(&pool, id).await?,
MailAction::Check { id } => ias_mail::auth::check_account(&pool, id).await?,
MailAction::Pull { id } => ias_mail::auth::pull_mail(&pool, id).await?,
MailAction::Delete { id } => ias_mail::auth::delete_account(&pool, id).await?,
MailAction::Enable { id } => {
ias_mail::auth::enable_account(&pool, id, true).await?
}
MailAction::Disable { id } => {
ias_mail::auth::enable_account(&pool, id, false).await?
}
MailAction::Messages { page, per_page } => {
ias_mail::auth::list_messages(&pool, page, per_page).await?
}
MailAction::Message { id } => ias_mail::auth::show_message(&pool, id).await?,
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ias-service"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
[dependencies]
@@ -0,0 +1,35 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.1.8',
'2026-07-06T00:00:00+08:00',
'邮件账户修改与连通性检查 CLI',
'新增邮件账户修改和 IMAP 连通性检查命令,便于在不直接操作数据库的情况下维护邮箱配置并确认服务器可访问。',
'[
"新增 ias mail edit <id> 命令,可交互式修改已保存的 IMAP 邮件账户配置,密码留空时保留原值。",
"新增 ias mail check <id> 命令,可使用已保存配置检查 IMAP 服务器连通性和邮箱目录可访问性。",
"邮件账户检查成功会更新 last_checked_at 并清空 last_error,失败会记录错误信息。",
"复用新增账户时的 IMAP 连接测试逻辑,TLS 证书错误仍支持交互式选择接受无效证书后重试。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.0",
"ias-wechat": "0.1.0",
"ias-context": "0.1.0",
"ias-http": "0.1.0",
"ias-mail": "0.1.1",
"ias-service": "0.1.0",
"ias-main": "0.1.8"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,35 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.1.9',
'2026-07-06T00:00:00+08:00',
'邮件最近 20 封未读检查',
'调整邮件轮询策略,每轮读取邮箱最近 20 封邮件并基于 FLAGS 判断未读状态,仅对未读且未入库的邮件生成预览页并发送提醒。',
'[
"调整邮件轮询逻辑:每轮固定读取邮箱最近 20 封邮件,而不是仅搜索 UNSEEN 集合。",
"读取最近邮件时同步获取 FLAGS,只对未包含 \\Seen 标记的邮件入库、生成预览页并发送微信提醒。",
"邮件正文抓取改用 BODY.PEEK[],避免检查最近邮件时把未读邮件隐式标记为已读。",
"新增最近 20 封 sequence range 的单元测试,并通过 cargo test -p ias-mail 与 cargo check 验证。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.0",
"ias-wechat": "0.1.0",
"ias-context": "0.1.0",
"ias-http": "0.1.0",
"ias-mail": "0.1.2",
"ias-service": "0.1.0",
"ias-main": "0.1.9"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,35 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.1.10',
'2026-07-06T00:00:00+08:00',
'手动邮件拉取与版本更新链接修复',
'新增手动拉取最新邮件的 CLI 命令,并修复服务使用模块版本生成更新日志链接导致 /updates/0.1.0 不存在的问题。',
'[
"新增 ias mail pull [id] 命令,可手动拉取最近 20 封中的未读邮件;不传 id 时拉取所有启用邮件账户,传 id 时拉取指定账户。",
"手动拉取会复用后台轮询的解析、去重、入库和邮件预览页生成逻辑,并输出本次未读、新增和已存在数量。",
"修复服务启动和版本更新通知使用 ias-service crate 版本的问题,改为读取根目录 VERSION 作为整体项目版本。",
"修复版本更新通知可能链接到不存在的 /updates/0.1.0 页面的问题;本版本通知会指向 /updates/0.1.10。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.0",
"ias-wechat": "0.1.0",
"ias-context": "0.1.0",
"ias-http": "0.1.0",
"ias-mail": "0.1.3",
"ias-service": "0.1.1",
"ias-main": "0.1.10"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,34 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.1.11',
'2026-07-06T00:00:00+08:00',
'手动邮件拉取同步最近 20 封',
'修正手动邮件拉取命令的语义,使其抓取最近 20 封邮件并入库,同时保留后台轮询只提醒未读邮件的行为。',
'[
"修正 ias mail pull [id] 的手动拉取语义:现在会抓取最近 20 封邮件并入库,不再只处理未读邮件。",
"ias mail pull 输出新增扫描数量,便于确认 IMAP 确实返回了最近邮件;同时继续显示其中未读、新增和已存在数量。",
"后台邮件轮询保持只处理未读邮件并发送微信提醒,手动拉取不发送微信提醒。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.0",
"ias-wechat": "0.1.0",
"ias-context": "0.1.0",
"ias-http": "0.1.0",
"ias-mail": "0.1.4",
"ias-service": "0.1.1",
"ias-main": "0.1.11"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,35 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.1.12',
'2026-07-06T00:00:00+08:00',
'邮件 HTTP 查询服务增强',
'增强邮件 HTTP 查询接口,支持关键词、账户、邮箱目录、来源和分页查询,并新增查询语义别名路由。',
'[
"增强邮件 HTTP 查询服务:GET /mail-api/messages 支持关键词、账户、邮箱目录、来源和分页查询。",
"新增 GET /mail-api/search 作为邮件查询别名,便于客户端直接按查询语义调用。",
"邮件列表响应新增 page、per_page 和 total 字段,保留 limit 参数的兼容行为。",
"查询关键词 q 会匹配发件人、主题、摘要、正文和 Message-ID。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.0",
"ias-wechat": "0.1.0",
"ias-context": "0.1.0",
"ias-http": "0.1.0",
"ias-mail": "0.1.5",
"ias-service": "0.1.1",
"ias-main": "0.1.12"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,35 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.1.13',
'2026-07-06T00:00:00+08:00',
'邮件查询 API 与页面',
'新增 v1 邮件查询 API,并提供邮件列表和邮件预览页面,同时保留旧 mail-api 路由兼容已有调用。',
'[
"新增 GET /v1/mail/messages、GET /v1/mail/messages/{id} 和 GET /v1/mail/search,作为邮件查询 API 的新地址。",
"保留 GET /mail-api/messages、GET /mail-api/messages/{id} 和 GET /mail-api/search 兼容旧调用。",
"新增 GET /mail 邮件列表页面,支持关键词、账户、邮箱目录、来源和分页查询。",
"新增 GET /mail/messages/{id} 邮件预览页面,可查看邮件正文、元信息和原始头信息。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.0",
"ias-wechat": "0.1.0",
"ias-context": "0.1.0",
"ias-http": "0.1.0",
"ias-mail": "0.1.6",
"ias-service": "0.1.1",
"ias-main": "0.1.13"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,35 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.1.14',
'2026-07-06T00:00:00+08:00',
'统一 v1 API 路由',
'删除旧邮件查询兼容入口,并将 JSON API 统一迁移到 /v1 前缀;页面路由保持非 /v1 地址。',
'[
"删除旧邮件查询兼容路由 GET /mail-api/messages、GET /mail-api/messages/{id} 和 GET /mail-api/search。",
"所有 JSON API 统一使用 /v1/...:邮件、上下文记忆、动态页面创建和健康检查接口均迁移到新地址。",
"页面路由继续保持非 /v1 地址:/mail、/mail/messages/{id}、/mail/{slug}、/web/{page_type}/{slug} 和 /updates。",
"AI 内部工具调用同步改用 /v1/web/pages 与 /v1/context/memories...。",
"无数据库结构变化。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.1",
"ias-wechat": "0.1.0",
"ias-context": "0.1.1",
"ias-http": "0.1.1",
"ias-mail": "0.1.7",
"ias-service": "0.1.1",
"ias-main": "0.1.14"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
+7 -3
View File
@@ -30,7 +30,11 @@ use crate::channel::wechat::manager::init_wechat;
pub use core::supervisor;
pub use channel::wechat::manager::{delete_account, list_accounts, login_user, rename_account};
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const PROJECT_VERSION: &str = include_str!("../../VERSION");
fn current_version() -> &'static str {
PROJECT_VERSION.trim()
}
#[derive(Clone)]
pub struct AppState {
@@ -64,7 +68,7 @@ pub async fn run_service(pool: PgPool) -> anyhow::Result<()> {
anyhow::bail!("服务已在运行,PID: {}", snapshot.pid);
}
println!("{} v{} 已启动...", name, CURRENT_VERSION);
println!("{} v{} 已启动...", name, current_version());
let runtime = Arc::new(RuntimeState::new(host.clone()));
let (sender, receiver) = mpsc::channel::<QueueMessage>(100);
let state = AppState {
@@ -231,7 +235,7 @@ async fn send_version_update_notice(pool: &PgPool, manager: &WeChatMultiAccountM
return;
}
let version = CURRENT_VERSION;
let version = current_version();
match version_notice_already_sent(pool, version).await {
Ok(true) => return,
Ok(false) => {}