Skip to main content

io_imap/sasl/
auth_login.rs

1//! IMAP SASL LOGIN coroutine (legacy two-prompt mechanism, pre-IETF);
2//! supports both the non-IR and SASL-IR (RFC 4959) flows.
3//!
4//! The mechanism itself lives in io-sasl: this coroutine holds the IMAP
5//! half of the exchange, the `AUTHENTICATE LOGIN` command, the
6//! continuation requests, the tagged response and the post-auth
7//! follow-ups, and asks [`SaslLogin`] what to put in each response. So
8//! the two prompts, their order and the refusal of a third one are the
9//! mechanism's business, and nothing here knows LOGIN sends a username
10//! before a password.
11//!
12//! Background: <https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login>
13//! SASL-IR: <https://www.rfc-editor.org/rfc/rfc4959>
14//!
15//! # Example
16//!
17//! ```rust,no_run
18//! use std::{
19//!     io::{Read, Write},
20//!     net::TcpStream,
21//! };
22//!
23//! use io_imap::{
24//!     codec::fragmentizer::Fragmentizer,
25//!     coroutine::{ImapCoroutine, ImapCoroutineState, ImapYield},
26//!     sasl::auth_login::{ImapAuthLogin, ImapAuthLoginOptions},
27//! };
28//!
29//! // Ready stream needed (TCP-connected, TLS-negotiated)
30//! let mut stream = TcpStream::connect("localhost:143").unwrap();
31//!
32//! let mut fragmentizer = Fragmentizer::new(50 * 1024 * 1024);
33//! let mut buf = [0u8; 4096];
34//!
35//! let opts = ImapAuthLoginOptions::default();
36//! let mut coroutine = ImapAuthLogin::new("alice", "secret", opts);
37//! let mut arg = None;
38//!
39//! let capability = loop {
40//!     match coroutine.resume(&mut fragmentizer, arg.take()) {
41//!         ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
42//!             stream.write_all(&bytes).unwrap();
43//!         }
44//!         ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
45//!             let n = stream.read(&mut buf).unwrap();
46//!             arg = Some(&buf[..n]);
47//!         }
48//!         ImapCoroutineState::Complete(Ok(capability)) => break capability,
49//!         ImapCoroutineState::Complete(Err(err)) => panic!("{err}"),
50//!     }
51//! };
52//!
53//! println!("{capability:?}");
54//! ```
55
56use core::{fmt, mem};
57
58use alloc::{
59    string::{String, ToString},
60    vec,
61    vec::Vec,
62};
63
64use imap_codec::{
65    AuthenticateDataCodec, CommandCodec,
66    fragmentizer::Fragmentizer,
67    imap_types::{
68        auth::{AuthMechanism, AuthenticateData},
69        command::{Command, CommandBody},
70        core::{IString, NString, TagGenerator},
71        response::{
72            Capability, Code, CommandContinuationRequest, Data, StatusBody, StatusKind, Tagged,
73        },
74        secret::Secret,
75    },
76};
77use io_sasl::{
78    coroutine::*,
79    login::{SaslLogin, SaslLoginCreds, SaslLoginError},
80};
81use log::{debug, trace};
82use secrecy::SecretString;
83use thiserror::Error;
84
85use crate::{coroutine::*, imap_try, rfc2971::id::*, rfc3501::capability::*, send::*};
86
87/// Failure causes during the SASL LOGIN flow.
88#[derive(Clone, Debug, Error)]
89pub enum ImapAuthLoginError {
90    /// The server rejected authentication with a tagged NO.
91    #[error("IMAP AUTHENTICATE LOGIN failed: NO {0}")]
92    No(String),
93    /// The server rejected the AUTHENTICATE command with a tagged BAD.
94    #[error("IMAP AUTHENTICATE LOGIN failed: BAD {0}")]
95    Bad(String),
96    /// The server closed the connection with an untagged BYE.
97    #[error("IMAP AUTHENTICATE LOGIN failed: BYE {0}")]
98    Bye(String),
99    /// The server never returned the final tagged response.
100    #[error("IMAP AUTHENTICATE LOGIN failed: server did not return a tagged response")]
101    MissingTagged,
102    /// The server never sent the expected continuation request.
103    #[error(
104        "IMAP AUTHENTICATE LOGIN failed: server did not send the expected continuation request"
105    )]
106    ExpectedContinuationRequest,
107    /// The server returned OK before the mechanism could complete.
108    #[error(
109        "IMAP AUTHENTICATE LOGIN failed: server returned OK before the mechanism could complete"
110    )]
111    UnexpectedOk,
112    /// The mechanism refused the exchange.
113    ///
114    /// A challenge arriving once LOGIN has nothing left to say lands
115    /// here rather than in a framing error of this crate's, only the
116    /// mechanism knowing how many prompts it answers.
117    #[error("IMAP AUTHENTICATE LOGIN failed: {0}")]
118    Mechanism(#[from] SaslLoginError),
119    /// The underlying send coroutine failed.
120    #[error("IMAP AUTHENTICATE LOGIN failed: {0}")]
121    Send(#[from] ImapSendError),
122    /// The follow-up CAPABILITY command failed.
123    #[error(transparent)]
124    Capability(#[from] ImapCapabilityGetError),
125    /// The follow-up ID command failed.
126    #[error(transparent)]
127    ServerId(#[from] ImapServerIdError),
128}
129
130/// Options for [`ImapAuthLogin::new`].
131#[derive(Clone, Debug, Default, Eq, PartialEq)]
132pub struct ImapAuthLoginOptions {
133    /// `true` selects SASL-IR (RFC 4959, inline username);
134    /// `false` selects the non-IR two-prompt flow.
135    pub initial_request: bool,
136    /// Fetch CAPABILITY after authentication when the tagged response
137    /// carries no capability data. Defaults to `false`.
138    pub ensure_capabilities: bool,
139    /// Chain an RFC 2971 ID round-trip right after authentication, as
140    /// required by some providers.
141    ///
142    /// Defaults to `None` (no ID); an empty list sends ID NIL.
143    pub auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
144}
145
146/// I/O-free SASL LOGIN coroutine.
147pub struct ImapAuthLogin {
148    state: State,
149    mechanism: SaslLogin,
150    observed: Vec<Capability<'static>>,
151    opts: ImapAuthLoginOptions,
152}
153
154impl ImapAuthLogin {
155    /// Builds a SASL LOGIN coroutine authenticating `user` with
156    /// `password`.
157    ///
158    /// Depending on `opts.initial_request`, the username goes inline
159    /// with the AUTHENTICATE command (SASL-IR) or is uploaded after
160    /// the first server prompt; the password always follows a prompt.
161    pub fn new(
162        user: impl AsRef<str>,
163        password: impl AsRef<str>,
164        opts: ImapAuthLoginOptions,
165    ) -> Self {
166        let mechanism = SaslLogin::new(SaslLoginCreds {
167            username: user.as_ref().to_string(),
168            password: SecretString::from(password.as_ref().to_string()),
169        });
170
171        Self {
172            state: State::Start,
173            mechanism,
174            observed: Vec::new(),
175            opts,
176        }
177    }
178
179    // helper that tells if the coroutine needs to fetch capability or not (in
180    // case found in data or untagged responses)
181    fn wants_capability(
182        &mut self,
183        code: Option<Code<'static>>,
184        data: Vec<Data<'static>>,
185        untagged: Vec<StatusBody<'static>>,
186    ) -> Option<State> {
187        let mut new_capability = None;
188
189        if let Some(Code::Capability(capability)) = code {
190            new_capability.replace(capability);
191        }
192
193        for data in data {
194            if let Data::Capability(capability) = data {
195                new_capability.replace(capability);
196            }
197        }
198
199        for StatusBody { code, .. } in untagged {
200            if let Some(Code::Capability(capability)) = code {
201                new_capability.replace(capability);
202            }
203        }
204
205        if let Some(capability) = new_capability {
206            self.observed = capability.into_iter().collect();
207        }
208
209        (self.opts.ensure_capabilities && self.observed.is_empty())
210            .then(|| State::Capability(ImapCapabilityGet::new()))
211    }
212
213    // helper that tells if the coroutine needs to exchange ID with server
214    fn wants_id(&mut self) -> Option<State> {
215        let params = self.opts.auto_id.take()?;
216        let wire = (!params.is_empty()).then_some(params);
217        Some(State::Id(ImapServerId::new(ImapServerIdOptions {
218            parameters: wire,
219        })))
220    }
221
222    // helper that tells if the coroutine needs to send continuation auth data
223    fn wants_continue(payload: Vec<u8>) -> State {
224        let auth = AuthenticateData::r#continue(payload);
225        let codec = AuthenticateDataCodec::new();
226        State::Continue(ImapSend::new(codec, auth))
227    }
228
229    // helper that resumes SASL coroutine
230    fn resume_sasl(&mut self, arg: SaslArg<'_>) -> Result<Option<Vec<u8>>, ImapAuthLoginError> {
231        match self.mechanism.resume(arg) {
232            SaslCoroutineState::Yielded(SaslYield::WantsWrite(payload)) => Ok(Some(payload)),
233            SaslCoroutineState::Yielded(SaslYield::WantsRead) => Ok(None),
234            SaslCoroutineState::Complete(Ok(())) => Ok(None),
235            SaslCoroutineState::Complete(Err(err)) => Err(err.into()),
236        }
237    }
238}
239
240impl ImapCoroutine for ImapAuthLogin {
241    type Yield = ImapYield;
242    type Return = Result<Vec<Capability<'static>>, ImapAuthLoginError>;
243
244    fn resume(
245        &mut self,
246        fragmentizer: &mut Fragmentizer,
247        arg: Option<&[u8]>,
248    ) -> ImapCoroutineState<Self::Yield, Self::Return> {
249        loop {
250            match &mut self.state {
251                State::Start => {
252                    let payload = match self.resume_sasl(SaslArg::None) {
253                        Ok(payload) => payload,
254                        Err(err) => return ImapCoroutineState::Complete(Err(err)),
255                    };
256
257                    // NOTE: the initial response travels inline only when the
258                    // server was found to support RFC 4959, which is a decision
259                    // taken before the exchange; otherwise it waits for the
260                    // first prompt.
261                    let (initial_response, pending) = match payload {
262                        Some(payload) if self.opts.initial_request => {
263                            (Some(Secret::new(payload.into())), None)
264                        }
265                        payload => (None, payload),
266                    };
267
268                    let tag = TagGenerator::new().generate();
269                    let body = CommandBody::Authenticate {
270                        mechanism: AuthMechanism::Login,
271                        initial_response,
272                    };
273                    let cmd = Command { tag, body };
274                    trace!("send IMAP command {cmd:?}");
275
276                    self.state = State::Send {
277                        send: ImapSend::new(CommandCodec::new(), cmd),
278                        pending,
279                    };
280                    debug!("{}", self.state);
281                }
282                State::Send { send, pending } => {
283                    let out = imap_try!(send, fragmentizer, arg);
284
285                    if let Some(bye) = out.bye {
286                        let err = ImapAuthLoginError::Bye(bye.text.to_string());
287                        return ImapCoroutineState::Complete(Err(err));
288                    }
289
290                    if let Some(cr) = out.continuation_request {
291                        // NOTE: the username prompt is the implicit
292                        // empty challenge whose answer is the initial
293                        // response, as RFC 4959 defines it, so it is
294                        // answered from what the mechanism already
295                        // yielded rather than fed back to it.
296                        let payload = match pending.take() {
297                            Some(payload) => payload,
298                            None => {
299                                match self.resume_sasl(SaslArg::Input(&extract_challenge(cr))) {
300                                    Ok(payload) => payload.unwrap_or_default(),
301                                    Err(err) => return ImapCoroutineState::Complete(Err(err)),
302                                }
303                            }
304                        };
305
306                        self.state = Self::wants_continue(payload);
307                        debug!("{}", self.state);
308                        continue;
309                    }
310
311                    if let Some(Tagged { body, .. }) = out.tagged {
312                        let err = match body.kind {
313                            StatusKind::Ok => ImapAuthLoginError::UnexpectedOk,
314                            StatusKind::No => ImapAuthLoginError::No(body.text.to_string()),
315                            StatusKind::Bad => ImapAuthLoginError::Bad(body.text.to_string()),
316                        };
317
318                        return ImapCoroutineState::Complete(Err(err));
319                    }
320
321                    let err = ImapAuthLoginError::ExpectedContinuationRequest;
322                    return ImapCoroutineState::Complete(Err(err));
323                }
324                State::Continue(send) => {
325                    let out = imap_try!(send, fragmentizer, arg);
326
327                    if let Some(bye) = out.bye {
328                        let err = ImapAuthLoginError::Bye(bye.text.to_string());
329                        return ImapCoroutineState::Complete(Err(err));
330                    }
331
332                    if let Some(cr) = out.continuation_request {
333                        let payload = match self.resume_sasl(SaslArg::Input(&extract_challenge(cr)))
334                        {
335                            Ok(payload) => payload.unwrap_or_default(),
336                            Err(err) => return ImapCoroutineState::Complete(Err(err)),
337                        };
338
339                        self.state = Self::wants_continue(payload);
340                        debug!("{}", self.state);
341                        continue;
342                    }
343
344                    let Some(Tagged { body, .. }) = out.tagged else {
345                        let err = ImapAuthLoginError::MissingTagged;
346                        return ImapCoroutineState::Complete(Err(err));
347                    };
348
349                    let code = match body.kind {
350                        StatusKind::Ok => body.code,
351                        StatusKind::No => {
352                            let err = ImapAuthLoginError::No(body.text.to_string());
353                            return ImapCoroutineState::Complete(Err(err));
354                        }
355                        StatusKind::Bad => {
356                            let err = ImapAuthLoginError::Bad(body.text.to_string());
357                            return ImapCoroutineState::Complete(Err(err));
358                        }
359                    };
360
361                    // NOTE: the tagged OK ends the exchange, and the mechanism
362                    // is told so rather than dropped: a mechanism performing
363                    // mutual authentication refuses here when it verified
364                    // nothing, which is what stops a success reply from
365                    // standing in for a proof the server never gave.
366                    if let Err(err) = self.resume_sasl(SaslArg::Done) {
367                        return ImapCoroutineState::Complete(Err(err));
368                    }
369
370                    if let Some(next) = self.wants_capability(code, out.data, out.untagged) {
371                        self.state = next;
372                        debug!("{}", self.state);
373                        continue;
374                    }
375
376                    if let Some(next) = self.wants_id() {
377                        self.state = next;
378                        debug!("{}", self.state);
379                        continue;
380                    }
381
382                    let capability = mem::take(&mut self.observed);
383                    return ImapCoroutineState::Complete(Ok(capability));
384                }
385                State::Capability(capability) => {
386                    self.observed = imap_try!(capability, fragmentizer, arg);
387
388                    if let Some(next) = self.wants_id() {
389                        self.state = next;
390                        debug!("{}", self.state);
391                        continue;
392                    }
393
394                    let capability = mem::take(&mut self.observed);
395                    return ImapCoroutineState::Complete(Ok(capability));
396                }
397                State::Id(id) => {
398                    imap_try!(id, fragmentizer, arg);
399                    let capability = mem::take(&mut self.observed);
400                    return ImapCoroutineState::Complete(Ok(capability));
401                }
402            }
403        }
404    }
405}
406
407enum State {
408    Start,
409    Send {
410        send: ImapSend<CommandCodec>,
411        pending: Option<Vec<u8>>,
412    },
413    Continue(ImapSend<AuthenticateDataCodec>),
414    Capability(ImapCapabilityGet),
415    Id(ImapServerId),
416}
417
418impl fmt::Display for State {
419    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420        match self {
421            Self::Start => f.write_str("start mechanism"),
422            Self::Send { pending, .. } if pending.is_some() => f.write_str("send auth"),
423            Self::Send { .. } => f.write_str("send auth with ir"),
424            Self::Continue(_) => f.write_str("send response"),
425            Self::Capability(_) => f.write_str("fetch capabilities"),
426            Self::Id(_) => f.write_str("send id"),
427        }
428    }
429}
430
431fn extract_challenge(cr: CommandContinuationRequest<'static>) -> Vec<u8> {
432    match cr {
433        CommandContinuationRequest::Base64(data) => data.as_ref().to_vec(),
434        CommandContinuationRequest::Basic(_) => vec![],
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use core::str;
441
442    use alloc::format;
443
444    use crate::sasl::auth_login::*;
445
446    #[test]
447    fn ir_success_returns_ok() {
448        let opts = ImapAuthLoginOptions {
449            initial_request: true,
450            ..Default::default()
451        };
452
453        let mut auth = ImapAuthLogin::new("alice", "secret", opts);
454        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
455
456        let bytes = expect_wants_write(&mut auth, &mut frag, None);
457        let line = str::from_utf8(&bytes).expect("utf8 command");
458        let tag = first_word(line);
459        assert!(line.contains("AUTHENTICATE LOGIN "));
460
461        expect_wants_read(&mut auth, &mut frag);
462
463        // NOTE: "Password:" base64 = "UGFzc3dvcmQ6".
464        let pass = expect_wants_write(&mut auth, &mut frag, Some(b"+ UGFzc3dvcmQ6\r\n"));
465        assert!(pass.ends_with(b"\r\n"));
466
467        expect_wants_read(&mut auth, &mut frag);
468
469        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
470        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
471    }
472
473    #[test]
474    fn ir_invalid_password_returns_no_error() {
475        let opts = ImapAuthLoginOptions {
476            initial_request: true,
477            ..Default::default()
478        };
479
480        let mut auth = ImapAuthLogin::new("alice", "wrong", opts);
481        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
482
483        let bytes = expect_wants_write(&mut auth, &mut frag, None);
484        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
485
486        expect_wants_read(&mut auth, &mut frag);
487        expect_wants_write(&mut auth, &mut frag, Some(b"+ UGFzc3dvcmQ6\r\n"));
488        expect_wants_read(&mut auth, &mut frag);
489
490        let reply = format!("{tag} NO authentication failed\r\n");
491        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
492        let ImapAuthLoginError::No(text) = err else {
493            panic!("expected ImapAuthLoginError::No, got {err:?}");
494        };
495        assert_eq!(text, "authentication failed");
496    }
497
498    #[test]
499    fn ir_tagged_bad_returns_bad_error() {
500        let opts = ImapAuthLoginOptions {
501            initial_request: true,
502            ..Default::default()
503        };
504
505        let mut auth = ImapAuthLogin::new("alice", "secret", opts);
506        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
507
508        let bytes = expect_wants_write(&mut auth, &mut frag, None);
509        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
510
511        expect_wants_read(&mut auth, &mut frag);
512
513        let reply = format!("{tag} BAD AUTHENTICATE not enabled\r\n");
514        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
515        let ImapAuthLoginError::Bad(text) = err else {
516            panic!("expected ImapAuthLoginError::Bad, got {err:?}");
517        };
518        assert_eq!(text, "AUTHENTICATE not enabled");
519    }
520
521    #[test]
522    fn non_ir_success_returns_ok() {
523        let opts = ImapAuthLoginOptions::default();
524        let mut auth = ImapAuthLogin::new("alice", "secret", opts);
525        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
526
527        let bytes = expect_wants_write(&mut auth, &mut frag, None);
528        let line = str::from_utf8(&bytes).expect("utf8 command");
529        let tag = first_word(line);
530        assert!(line.trim_end().ends_with("AUTHENTICATE LOGIN"));
531
532        expect_wants_read(&mut auth, &mut frag);
533
534        // NOTE: "Username:" base64 = "VXNlcm5hbWU6".
535        let user = expect_wants_write(&mut auth, &mut frag, Some(b"+ VXNlcm5hbWU6\r\n"));
536        assert!(user.ends_with(b"\r\n"));
537
538        expect_wants_read(&mut auth, &mut frag);
539
540        // NOTE: "Password:" base64 = "UGFzc3dvcmQ6".
541        let pass = expect_wants_write(&mut auth, &mut frag, Some(b"+ UGFzc3dvcmQ6\r\n"));
542        assert!(pass.ends_with(b"\r\n"));
543
544        expect_wants_read(&mut auth, &mut frag);
545
546        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
547        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
548    }
549
550    #[test]
551    fn non_ir_invalid_password_returns_no_error() {
552        let opts = ImapAuthLoginOptions::default();
553        let mut auth = ImapAuthLogin::new("alice", "wrong", opts);
554        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
555
556        let bytes = expect_wants_write(&mut auth, &mut frag, None);
557        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
558
559        expect_wants_read(&mut auth, &mut frag);
560        expect_wants_write(&mut auth, &mut frag, Some(b"+ VXNlcm5hbWU6\r\n"));
561        expect_wants_read(&mut auth, &mut frag);
562        expect_wants_write(&mut auth, &mut frag, Some(b"+ UGFzc3dvcmQ6\r\n"));
563        expect_wants_read(&mut auth, &mut frag);
564
565        let reply = format!("{tag} NO authentication failed\r\n");
566        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
567        let ImapAuthLoginError::No(text) = err else {
568            panic!("expected ImapAuthLoginError::No, got {err:?}");
569        };
570        assert_eq!(text, "authentication failed");
571    }
572
573    #[test]
574    fn ir_extra_prompt_returns_mechanism_error() {
575        let opts = ImapAuthLoginOptions {
576            initial_request: true,
577            ..Default::default()
578        };
579
580        let mut auth = ImapAuthLogin::new("alice", "secret", opts);
581        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
582
583        expect_wants_write(&mut auth, &mut frag, None);
584        expect_wants_read(&mut auth, &mut frag);
585        expect_wants_write(&mut auth, &mut frag, Some(b"+ UGFzc3dvcmQ6\r\n"));
586        expect_wants_read(&mut auth, &mut frag);
587
588        // NOTE: a third prompt, which LOGIN has nothing left to answer.
589        // The refusal is the mechanism's, this crate having no way to
590        // know how many prompts a mechanism answers.
591        let err = expect_complete_err(&mut auth, &mut frag, b"+ UGFzc3dvcmQ6\r\n");
592        let ImapAuthLoginError::Mechanism(SaslLoginError::UnexpectedChallenge) = err else {
593            panic!("expected ImapAuthLoginError::Mechanism, got {err:?}");
594        };
595    }
596
597    fn expect_wants_write(
598        cor: &mut ImapAuthLogin,
599        frag: &mut Fragmentizer,
600        arg: Option<&[u8]>,
601    ) -> Vec<u8> {
602        match cor.resume(frag, arg) {
603            ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => bytes,
604            state => panic!("expected WantsWrite, got {state:?}"),
605        }
606    }
607
608    fn expect_wants_read(cor: &mut ImapAuthLogin, frag: &mut Fragmentizer) {
609        match cor.resume(frag, None) {
610            ImapCoroutineState::Yielded(ImapYield::WantsRead) => {}
611            state => panic!("expected WantsRead, got {state:?}"),
612        }
613    }
614
615    fn expect_complete_ok(cor: &mut ImapAuthLogin, frag: &mut Fragmentizer, reply: &[u8]) {
616        match cor.resume(frag, Some(reply)) {
617            ImapCoroutineState::Complete(Ok(_)) => {}
618            state => panic!("expected Complete(Ok), got {state:?}"),
619        }
620    }
621
622    fn expect_complete_err(
623        cor: &mut ImapAuthLogin,
624        frag: &mut Fragmentizer,
625        reply: &[u8],
626    ) -> ImapAuthLoginError {
627        match cor.resume(frag, Some(reply)) {
628            ImapCoroutineState::Complete(Err(err)) => err,
629            state => panic!("expected Complete(Err), got {state:?}"),
630        }
631    }
632
633    fn first_word(line: &str) -> &str {
634        line.split_whitespace()
635            .next()
636            .expect("first whitespace-separated token")
637    }
638}