Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 22 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,16 @@ inbox more room when you need it.
### 👥 Profiles and account colors

Group related accounts into named profiles such as Work or Personal, then give
each profile its own color. In Settings → Accounts → Profiles, you can assign
accounts, override an individual account's color, and show the effective color
along the left edge of every message. The markers make accounts easy to tell
apart while working in the unified inbox; the example below uses purple for Work
and orange for Support.
each profile its own color. Create a profile from the profile menu at the start
of the tab bar, or in Settings → Accounts → Profiles, and check the accounts
that belong to it. Choose a profile in that menu to focus the mail view on its
accounts: the unified inbox, search, counts, and sidebar show only their mail,
while your tabs stay the same. **All accounts** shows everything again.

You can also override an individual account's color and show the effective
color along the left edge of every message. The markers make accounts easy to
tell apart while working in the unified inbox; the example below uses purple
for Work and orange for Support.

<picture>
<source media="(prefers-color-scheme: dark)" srcset="resources/screenshots/desktop-profiles-dark.png">
Expand Down Expand Up @@ -162,15 +167,18 @@ layout, arrow keys highlight messages and **Enter** opens them; **Escape** or
### 🗂️ Tabs

Keep several places open at once with browser-style tabs. Each tab remembers
its own location: a mailbox, search, or open conversation, or the calendar,
contacts, or files. Middle-click or Ctrl/⌘-click a message to open it in a
background tab (add Shift to switch to it), or choose **Open in new tab** from
its menu. Drag tabs to reorder them, middle-click a tab to close it, and pick
up where you left off the next time you open Flectar Mail.

**Ctrl/⌘+T** opens a new tab and **Ctrl/⌘+W** closes the current one.
**Ctrl+Tab** and **Ctrl+Shift+Tab** cycle through tabs, **Ctrl/⌘+1–8** jump to
a tab, and **Ctrl/⌘+9** jumps to the last.
its own place: a mailbox, search, or open conversation, the calendar,
contacts, files, or Settings, along with how far you scrolled the list and the
message you were reading. Middle-click or Ctrl/⌘-click a message to open it in
a background tab (add Shift to switch to it), or choose **Open in new tab**
from its menu. Drag tabs to reorder them, middle-click a tab to close it,
right-click a tab to close the others or those to its right, and pick up where
you left off the next time you open Flectar Mail.

**Ctrl/⌘+T** opens a new tab, **Ctrl/⌘+W** closes the current one, and
**Ctrl/⌘+Shift+T** reopens the last tab you closed. **Ctrl+Tab** and
**Ctrl+Shift+Tab** cycle through tabs, **Ctrl/⌘+1–8** jump to a tab, and
**Ctrl/⌘+9** jumps to the last.

<picture>
<source media="(prefers-color-scheme: dark)" srcset="resources/screenshots/desktop-tabs-dark.png">
Expand Down
8 changes: 5 additions & 3 deletions README.zh_Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ Flectar Mail 是一个轻量、原生的客户端,用来承载你的邮件、

### 👥 分组与账户颜色

把相关账户归入具名分组,例如「工作」或「个人」,再为每个分组指定颜色。在「设置 → 账户 → 分组」中,你可以分配账户、覆盖单个账户的颜色,并在每封邮件的左边缘显示最终生效的颜色。在统一收件箱中处理邮件时,这些色标让账户易于区分;下例中「工作」使用紫色,「支持」使用橙色。
把相关账户归入具名分组,例如「工作」或「个人」,再为每个分组指定颜色。你可以在标签栏最前面的分组菜单中,或在「设置 → 账户 → 分组」中新建分组,并勾选属于它的账户。在该菜单中选择一个分组,邮件视图就只显示这些账户:统一收件箱、搜索、计数和侧边栏都只包含它们的邮件,标签页则保持不变。选择 **全部账户** 即可再次查看所有邮件。

你还可以覆盖单个账户的颜色,并在每封邮件的左边缘显示最终生效的颜色。在统一收件箱中处理邮件时,这些色标让账户易于区分;下例中「工作」使用紫色,「支持」使用橙色。

<picture>
<source media="(prefers-color-scheme: dark)" srcset="resources/screenshots/desktop-profiles-dark.png">
Expand All @@ -99,9 +101,9 @@ Flectar Mail 是一个轻量、原生的客户端,用来承载你的邮件、

### 🗂️ 标签页

