Skip to content

Commit 2b50652

Browse files
committed
fix: add missing imap id config
1 parent bd6e308 commit 2b50652

5 files changed

Lines changed: 107 additions & 3 deletions

File tree

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ all-features = true
1717
rustdoc-args = ["--cfg", "docsrs"]
1818

1919
[features]
20-
default = ["imap", "jmap", "maildir", "rustls-ring"]
20+
default = ["rustls-ring", "imap", "jmap", "maildir"]
2121
imap = ["dep:io-imap", "io-email/imap", "io-imap/client", "pimalaya-cli/imap"]
2222
jmap = ["dep:base64", "dep:io-jmap", "io-email/jmap", "io-jmap/client", "pimalaya-cli/jmap"]
2323
maildir = ["dep:io-maildir", "io-email/maildir", "io-maildir/client"]

‎config.sample.toml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,17 @@ imap.sasl.plain.password.raw = "REPLACE_ME"
9898
#imap.sasl.scram-sha-256.username = "user@example.org"
9999
#imap.sasl.scram-sha-256.password.raw = "***"
100100

101+
# RFC 2971 ID extension. Some providers (mail.qq.com, fastmail) require an `ID`
102+
# exchange straight after authentication; set `auto = true` to opt in.
103+
# https://www.rfc-editor.org/rfc/rfc2971.html
104+
#imap.id.auto = false
105+
106+
# Per-field policy for the auto-ID command. Keys not listed here are NOT sent.
107+
# `true` substitutes mirador's canned value for the well-known keys (name,
108+
# version, vendor, support-url); unknown keys with `true` fall back to NIL with
109+
# a warning. `false` always sends NIL. An empty map sends `ID NIL`.
110+
#imap.id.fields = { name = true, version = true, vendor = true, support-url = true }
111+
101112
# ---- JMAP backend (RFC 8620 §7.2 EventSource push) -----------------------------
102113

103114
# JMAP server address. Either a bare authority (auto-discovered via

‎src/check.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ fn check_imap(
105105
use io_imap::client::ImapClientStd;
106106
use pimalaya_stream::{sasl::Sasl, tls::Tls};
107107

108+
use crate::config::resolve_auto_id_params;
109+
108110
let result = (|| -> Result<()> {
109111
let mut tls: Tls = imap_config.tls.clone().into();
110112
tls.rustls.alpn = vec!["imap".into()];
@@ -118,7 +120,8 @@ fn check_imap(
118120
Some(cfg.try_into_sasl(host, port))
119121
})
120122
.transpose()?;
121-
let _ = ImapClientStd::connect(&server, &tls, imap_config.starttls, sasl)?;
123+
let auto_id = resolve_auto_id_params(&imap_config.id)?;
124+
let _ = ImapClientStd::connect(&server, &tls, imap_config.starttls, sasl, auto_id)?;
122125
Ok(())
123126
})();
124127

‎src/client.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ pub fn open(account: AccountConfig, backend: Backend) -> Result<EmailClientStd>
8585

8686
#[cfg(feature = "imap")]
8787
fn open_imap(config: crate::config::ImapConfig) -> Result<EmailClientStd> {
88+
use crate::config::resolve_auto_id_params;
89+
8890
let mut tls: Tls = config.tls.into();
8991
tls.rustls.alpn = vec!["imap".into()];
9092
let server = parse_imap_server(&config.server)?;
@@ -96,8 +98,10 @@ fn open_imap(config: crate::config::ImapConfig) -> Result<EmailClientStd> {
9698
Some(cfg.try_into_sasl(host, port))
9799
})
98100
.transpose()?;
101+
let auto_id = resolve_auto_id_params(&config.id)?;
99102

