1use core::fmt;
73
74use alloc::{string::String, vec, vec::Vec};
75
76use imap_codec::{
77 fragmentizer::Fragmentizer,
78 imap_types::{
79 core::{IString, NString},
80 error::ValidationError,
81 response::Capability,
82 },
83};
84use io_sasl::{
85 login::SaslLoginCreds,
86 mechanism::{Sasl, SaslMechanism},
87 rfc4505::anonymous::SaslAnonymousCreds,
88 rfc4616::plain::SaslPlainCreds,
89 rfc7628::oauthbearer::SaslOauthbearerCreds,
90 xoauth2::SaslXoauth2Creds,
91};
92use log::debug;
93use secrecy::ExposeSecret;
94use thiserror::Error;
95#[cfg(feature = "url")]
96use url::Url;
97
98#[cfg(feature = "scram")]
99use crate::rfc7677::auth_scram_sha_256::*;
100use crate::{
101 coroutine::*,
102 imap_try,
103 rfc3501::{capability::*, greeting::*, login::*, starttls::*},
104 rfc7628::auth_oauthbearer::*,
105 sasl::{auth_anonymous::*, auth_login::*, auth_plain::*, auth_xoauth2::*},
106};
107
108#[derive(Debug, Error)]
110pub enum ImapSessionOpenError {
111 #[error("STARTTLS requested on an already-encrypted transport: TLS is active")]
113 StartTlsOverTls,
114 #[error("IMAP STARTTLS response carried trailing bytes: refusing the TLS upgrade")]
120 StartTlsInjection,
121 #[error("{} SASL mechanism is not supported by this crate", .0.as_str())]
128 UnsupportedMechanism(SaslMechanism),
129 #[cfg(feature = "url")]
131 #[error("IMAP URL `{0}` has no host")]
132 UrlMissingHost(String),
133 #[cfg(feature = "url")]
135 #[error("IMAP URL `{0}` has unsupported scheme `{1}` (expected `imap`, `imaps` or `unix`)")]
136 UrlUnsupportedScheme(String, String),
137 #[error("Invalid IMAP LOGIN credentials")]
139 InvalidLoginCredentials(#[from] ValidationError),
140 #[error(transparent)]
142 StartTls(#[from] ImapStartTlsError),
143 #[error(transparent)]
145 Greeting(#[from] ImapGreetingGetError),
146 #[error(transparent)]
148 Capability(#[from] ImapCapabilityGetError),
149 #[error(transparent)]
151 Login(#[from] ImapLoginError),
152 #[error(transparent)]
154 AuthAnonymous(#[from] ImapAuthAnonymousError),
155 #[error(transparent)]
157 AuthLogin(#[from] ImapAuthLoginError),
158 #[error(transparent)]
160 AuthPlain(#[from] ImapAuthPlainError),
161 #[error(transparent)]
163 AuthOauthbearer(#[from] ImapAuthOauthbearerError),
164 #[error(transparent)]
166 AuthXoauth2(#[from] ImapAuthXoauth2Error),
167 #[cfg(feature = "scram")]
169 #[error(transparent)]
170 AuthScramSha256(#[from] ImapAuthScramSha256Error),
171}
172
173#[derive(Clone, Debug, Eq, PartialEq)]
179pub enum ImapSessionTransport {
180 Tcp {
183 host: String,
185 port: u16,
187 },
188 Tls {
190 host: String,
192 port: u16,
194 },
195 Unix(String),
198}
199
200#[cfg(feature = "url")]
201impl ImapSessionTransport {
202 pub fn from_url(url: &Url) -> Result<Self, ImapSessionOpenError> {
208 let scheme = url.scheme();
209
210 if scheme.eq_ignore_ascii_case("unix") {
211 return Ok(Self::Unix(String::from(url.path())));
212 }
213
214 let Some(host) = url.host_str() else {
215 let url = String::from(url.as_str());
216 return Err(ImapSessionOpenError::UrlMissingHost(url));
217 };
218
219 let host = String::from(host);
220 let port = url.port().unwrap_or_else(|| default_port(scheme));
221
222 if scheme.eq_ignore_ascii_case("imap") {
223 Ok(Self::Tcp { host, port })
224 } else if scheme.eq_ignore_ascii_case("imaps") {
225 Ok(Self::Tls { host, port })
226 } else {
227 let scheme = String::from(scheme);
228 let url = String::from(url.as_str());
229 Err(ImapSessionOpenError::UrlUnsupportedScheme(url, scheme))
230 }
231 }
232}
233
234#[derive(Clone, Debug, Default)]
239pub struct ImapSessionOpenOptions {
240 pub starttls: bool,
244 pub auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
250 pub sasl_ir: Option<bool>,
258}
259
260#[derive(Clone, Debug)]
262pub struct ImapSessionOpenData {
263 pub capability: Vec<Capability<'static>>,
266 pub pre_authenticated: bool,
269}
270
271#[derive(Debug)]
277pub enum ImapSessionOpenYield {
278 WantsTcpConnect {
280 host: String,
282 port: u16,
284 },
285 WantsTlsConnect {
287 host: String,
289 port: u16,
291 },
292 WantsUnixConnect(String),
294 WantsTlsUpgrade,
300 WantsRead,
302 WantsWrite(Vec<u8>),
304}
305
306impl From<ImapYield> for ImapSessionOpenYield {
307 fn from(y: ImapYield) -> Self {
308 match y {
309 ImapYield::WantsRead => Self::WantsRead,
310 ImapYield::WantsWrite(bytes) => Self::WantsWrite(bytes),
311 }
312 }
313}
314
315pub struct ImapSessionOpen {
317 state: State,
318 transport: ImapSessionTransport,
319 sasl: Option<Sasl>,
320 capability: Vec<Capability<'static>>,
321 pre_authenticated: bool,
322 opts: ImapSessionOpenOptions,
323}
324
325impl ImapSessionOpen {
326 pub fn new(
333 transport: ImapSessionTransport,
334 sasl: Option<impl Into<Sasl>>,
335 opts: ImapSessionOpenOptions,
336 ) -> Self {
337 Self {
338 state: State::Connect,
339 transport,
340 sasl: sasl.map(Into::into),
341 capability: Vec::new(),
342 pre_authenticated: false,
343 opts,
344 }
345 }
346
347 fn wants_auth(&mut self) -> Result<Option<State>, ImapSessionOpenError> {
353 if self.pre_authenticated {
354 return Ok(None);
355 }
356
357 let Some(sasl) = self.sasl.take() else {
358 return Ok(None);
359 };
360
361 let initial_request = self
362 .opts
363 .sasl_ir
364 .unwrap_or_else(|| self.capability.contains(&Capability::SaslIr));
365 let auto_id = self.opts.auto_id.take();
366 let ensure_capabilities = true;
367
368 let auth = match sasl {
369 Sasl::Anonymous(SaslAnonymousCreds { message }) => {
370 let opts = ImapAuthAnonymousOptions {
371 initial_request,
372 ensure_capabilities,
373 auto_id,
374 };
375
376 Auth::Anonymous(ImapAuthAnonymous::new(message, opts))
377 }
378 Sasl::Login(SaslLoginCreds { username, password }) => {
379 let opts = ImapLoginOptions {
380 ensure_capabilities,
381 auto_id,
382 };
383
384 Auth::Login(ImapLogin::new(username, password.expose_secret(), opts)?)
385 }
386 Sasl::Plain(SaslPlainCreds {
387 authzid,
388 authcid,
389 passwd,
390 }) => {
391 let opts = ImapAuthPlainOptions {
392 initial_request,
393 ensure_capabilities,
394 auto_id,
395 };
396
397 Auth::Plain(ImapAuthPlain::new(
398 authzid,
399 authcid,
400 passwd.expose_secret(),
401 opts,
402 ))
403 }
404 Sasl::Oauthbearer(SaslOauthbearerCreds {
405 username,
406 host,
407 port,
408 token,
409 }) => {
410 let opts = ImapAuthOauthbearerOptions {
411 initial_request,
412 ensure_capabilities,
413 auto_id,
414 };
415
416 Auth::Oauthbearer(ImapAuthOauthbearer::new(
417 username,
418 host,
419 port,
420 token.expose_secret(),
421 opts,
422 ))
423 }
424 Sasl::Xoauth2(SaslXoauth2Creds { username, token }) => {
425 let opts = ImapAuthXoauth2Options {
426 initial_request,
427 ensure_capabilities,
428 auto_id,
429 };
430
431 Auth::Xoauth2(ImapAuthXoauth2::new(username, token.expose_secret(), opts))
432 }
433 #[cfg(feature = "scram")]
434 Sasl::ScramSha256(creds) => {
435 let opts = ImapAuthScramSha256Options {
436 initial_request,
437 ensure_capabilities,
438 auto_id,
439 };
440
441 Auth::ScramSha256(ImapAuthScramSha256::new(creds, opts))
442 }
443 sasl => {
448 let mechanism = sasl.mechanism();
449 return Err(ImapSessionOpenError::UnsupportedMechanism(mechanism));
450 }
451 };
452
453 Ok(Some(State::Auth(auth)))
454 }
455
456 fn complete(
458 &mut self,
459 ) -> ImapCoroutineState<ImapSessionOpenYield, <Self as ImapCoroutine>::Return> {
460 let data = ImapSessionOpenData {
461 capability: core::mem::take(&mut self.capability),
462 pre_authenticated: self.pre_authenticated,
463 };
464
465 ImapCoroutineState::Complete(Ok(data))
466 }
467}
468
469impl ImapCoroutine for ImapSessionOpen {
470 type Yield = ImapSessionOpenYield;
471 type Return = Result<ImapSessionOpenData, ImapSessionOpenError>;
472
473 fn resume(
474 &mut self,
475 fragmentizer: &mut Fragmentizer,
476 arg: Option<&[u8]>,
477 ) -> ImapCoroutineState<Self::Yield, Self::Return> {
478 loop {
479 match &mut self.state {
480 State::Connect => {
481 let is_tls = matches!(self.transport, ImapSessionTransport::Tls { .. });
482
483 if self.opts.starttls && is_tls {
484 let err = ImapSessionOpenError::StartTlsOverTls;
485 return ImapCoroutineState::Complete(Err(err));
486 }
487
488 let yielded = match &self.transport {
492 ImapSessionTransport::Tcp { host, port } => {
493 ImapSessionOpenYield::WantsTcpConnect {
494 host: host.clone(),
495 port: *port,
496 }
497 }
498 ImapSessionTransport::Tls { host, port } => {
499 ImapSessionOpenYield::WantsTlsConnect {
500 host: host.clone(),
501 port: *port,
502 }
503 }
504 ImapSessionTransport::Unix(path) => {
505 ImapSessionOpenYield::WantsUnixConnect(path.clone())
506 }
507 };
508
509 self.state = State::Connected;
510 debug!("{}", self.state);
511
512 return ImapCoroutineState::Yielded(yielded);
513 }
514 State::Connected => {
515 self.state = if self.opts.starttls {
516 State::StartTls(ImapStartTls::new())
517 } else {
518 State::Greeting(ImapGreetingGet::new(ImapGreetingGetOptions {
519 ensure_capabilities: true,
520 }))
521 };
522
523 debug!("{}", self.state);
524 }
525 State::StartTls(starttls) => {
526 let leftover = imap_try!(starttls, fragmentizer, arg);
527
528 if !leftover.is_empty() {
529 let err = ImapSessionOpenError::StartTlsInjection;
530 return ImapCoroutineState::Complete(Err(err));
531 }
532
533 self.state = State::Upgraded;
534 debug!("{}", self.state);
535
536 return ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTlsUpgrade);
537 }
538 State::Upgraded => {
539 self.state = State::Capability(ImapCapabilityGet::new());
545 debug!("{}", self.state);
546 }
547 State::Capability(capability) => {
548 self.capability = imap_try!(capability, fragmentizer, arg);
549
550 match self.wants_auth() {
551 Err(err) => return ImapCoroutineState::Complete(Err(err)),
552 Ok(None) => return self.complete(),
553 Ok(Some(next)) => {
554 self.state = next;
555 debug!("{}", self.state);
556 }
557 }
558 }
559 State::Greeting(greeting) => {
560 let greeting = imap_try!(greeting, fragmentizer, arg);
561
562 self.capability = greeting.capability;
563 self.pre_authenticated = greeting.pre_authenticated;
564
565 match self.wants_auth() {
566 Err(err) => return ImapCoroutineState::Complete(Err(err)),
567 Ok(None) => return self.complete(),
568 Ok(Some(next)) => {
569 self.state = next;
570 debug!("{}", self.state);
571 }
572 }
573 }
574 State::Auth(auth) => {
575 self.capability = match auth {
576 Auth::Anonymous(auth) => imap_try!(auth, fragmentizer, arg),
577 Auth::Login(auth) => imap_try!(auth, fragmentizer, arg),
578 Auth::Plain(auth) => imap_try!(auth, fragmentizer, arg),
579 Auth::Oauthbearer(auth) => imap_try!(auth, fragmentizer, arg),
580 Auth::Xoauth2(auth) => imap_try!(auth, fragmentizer, arg),
581 #[cfg(feature = "scram")]
582 Auth::ScramSha256(auth) => imap_try!(auth, fragmentizer, arg),
583 };
584
585 return self.complete();
586 }
587 }
588 }
589 }
590}
591
592pub fn default_alpn() -> Vec<String> {
596 vec![String::from("imap")]
597}
598
599pub fn default_port(scheme: &str) -> u16 {
601 if scheme.eq_ignore_ascii_case("imaps") {
602 993
603 } else {
604 143
605 }
606}
607
608#[allow(clippy::large_enum_variant)]
613enum State {
614 Connect,
615 Connected,
616 StartTls(ImapStartTls),
617 Upgraded,
618 Capability(ImapCapabilityGet),
619 Greeting(ImapGreetingGet),
620 Auth(Auth),
621}
622
623impl fmt::Display for State {
624 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
625 match self {
626 Self::Connect => f.write_str("open connection"),
627 Self::Connected => f.write_str("connection opened"),
628 Self::StartTls(_) => f.write_str("send starttls"),
629 Self::Upgraded => f.write_str("connection upgraded to tls"),
630 Self::Capability(_) => f.write_str("fetch capabilities"),
631 Self::Greeting(_) => f.write_str("read greeting"),
632 Self::Auth(auth) => write!(f, "authenticate with {auth}"),
633 }
634 }
635}
636
637enum Auth {
638 Anonymous(ImapAuthAnonymous),
639 Login(ImapLogin),
640 Plain(ImapAuthPlain),
641 Oauthbearer(ImapAuthOauthbearer),
642 Xoauth2(ImapAuthXoauth2),
643 #[cfg(feature = "scram")]
644 ScramSha256(ImapAuthScramSha256),
645}
646
647impl fmt::Display for Auth {
648 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
649 match self {
650 Self::Anonymous(_) => f.write_str("anonymous"),
651 Self::Login(_) => f.write_str("login"),
652 Self::Plain(_) => f.write_str("plain"),
653 Self::Oauthbearer(_) => f.write_str("oauthbearer"),
654 Self::Xoauth2(_) => f.write_str("xoauth2"),
655 #[cfg(feature = "scram")]
656 Self::ScramSha256(_) => f.write_str("scram-sha-256"),
657 }
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use alloc::string::ToString;
664
665 use crate::session::*;
666
667 #[test]
668 fn tcp_transport_yields_tcp_connect_then_greeting() {
669 let transport = ImapSessionTransport::Tcp {
670 host: "localhost".to_string(),
671 port: 143,
672 };
673
674 let mut session = ImapSessionOpen::new(transport, None::<Sasl>, Default::default());
675 let mut frag = Fragmentizer::new(50 * 1024 * 1024);
676
677 match session.resume(&mut frag, None) {
678 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTcpConnect { host, port }) => {
679 assert_eq!(host, "localhost");
680 assert_eq!(port, 143);
681 }
682 state => panic!("expected WantsTcpConnect, got {state:?}"),
683 }
684
685 match session.resume(&mut frag, None) {
686 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead) => {}
687 state => panic!("expected WantsRead, got {state:?}"),
688 }
689
690 let greeting = b"* OK [CAPABILITY IMAP4REV1] server ready\r\n";
691 match session.resume(&mut frag, Some(greeting)) {
692 ImapCoroutineState::Complete(Ok(data)) => {
693 assert!(!data.pre_authenticated);
694 assert_eq!(data.capability, vec![Capability::Imap4Rev1]);
695 }
696 state => panic!("expected Complete(Ok), got {state:?}"),
697 }
698 }
699
700 #[test]
701 fn preauth_greeting_skips_the_sasl_step() {
702 let transport = ImapSessionTransport::Unix("/run/sirup.sock".to_string());
703 let sasl = SaslPlainCreds {
704 authzid: None,
705 authcid: "alice".to_string(),
706 passwd: "secret".to_string().into(),
707 };
708
709 let mut session = ImapSessionOpen::new(transport, Some(sasl), Default::default());
710 let mut frag = Fragmentizer::new(50 * 1024 * 1024);
711
712 match session.resume(&mut frag, None) {
713 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsUnixConnect(path)) => {
714 assert_eq!(path, "/run/sirup.sock");
715 }
716 state => panic!("expected WantsUnixConnect, got {state:?}"),
717 }
718
719 match session.resume(&mut frag, None) {
720 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead) => {}
721 state => panic!("expected WantsRead, got {state:?}"),
722 }
723
724 let greeting = b"* PREAUTH [CAPABILITY IMAP4REV1] already authenticated\r\n";
725 match session.resume(&mut frag, Some(greeting)) {
726 ImapCoroutineState::Complete(Ok(data)) => assert!(data.pre_authenticated),
727 state => panic!("expected Complete(Ok), got {state:?}"),
728 }
729 }
730
731 #[test]
732 fn starttls_over_tls_fails_before_opening_a_socket() {
733 let transport = ImapSessionTransport::Tls {
734 host: "localhost".to_string(),
735 port: 993,
736 };
737
738 let opts = ImapSessionOpenOptions {
739 starttls: true,
740 ..Default::default()
741 };
742
743 let mut session = ImapSessionOpen::new(transport, None::<Sasl>, opts);
744 let mut frag = Fragmentizer::new(50 * 1024 * 1024);
745
746 match session.resume(&mut frag, None) {
747 ImapCoroutineState::Complete(Err(ImapSessionOpenError::StartTlsOverTls)) => {}
748 state => panic!("expected StartTlsOverTls, got {state:?}"),
749 }
750 }
751
752 #[test]
753 fn starttls_reaches_the_upgrade_then_refetches_capabilities() {
754 let transport = ImapSessionTransport::Tcp {
755 host: "localhost".to_string(),
756 port: 143,
757 };
758
759 let opts = ImapSessionOpenOptions {
760 starttls: true,
761 ..Default::default()
762 };
763
764 let mut session = ImapSessionOpen::new(transport, None::<Sasl>, opts);
765 let mut frag = Fragmentizer::new(50 * 1024 * 1024);
766
767 assert!(matches!(
768 session.resume(&mut frag, None),
769 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTcpConnect { .. })
770 ));
771 assert!(matches!(
772 session.resume(&mut frag, None),
773 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead)
774 ));
775
776 let greeting = b"* OK server ready\r\n";
779 let command = match session.resume(&mut frag, Some(greeting)) {
780 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => bytes,
781 state => panic!("expected WantsWrite, got {state:?}"),
782 };
783
784 let command = String::from_utf8(command).expect("utf8 command");
785 assert!(command.contains("STARTTLS"));
786 let tag = command
787 .split_whitespace()
788 .next()
789 .expect("first whitespace-separated token")
790 .to_string();
791
792 assert!(matches!(
793 session.resume(&mut frag, None),
794 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead)
795 ));
796
797 let reply = alloc::format!("{tag} OK begin TLS negotiation\r\n");
798 assert!(matches!(
799 session.resume(&mut frag, Some(reply.as_bytes())),
800 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTlsUpgrade)
801 ));
802
803 let command = match session.resume(&mut frag, None) {
807 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => bytes,
808 state => panic!("expected WantsWrite, got {state:?}"),
809 };
810
811 let command = String::from_utf8(command).expect("utf8 command");
812 assert!(command.contains("CAPABILITY"));
813 }
814
815 #[test]
816 fn starttls_trailing_bytes_refuse_the_upgrade() {
817 let transport = ImapSessionTransport::Tcp {
818 host: "localhost".to_string(),
819 port: 143,
820 };
821
822 let opts = ImapSessionOpenOptions {
823 starttls: true,
824 ..Default::default()
825 };
826
827 let mut session = ImapSessionOpen::new(transport, None::<Sasl>, opts);
828 let mut frag = Fragmentizer::new(50 * 1024 * 1024);
829
830 session.resume(&mut frag, None);
831 session.resume(&mut frag, None);
832
833 let command = match session.resume(&mut frag, Some(b"* OK server ready\r\n")) {
834 ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => bytes,
835 state => panic!("expected WantsWrite, got {state:?}"),
836 };
837
838 let command = String::from_utf8(command).expect("utf8 command");
839 let tag = command
840 .split_whitespace()
841 .next()
842 .expect("first whitespace-separated token")
843 .to_string();
844
845 session.resume(&mut frag, None);
846
847 let reply = alloc::format!("{tag} OK begin TLS negotiation\r\na1 NOOP\r\n");
851 match session.resume(&mut frag, Some(reply.as_bytes())) {
852 ImapCoroutineState::Complete(Err(ImapSessionOpenError::StartTlsInjection)) => {}
853 state => panic!("expected StartTlsInjection, got {state:?}"),
854 }
855 }
856
857 #[cfg(feature = "url")]
858 #[test]
859 fn urls_map_onto_transports() {
860 let url = Url::parse("imap://example.org").unwrap();
861 let expected = ImapSessionTransport::Tcp {
862 host: "example.org".to_string(),
863 port: 143,
864 };
865 assert_eq!(ImapSessionTransport::from_url(&url).unwrap(), expected);
866
867 let url = Url::parse("imaps://example.org:1993").unwrap();
868 let expected = ImapSessionTransport::Tls {
869 host: "example.org".to_string(),
870 port: 1993,
871 };
872 assert_eq!(ImapSessionTransport::from_url(&url).unwrap(), expected);
873
874 let url = Url::parse("unix:///run/sirup.sock").unwrap();
875 let expected = ImapSessionTransport::Unix("/run/sirup.sock".to_string());
876 assert_eq!(ImapSessionTransport::from_url(&url).unwrap(), expected);
877
878 let url = Url::parse("http://example.org").unwrap();
879 let err = ImapSessionTransport::from_url(&url).unwrap_err();
880 assert!(matches!(
881 err,
882 ImapSessionOpenError::UrlUnsupportedScheme(_, _)
883 ));
884 }
885}