借助浏览器式标签页,同时打开多个位置。每个标签页都会记住自己的位置:邮箱、搜索或打开的会话,或者日历、联系人与文件。用鼠标中键或 Ctrl/⌘ 点击邮件,即可在后台标签页中打开(同时按住 Shift 则切换过去),也可以在邮件菜单中选择 **Open in new tab**(在新标签页中打开)。拖动标签页可调整顺序,用鼠标中键点击标签页即可关闭,下次打开 Flectar Mail 时也能从上次离开的地方继续。
借助浏览器式标签页,同时打开多个位置。每个标签页都会记住自己的位置:邮箱、搜索或打开的会话,日历、联系人、文件或设置,以及列表滚动到的位置和正在阅读的邮件。用鼠标中键或 Ctrl/⌘ 点击邮件,即可在后台标签页中打开(同时按住 Shift 则切换过去),也可以在邮件菜单中选择 **Open in new tab**(在新标签页中打开)。拖动标签页可调整顺序,用鼠标中键点击标签页即可关闭,右键点击标签页可关闭其他标签页或其右侧的标签页,下次打开 Flectar Mail 时也能从上次离开的地方继续。

**Ctrl/⌘+T** 新建标签页,**Ctrl/⌘+W** 关闭当前标签页。**Ctrl+Tab** 与 **Ctrl+Shift+Tab** 在标签页之间循环切换,**Ctrl/⌘+1–8** 跳转到对应标签页,**Ctrl/⌘+9** 跳转到最后一个标签页。
**Ctrl/⌘+T** 新建标签页,**Ctrl/⌘+W** 关闭当前标签页,**Ctrl/⌘+Shift+T** 重新打开最近关闭的标签页。**Ctrl+Tab** 与 **Ctrl+Shift+Tab** 在标签页之间循环切换,**Ctrl/⌘+1–8** 跳转到对应标签页,**Ctrl/⌘+9** 跳转到最后一个标签页。