100-
let mut client = EmailClientStd::new().connect_imap(&server, &tls, config.starttls, sasl)?;
103+
let mut client =
104+
EmailClientStd::new().connect_imap(&server, &tls, config.starttls, sasl, auto_id)?;
101105
if let Some(imap) = client.imap.as_mut() {
102106
// NOTE: mirador owns the connection for the lifetime of the
103107
// watch (IDLE + QRESYNC); auto_select would just cause an

‎src/config.rs‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ use std::{
3535
};
3636

3737
use anyhow::Result;
38+
#[cfg(feature = "imap")]
39+
use anyhow::anyhow;
40+
#[cfg(feature = "imap")]
41+
use io_imap::types::{
42+
IntoStatic,
43+
core::{IString, NString},
44+
};
3845
use pimalaya_config::command;
3946
#[cfg(feature = "imap")]
4047
use pimalaya_config::secret::Secret;
@@ -145,6 +152,85 @@ pub struct ImapConfig {
145152
/// Optional SASL credentials. When omitted, the connection skips
146153
/// authentication entirely (no `AUTHENTICATE` is sent).
147154
pub sasl: Option<SaslConfig>,
155+
156+
/// RFC 2971 `ID` extension quirks. Some providers (notably
157+
/// mail.qq.com, fastmail) require an `ID` exchange straight after
158+
/// authentication; set `id.auto = true` to opt in.
159+
#[serde(default)]
160+
pub id: ImapIdConfig,
161+
}
162+
163+
/// Per-account `imap.id.*` quirks.
164+
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
165+
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
166+
pub struct ImapIdConfig {
167+
/// When `true`, the auth coroutine chains an `ID` round-trip
168+
/// after the tagged auth response. Default `false` skips ID
169+
/// entirely.
170+
#[serde(default)]
171+
pub auto: bool,
172+
173+
/// Parameters sent with the auto-ID command. Empty (default)
174+
/// sends `ID NIL`. For each entry: `true` substitutes mirador's
175+
/// canned value for the well-known keys (`name`, `version`,
176+
/// `vendor`, `support-url`) or `NIL` for unknown keys; `false`
177+
/// always sends `NIL`. Keys absent from this map are not
178+
/// transmitted.
179+
#[serde(default)]
180+
pub fields: HashMap<String, bool>,
181+
}
182+
183+
/// Resolves an [`ImapIdConfig`] into the wire-level parameter list
184+
/// passed to the io-imap auth coroutines.
185+
///
186+
/// [`None`] when `auto = false`; otherwise a vec where each entry
187+
/// maps the user-supplied key to either mirador's canned value
188+
/// (when the user set `true` and the key is well-known) or `NIL`.
189+
/// Unknown keys with `true` log a warning and fall back to `NIL`.
190+
#[cfg(feature = "imap")]
191+
pub fn resolve_auto_id_params(
192+
config: &ImapIdConfig,
193+
) -> Result<Option<Vec<(IString<'static>, NString<'static>)>>> {
194+
if !config.auto {
195+
return Ok(None);
196+
}
197+
198+
let mut params = Vec::with_capacity(config.fields.len());
199+
for (key, &use_canned) in &config.fields {
200+
let ikey = IString::try_from(key.clone())
201+
.map_err(|err| anyhow!("Invalid IMAP ID parameter key `{key}`: {err}"))?
202+
.into_static();
203+
204+
let nval = if use_canned {
205+
match canned_imap_id_value(key) {
206+
Some(value) => NString::try_from(value)
207+
.map_err(|err| {
208+
anyhow!("Invalid canned IMAP ID value `{value}` for `{key}`: {err}")
209+
})?
210+
.into_static(),
211+
None => {
212+
log::warn!("imap.id.fields.{key} = true: no canned value defined, sending NIL");
213+
NString::NIL
214+
}
215+
}
216+
} else {
217+
NString::NIL
218+
};
219+
220+
params.push((ikey, nval));
221+
}
222+
Ok(Some(params))
223+
}
224+
225+
#[cfg(feature = "imap")]
226+
fn canned_imap_id_value(key: &str) -> Option<&'static str> {
227+
match key {
228+
"name" => Some(env!("CARGO_PKG_NAME")),
229+
"version" => Some(env!("CARGO_PKG_VERSION")),
230+
"vendor" => Some("Pimalaya"),
231+
"support-url" => Some("https://github.com/pimalaya/mirador"),
232+
_ => None,
233+
}
148234
}
149235

150236
// ---- JMAP ---------------------------------------------------------

0 commit comments

Comments
 (0)