<picture>
<source media="(prefers-color-scheme: dark)" srcset="resources/screenshots/desktop-tabs-dark.png">
Expand Down
3 changes: 3 additions & 0 deletions crates/flectar-mail-core/examples/mailbox_paging_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ async fn page(
view: View::Inbox,
tab: None,
account_id,
account_ids: None,
folder_id: None,
cursor,
limit: 25,
Expand All @@ -39,6 +40,7 @@ async fn count(db: &Db, account_id: Option<i64>) -> Result<usize> {
view: View::Inbox,
tab: None,
account_id,
account_ids: None,
folder_id: None,
cursor: None,
limit: 25,
Expand Down Expand Up @@ -110,6 +112,7 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
view: View::Inbox,
tab: None,
account_id: Some(1),
account_ids: None,
folder_id: None,
cursor: Some(deep_cursor),
limit: 25,
Expand Down
2 changes: 2 additions & 0 deletions crates/flectar-mail-core/examples/sqlite_cache_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
view: View::Inbox,
tab: None,
account_id: Some(1),
account_ids: None,
folder_id: None,
cursor: None,
limit: 50,
Expand All @@ -404,6 +405,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
view: View::Inbox,
tab: None,
account_id: Some(1),
account_ids: None,
folder_id: None,
cursor: None,
limit: 50,
Expand Down
86 changes: 82 additions & 4 deletions crates/flectar-mail-core/src/db/repo/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ fn has_positive_selector(q: &ParsedQuery) -> bool {
|| q.has_attachment == Some(true)
}

/// Append `from:`/`to:`/`in:`/`is:`/`has:` predicates over the `m` alias.
/// Placeholders are `?n` keyed off the running `bind` length, so this composes
/// with any binds already pushed by the caller.
fn append_operator_clauses(
/// Append the caller's account scope over the `m` alias. It narrows a search
/// but is not a matcher by itself, so callers add it after deciding whether
/// the query matches anything.
fn append_account_clauses(
q: &ParsedQuery,
where_clauses: &mut Vec<String>,
bind: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
Expand All @@ -57,6 +57,39 @@ fn append_operator_clauses(
bind.push(Box::new(account_id));
where_clauses.push(format!("m.account_id = ?{}", bind.len()));
}
if let Some(account_ids) = &q.account_ids {
where_clauses.push(account_set_clause("m.account_id", account_ids, bind));
}
}

/// `column IN (…)` for a set of accounts; an empty set matches nothing.
pub(crate) fn account_set_clause(
column: &str,
account_ids: &[i64],
bind: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
) -> String {
if account_ids.is_empty() {
return "0".to_owned();
}
let placeholders = account_ids
.iter()
.map(|account_id| {
bind.push(Box::new(*account_id));
format!("?{}", bind.len())
})
.collect::<Vec<_>>()
.join(", ");
format!("{column} IN ({placeholders})")
}

/// Append `from:`/`to:`/`in:`/`is:`/`has:` predicates over the `m` alias.
/// Placeholders are `?n` keyed off the running `bind` length, so this composes
/// with any binds already pushed by the caller.
fn append_operator_clauses(
q: &ParsedQuery,
where_clauses: &mut Vec<String>,
bind: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
) {
if let Some(from) = &q.from {
bind.push(Box::new(format!("%{}%", from.to_lowercase())));
where_clauses.push(format!(
Expand Down Expand Up @@ -141,6 +174,7 @@ fn structured_thread_ids(conn: &Connection, q: &ParsedQuery, cap: i64) -> Result
if where_clauses.is_empty() && q.fts.is_empty() {
return Ok(Vec::new());
}
append_account_clauses(q, &mut where_clauses, &mut bind);

let rank_expr = if q.fts.is_empty() {
"0.0"
Expand Down Expand Up @@ -207,6 +241,7 @@ fn structured_message_ids(conn: &Connection, q: &ParsedQuery, cap: i64) -> Resul
if where_clauses.is_empty() && q.fts.is_empty() {
return Ok(Vec::new());
}
append_account_clauses(q, &mut where_clauses, &mut bind);

let rank_expr = if q.fts.is_empty() {
"0.0"
Expand Down Expand Up @@ -314,6 +349,7 @@ fn allowed_message_threads(
let mut where_clauses: Vec<String> = Vec::new();
let mut bind: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
append_operator_clauses(q, &mut where_clauses, &mut bind);
append_account_clauses(q, &mut where_clauses, &mut bind);

let id_list = ids
.iter()
Expand Down Expand Up @@ -544,6 +580,7 @@ fn chronological_page_for_query(
next_cursor: None,
});
}
append_account_clauses(q, &mut where_clauses, &mut bind);

let sql = format!(
"SELECT m.thread_id, m.date FROM messages m {fts_join}
Expand Down Expand Up @@ -779,6 +816,47 @@ mod tests {
// Scoped: only the selected account's thread.
assert_eq!(ids(Some(1)), vec![t1]);
assert_eq!(ids(Some(2)), vec![99]);

// A profile's account set scopes every search branch the same way.
let set_ids = |account_ids: Vec<i64>| {
let mut q = crate::search::parse("sharedterm");
q.account_ids = Some(account_ids);
let mut lexical = lexical_thread_ids(&c, &q, 20).unwrap();
lexical.sort();
let mut timeline = chronological(&c, &q, 20)
.unwrap()
.into_iter()
.map(|thread| thread.id)
.collect::<Vec<_>>();
timeline.sort();
assert_eq!(lexical, timeline);
lexical
};
assert_eq!(set_ids(vec![2]), vec![99]);
assert_eq!(set_ids(vec![1, 2]), vec![t1, 99]);
assert_eq!(set_ids(Vec::new()), Vec::<i64>::new());
}

#[test]
fn an_account_scope_alone_matches_nothing() {
let c = testutil::conn();
testutil::seed_account(&c);
let (_, message_id) = testutil::seed_message(&c, "a@one.dev", "anything", false);
index_message(&c, message_id).unwrap();

for scope in [
ParsedQuery {
account_id: Some(1),
..ParsedQuery::default()
},
ParsedQuery {
account_ids: Some(vec![1]),
..ParsedQuery::default()
},
] {
assert!(lexical_thread_ids(&c, &scope, 20).unwrap().is_empty());
assert!(chronological(&c, &scope, 20).unwrap().is_empty());
}
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions crates/flectar-mail-core/src/db/repo/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ mod tests {
);
assert_eq!(d.custom_theme.light_primary, "#0969DA");
assert!(d.mail_profiles.is_empty());
assert!(d.active_mail_profile.is_none());
assert!(!d.show_account_badges);

let mut s = d.clone();
Expand Down Expand Up @@ -100,6 +101,7 @@ mod tests {
color: "#3B82F6".into(),
account_ids: vec![1, 2],
});
s.active_mail_profile = Some("work".into());
set(&c, &s).unwrap();

let back = get(&c).unwrap();
Expand All @@ -121,6 +123,7 @@ mod tests {
assert_eq!(back.signature_list[0].html, "<b>Dean</b>");
assert_eq!(back.ai_automation_rules, s.ai_automation_rules);
assert_eq!(back.mail_profiles, s.mail_profiles);
assert_eq!(back.active_mail_profile.as_deref(), Some("work"));
assert_eq!(
back.signature_defaults
.get("1")
Expand Down Expand Up @@ -161,6 +164,7 @@ mod tests {
assert!(s.auto_labels_enabled);
assert!(s.ai_automation_rules.is_empty());
assert!(s.mail_profiles.is_empty());
assert!(s.active_mail_profile.is_none());
assert!(!s.show_account_badges);
assert!(!s.ai_model_instant.is_empty());
assert!(!s.ai_model_cheap.is_empty());
Expand Down
71 changes: 71 additions & 0 deletions crates/flectar-mail-core/src/db/repo/threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ pub struct ListArgs {
pub view: View,
pub tab: Option<TabFilter>,
pub account_id: Option<i64>,
/// Restrict to a set of accounts, such as a mail profile's; an empty set
/// lists nothing. Combines with `account_id`.
pub account_ids: Option<Vec<i64>>,
/// Restrict to threads with a message in this folder (user-folder view).
pub folder_id: Option<i64>,
pub cursor: Option<ThreadCursor>,
Expand Down Expand Up @@ -342,6 +345,13 @@ fn build_filter(
bind.push(Box::new(acc));
where_clauses.push(format!("t.account_id = ?{}", bind.len()));
}
if let Some(account_ids) = &args.account_ids {
where_clauses.push(super::search::account_set_clause(
"t.account_id",
account_ids,
&mut bind,
));
}
match &args.tab {
Some(TabFilter::Important) => where_clauses.push(format!(
"(t.routed_tab = 'important'
Expand Down Expand Up @@ -541,6 +551,7 @@ mod tests {
view: View::Inbox,
tab: None,
account_id: Some(1),
account_ids: None,
folder_id: Some(1),
cursor: None,
limit: 2,
Expand All @@ -552,6 +563,63 @@ mod tests {
assert_eq!(count(&conn, &args).unwrap(), 3);
}

#[test]
fn account_sets_list_and_count_only_their_accounts() {
let conn = crate::db::testutil::conn();
crate::db::testutil::seed_account(&conn);
let (first, _) = crate::db::testutil::seed_message(&conn, "a@one.dev", "one", false);
conn.execute(
"INSERT INTO accounts (id, email, provider, auth_kind, username,
imap_host, imap_port, smtp_host, smtp_port, created_at)
VALUES (2,'me2@test.dev','imap','password','me2','h',993,'h',587,0)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO folders (id, account_id, imap_name, role) VALUES (2,2,'INBOX','inbox')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO threads (id, account_id, subject_norm, unread_count, last_message_at)
VALUES (99, 2, 'two', 1, 1000)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO messages (thread_id, account_id, folder_id, uid, message_id, subject,
from_addr, date, is_read, is_automated, is_draft, is_outgoing)
VALUES (99, 2, 2, 99, 'mid-99', 'two', 'b@two.dev', 1000, 0, 0, 0, 0)",
[],
)
.unwrap();

let listed = |account_ids: Option<Vec<i64>>| {
let args = ListArgs {
view: View::Inbox,
tab: None,
account_id: None,
account_ids,
folder_id: None,
cursor: None,
limit: 50,
};
let mut ids = list(&conn, &args)
.unwrap()
.threads
.iter()
.map(|thread| thread.id)
.collect::<Vec<_>>();
ids.sort();
assert_eq!(count(&conn, &args).unwrap(), ids.len());
ids
};
assert_eq!(listed(None), vec![first, 99]);
assert_eq!(listed(Some(vec![2])), vec![99]);
assert_eq!(listed(Some(vec![1, 2])), vec![first, 99]);
assert_eq!(listed(Some(Vec::new())), Vec::<i64>::new());
}

#[test]
fn keyset_cursor_never_skips_threads_with_the_same_timestamp() {
let conn = crate::db::testutil::conn();
Expand All @@ -566,6 +634,7 @@ mod tests {
view: View::Inbox,
tab: None,
account_id: None,
account_ids: None,
folder_id: None,
cursor: None,
limit: 2,
Expand All @@ -578,6 +647,7 @@ mod tests {
view: View::Inbox,
tab: None,
account_id: None,
account_ids: None,
folder_id: None,
cursor: first.next_cursor,
limit: 2,
Expand Down Expand Up @@ -606,6 +676,7 @@ mod tests {
view: View::Inbox,
tab: None,
account_id: Some(1),
account_ids: None,
folder_id: None,
cursor: Some(ThreadCursor {
last_message_at: i64::MAX,
Expand Down
Loading
Loading