Repository: duesee/imap-codec (language: python)

Issue:
feat: Implement `METADATA`
There is currently no support for [METADATA](https://www.rfc-editor.org/rfc/rfc5464) extension.

Encoding SETMETADATA command and decoding METADATA responses returned for GETMETADATA commands does not seem to be possible currently.


Discussion / hints:
[SORT/THREAD](https://github.com/duesee/imap-codec/pull/426) is next. Then [CONDSTORE/QRESYNC](https://github.com/duesee/imap-codec/pull/417). 

METADATA doesn't look too complicated. But the devil may be in the detail. Are you in a position to draft something up? :-)

If not, I can take a look in a few weeks.

Fix patch:
diff --git a/imap-codec/Cargo.toml b/imap-codec/Cargo.toml
index c342a42f..92709b2f 100644
--- a/imap-codec/Cargo.toml
+++ b/imap-codec/Cargo.toml
@@ -27,6 +27,7 @@ ext_mailbox_referrals = ["imap-types/ext_mailbox_referrals"]
 ext_id = ["imap-types/ext_id"]
 ext_sort_thread = ["imap-types/ext_sort_thread"]
 ext_binary = ["imap-types/ext_binary"]
+ext_metadata = ["ext_binary", "imap-types/ext_metadata"]
 # </Forward to imap-types>
 
 # IMAP quirks
diff --git a/imap-codec/fuzz/Cargo.toml b/imap-codec/fuzz/Cargo.toml
index 5b625fde..b50e862d 100644
--- a/imap-codec/fuzz/Cargo.toml
+++ b/imap-codec/fuzz/Cargo.toml
@@ -21,6 +21,7 @@ ext_mailbox_referrals = ["imap-codec/ext_mailbox_referrals"]
 ext_id = ["imap-codec/ext_id"]
 ext_sort_thread = ["imap-codec/ext_sort_thread"]
 ext_binary = ["imap-codec/ext_binary"]
+ext_metadata = ["ext_binary", "imap-codec/ext_metadata"]
 
 # IMAP quirks
 quirk_crlf_relaxed = ["imap-codec/quirk_crlf_relaxed"]
@@ -35,6 +36,7 @@ ext = [
     "ext_id",
     "ext_sort_thread",
     "ext_binary",
+    "ext_metadata",
 ]
 # Enable `Debug`-printing during parsing. This is useful to analyze crashes.
 debug = []
diff --git a/imap-codec/src/codec/encode.rs b/imap-codec/src/codec/encode.rs
index 8d9f9916..c26b633a 100644
--- a/imap-codec/src/codec/encode.rs
+++ b/imap-codec/src/codec/encode.rs
@@ -614,6 +614,44 @@ impl<'a> EncodeIntoContext for CommandBody<'a> {
                     None => ctx.write_all(b"NIL"),
                 }
             }
+            #[cfg(feature = "ext_metadata")]
+            CommandBody::SetMetadata {
+                mailbox,
+                entry_values,
+            } => {
+                ctx.write_all(b"SETMETADATA ")?;
+                mailbox.encode_ctx(ctx)?;
+                ctx.write_all(b" (")?;
+                join_serializable(entry_values.as_ref(), b" ", ctx)?;
+                ctx.write_all(b")")
+            }
+            #[cfg(feature = "ext_metadata")]
+            CommandBody::GetMetadata {
+                options,
+                mailbox,
+                entries,
+            } => {
+                ctx.write_all(b"GETMETADATA")?;
+
+                if !options.is_empty() {
+                    ctx.write_all(b" (")?;
+                    join_serializable(options, b" ", ctx)?;
+                    ctx.write_all(b")")?;
+                }
+
+                ctx.write_all(b" ")?;
+                mailbox.encode_ctx(ctx)?;
+
+                ctx.write_all(b" ")?;
+
+                if entries.as_ref().len() == 1 {
+                    entries.as_ref()[0].encode_ctx(ctx)
+                } else {
+                    ctx.write_all(b"(")?;
+                    join_serializable(entries.as_ref(), b" ", ctx)?;
+                    ctx.write_all(b")")
+                }
+            }
         }
     }
 }
@@ -1174,6 +1212,11 @@ impl<'a> EncodeIntoContext for Code<'a> {
             Code::CompressionActive => ctx.write_all(b"COMPRESSIONACTIVE"),
             Code::OverQuota => ctx.write_all(b"OVERQUOTA"),
             Code::TooBig => ctx.write_all(b"TOOBIG"),
+            #[cfg(feature = "ext_metadata")]
+            Code::Metadata(code) => {
+                ctx.write_all(b"METADATA ")?;
+                code.encode_ctx(ctx)
+            }
             Code::Other(unknown) => unknown.encode_ctx(ctx),
         }
     }
@@ -1344,6 +1387,13 @@ impl<'a> EncodeIntoContext for Data<'a> {
                     }
                 }
             }
+            #[cfg(feature = "ext_metadata")]
+            Data::Metadata { mailbox, items } => {
+                ctx.write_all(b"* METADATA ")?;
+                mailbox.encode_ctx(ctx)?;
+                ctx.write_all(b" ")?;
+                items.encode_ctx(ctx)?;
+            }
         }
 
         ctx.write_all(b"\r\n")
@@ -1722,7 +1772,7 @@ impl<'a> EncodeIntoContext for CommandContinuationRequest<'a> {
     }
 }
 
-mod utils {
+pub(crate) mod utils {
     use std::io::Write;
 
     use super::{EncodeContext, EncodeIntoContext};
diff --git a/imap-codec/src/command.rs b/imap-codec/src/command.rs
index f0237c84..a1bc7c6a 100644
--- a/imap-codec/src/command.rs
+++ b/imap-codec/src/command.rs
@@ -23,10 +23,10 @@ use nom::{
 
 #[cfg(feature = "ext_id")]
 use crate::extensions::id::id;
+#[cfg(feature = "ext_metadata")]
+use crate::extensions::metadata::{getmetadata, setmetadata};
 #[cfg(feature = "ext_sort_thread")]
-use crate::extensions::sort::sort;
-#[cfg(feature = "ext_sort_thread")]
-use crate::extensions::thread::thread;
+use crate::extensions::{sort::sort, thread::thread};
 use crate::{
     auth::auth_type,
     core::{astring, base64, literal, tag_imap},
@@ -107,20 +107,27 @@ pub(crate) fn command_any(input: &[u8]) -> IMAPResult<&[u8], CommandBody> {
 
 // # Command Auth
 
-/// `command-auth = append /
-///                 create /
-///                 delete /
-///                 examine /
-///                 list /
-///                 lsub /
-///                 rename /
-///                 select /
-///                 status /
-///                 subscribe /
-///                 unsubscribe /
-///                 idle ; RFC 2177
-///                 enable ; RFC 5161
-///                 compress ; RFC 4978`
+/// ```abnf
+/// command-auth = append /
+///                create /
+///                delete /
+///                examine /
+///                list /
+///                lsub /
+///                rename /
+///                select /
+///                status /
+///                subscribe /
+///                unsubscribe /
+///                idle /         ; RFC 2177
+///                enable /       ; RFC 5161
+///                compress /     ; RFC 4978
+///                getquota /     ; RFC 9208
+///                getquotaroot / ; RFC 9208
+///                setquota /     ; RFC 9208
+///                setmetadata /  ; RFC 5464
+///                getmetadata    ; RFC 5464
+/// ```
 ///
 /// Note: Valid only in Authenticated or Selected state
 pub(crate) fn command_auth(input: &[u8]) -> IMAPResult<&[u8], CommandBody> {
@@ -142,6 +149,10 @@ pub(crate) fn command_auth(input: &[u8]) -> IMAPResult<&[u8], CommandBody> {
         getquota,
         getquotaroot,
         setquota,
+        #[cfg(feature = "ext_metadata")]
+        setmetadata,
+        #[cfg(feature = "ext_metadata")]
+        getmetadata,
     ))(input)
 }
 
diff --git a/imap-codec/src/extensions.rs b/imap-codec/src/extensions.rs
index 6f8b3132..00bbdcc1 100644
--- a/imap-codec/src/extensions.rs
+++ b/imap-codec/src/extensions.rs
@@ -6,6 +6,8 @@ pub mod enable;
 pub mod id;
 pub mod idle;
 pub mod literal;
+#[cfg(feature = "ext_metadata")]
+pub mod metadata;
 pub mod r#move;
 pub mod quota;
 #[cfg(feature = "ext_sort_thread")]
diff --git a/imap-codec/src/extensions/binary.rs b/imap-codec/src/extensions/binary.rs
index d5334f2a..d60d88b6 100644
--- a/imap-codec/src/extensions/binary.rs
+++ b/imap-codec/src/extensions/binary.rs
@@ -1,4 +1,4 @@
-use std::borrow::Cow;
+use std::{borrow::Cow, io::Write};
 
 #[cfg(not(feature = "quirk_crlf_relaxed"))]
 use abnf_core::streaming::crlf;
@@ -15,6 +15,7 @@ use nom::{
 use crate::{
     core::number,
     decode::{IMAPErrorKind, IMAPParseError, IMAPResult},
+    encode::{EncodeContext, EncodeIntoContext},
 };
 
 #[allow(unused)] // TODO(444)
@@ -64,3 +65,18 @@ pub(crate) fn literal8(input: &[u8]) -> IMAPResult<&[u8], Literal8> {
         },
     ))
 }
+
+impl<'a> EncodeIntoContext for Literal8<'a> {
+    fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> {
+        match self.mode {
+            LiteralMode::Sync => write!(ctx, "~{{{}}}\r\n", self.data.len())?,
+            LiteralMode::NonSync => write!(ctx, "~{{{}+}}\r\n", self.data.len())?,
+        }
+
+        ctx.push_line();
+        ctx.write_all(&self.data)?;
+        ctx.push_literal(self.mode);
+
+        Ok(())
+    }
+}
diff --git a/imap-codec/src/extensions/metadata.rs b/imap-codec/src/extensions/metadata.rs
new file mode 100644
index 00000000..c6c2c991
--- /dev/null
+++ b/imap-codec/src/extensions/metadata.rs
@@ -0,0 +1,598 @@
+//! The IMAP METADATA Extension
+
+use std::io::Write;
+
+use abnf_core::streaming::sp;
+use imap_types::{
+    command::CommandBody,
+    core::Vec1,
+    extensions::metadata::{
+        Depth, Entry, EntryValue, GetMetadataOption, MetadataCode, MetadataResponse, Value,
+    },
+    response::Data,
+};
+use nom::{
+    branch::alt,
+    bytes::streaming::{tag, tag_no_case},
+    combinator::{map, opt, value},
+    error::ErrorKind,
+    multi::separated_list1,
+    sequence::{delimited, preceded, separated_pair, tuple},
+};
+
+use crate::{
+    core::{astring, nstring, number},
+    decode::{IMAPErrorKind, IMAPParseError, IMAPResult},
+    encode::{utils::join_serializable, EncodeContext, EncodeIntoContext},
+    extensions::binary::literal8,
+    mailbox::mailbox,
+};
+
+// ----- Command -----
+
+/// ```abnf
+/// ; empty string for mailbox implies server annotation.
+/// setmetadata  = "SETMETADATA" SP mailbox SP entry-values
+/// ```
+pub(crate) fn setmetadata(input: &[u8]) -> IMAPResult<&[u8], CommandBody> {
+    let mut parser = tuple((
+        tag_no_case("SETMETADATA"),
+        preceded(sp, mailbox),
+        preceded(sp, entry_values),
+    ));
+
+    let (rem, (_, mailbox, entry_values)) = parser(input)?;
+
+    Ok((
+        rem,
+        CommandBody::SetMetadata {
+            mailbox,
+            entry_values,
+        },
+    ))
+}
+
+/// ```abnf
+/// entry-values = "(" entry-value *(SP entry-value) ")"
+/// ```
+pub(crate) fn entry_values(input: &[u8]) -> IMAPResult<&[u8], Vec1<EntryValue>> {
+    map(
+        delimited(tag("("), separated_list1(sp, entry_value), tag(")")),
+        Vec1::unvalidated,
+    )(input)
+}
+
+/// ```abnf
+/// entry-value = entry SP value
+/// ```
+#[inline]
+pub(crate) fn entry_value(input: &[u8]) -> IMAPResult<&[u8], EntryValue> {
+    map(separated_pair(entry, sp, imap_value), |(entry, value)| {
+        EntryValue { entry, value }
+    })(input)
+}
+
+/// Slash-separated path to entry.
+///
+/// Note: MUST NOT contain "*" or "%".
+///
+/// ```abnf
+/// entry = astring
+/// ```
+pub(crate) fn entry(input: &[u8]) -> IMAPResult<&[u8], Entry> {
+    let (rem, astring) = astring(input)?;
+
+    if let Ok(entry) = Entry::try_from(astring) {
+        Ok((rem, entry))
+    } else {
+        Err(nom::Err::Failure(IMAPParseError {
+            input,
+            kind: IMAPErrorKind::Nom(ErrorKind::Verify),
+        }))
+    }
+}
+
+/// ```abnf
+/// value = nstring / literal8
+/// ```
+#[inline]
+pub(crate) fn imap_value(input: &[u8]) -> IMAPResult<&[u8], Value> {
+    alt((map(nstring, Value::NString), map(literal8, Value::Literal8)))(input)
+}
+
+/// ```abnf
+/// getmetadata = "GETMETADATA" [SP getmetadata-options] SP mailbox SP entries
+/// ```
+///
+/// Note: Empty string for mailbox implies server annotation.
+pub(crate) fn getmetadata(input: &[u8]) -> IMAPResult<&[u8], CommandBody> {
+    let mut parser = tuple((
+        tag_no_case("GETMETADATA"),
+        opt(preceded(sp, getmetadata_options)),
+        preceded(sp, mailbox),
+        preceded(sp, entries),
+    ));
+
+    let (rem, (_, options, mailbox, entries)) = parser(input)?;
+
+    Ok((
+        rem,
+        CommandBody::GetMetadata {
+            options: options.map(|x| x.into_inner()).unwrap_or_default(),
+            mailbox,
+            entries,
+        },
+    ))
+}
+
+/// ```abnf
+/// getmetadata-options = "(" getmetadata-option *(SP getmetadata-option) ")"
+/// ```
+pub(crate) fn getmetadata_options(input: &[u8]) -> IMAPResult<&[u8], Vec1<GetMetadataOption>> {
+    map(
+        delimited(tag("("), separated_list1(sp, getmetadata_option), tag(")")),
+        Vec1::unvalidated,
+    )(input)
+}
+
+/// ```abnf
+/// ; tagged-ext-label and tagged-ext-val are defined in [RFC4466].
+/// getmetadata-option = tagged-ext-label [SP tagged-ext-val]
+///
+/// ; Used as a getmetadata-option
+/// maxsize-opt = "MAXSIZE" SP number
+///
+/// ; Used as a getmetadata-option
+/// scope-opt = "DEPTH" SP ("0" / "1" / "infinity")
+/// ```
+pub(crate) fn getmetadata_option(input: &[u8]) -> IMAPResult<&[u8], GetMetadataOption> {
+    // TODO: Generic parser required?
+    alt((
+        map(
+            preceded(tag_no_case("MAXSIZE "), number),
+            GetMetadataOption::MaxSize,
+        ),
+        map(
+            preceded(
+                tag_no_case("DEPTH "),
+                alt((
+                    value(Depth::Null, tag("0")),
+                    value(Depth::One, tag("1")),
+                    value(Depth::Infinity, tag_no_case("infinity")),
+                )),
+            ),
+            GetMetadataOption::Depth,
+        ),
+    ))(input)
+}
+
+/// ```abnf
+/// entries = entry / "(" entry *(SP entry) ")"
+/// ```
+pub(crate) fn entries(input: &[u8]) -> IMAPResult<&[u8], Vec1<Entry>> {
+    alt((
+        map(entry, Vec1::from),
+        map(
+            delimited(tag("("), separated_list1(sp, entry), tag(")")),
+            Vec1::unvalidated,
+        ),
+    ))(input)
+}
+
+// ----- Response -----
+
+/// ```abnf
+/// response-payload =/ metadata-resp
+///
+/// ; empty string for mailbox implies server annotation.
+/// metadata-resp = "METADATA" SP mailbox SP (entry-values / entry-list)
+///
+/// entry-list = entry *(SP entry)
+/// ```
+pub(crate) fn metadata_resp(input: &[u8]) -> IMAPResult<&[u8], Data> {
+    let mut parser = tuple((
+        tag_no_case("METADATA"),
+        preceded(sp, mailbox),
+        preceded(
+            sp,
+            alt((
+                map(entry_values, MetadataResponse::WithValues),
+                map(entry_list, MetadataResponse::WithoutValues),
+            )),
+        ),
+    ));
+
+    let (rem, (_, mailbox, items)) = parser(input)?;
+
+    Ok((rem, Data::Metadata { mailbox, items }))
+}
+
+/// ```abnf
+/// entry-list = entry *(SP entry)
+/// ```
+pub(crate) fn entry_list(input: &[u8]) -> IMAPResult<&[u8], Vec1<Entry>> {
+    map(separated_list1(sp, entry), Vec1::unvalidated)(input)
+}
+
+impl<'a> EncodeIntoContext for MetadataResponse<'a> {
+    fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> {
+        match self {
+            MetadataResponse::WithValues(list) => {
+                ctx.write_all(b"(")?;
+                join_serializable(list.as_ref(), b" ", ctx)?;
+                ctx.write_all(b")")
+            }
+            MetadataResponse::WithoutValues(list) => join_serializable(list.as_ref(), b" ", ctx),
+        }
+    }
+}
+
+/// ```abnf
+/// "LONGENTRIES" SP number /
+/// "MAXSIZE" SP number /
+/// "TOOMANY" /
+/// "NOPRIVATE"
+/// ```
+pub(crate) fn metadata_code(input: &[u8]) -> IMAPResult<&[u8], MetadataCode> {
+    alt((
+        map(
+            preceded(tag_no_case("LONGENTRIES "), number),
+            MetadataCode::LongEntries,
+        ),
+        map(
+            preceded(tag_no_case("MAXSIZE "), number),
+            MetadataCode::MaxSize,
+        ),
+        value(MetadataCode::TooMany, tag_no_case("TOOMANY")),
+        value(MetadataCode::NoPrivate, tag_no_case("NOPRIVATE")),
+    ))(input)
+}
+
+impl EncodeIntoContext for MetadataCode {
+    fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> {
+        match self {
+            MetadataCode::LongEntries(number) => {
+                ctx.write_all(b"LONGENTRIES ")?;
+                number.encode_ctx(ctx)
+            }
+            MetadataCode::MaxSize(number) => {
+                ctx.write_all(b"MAXSIZE ")?;
+                number.encode_ctx(ctx)
+            }
+            MetadataCode::TooMany => ctx.write_all(b"TOOMANY"),
+            MetadataCode::NoPrivate => ctx.write_all(b"NOPRIVATE"),
+        }
+    }
+}
+
+impl<'a> EncodeIntoContext for Entry<'a> {
+    fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> {
+        self.inner().encode_ctx(ctx)
+    }
+}
+
+impl<'a> EncodeIntoContext for Value<'a> {
+    fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> {
+        match self {
+            Value::NString(nstring) => nstring.encode_ctx(ctx),
+            Value::Literal8(literal8) => literal8.encode_ctx(ctx),
+        }
+    }
+}
+
+impl EncodeIntoContext for GetMetadataOption {
+    fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> {
+        match self {
+            GetMetadataOption::MaxSize(number) => {
+                ctx.write_all(b"MAXSIZE ")?;
+                number.encode_ctx(ctx)
+            }
+            GetMetadataOption::Depth(depth) => {
+                ctx.write_all(b"DEPTH ")?;
+                depth.encode_ctx(ctx)
+            }
+        }
+    }
+}
+
+impl EncodeIntoContext for Depth {
+    fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> {
+        ctx.write_all(match self {
+            Depth::Null => b"0",
+            Depth::One => b"1",
+            Depth::Infinity => b"INFINITY",
+        })
+    }
+}
+
+impl<'a> EncodeIntoContext for EntryValue<'a> {
+    fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> {
+        self.entry.encode_ctx(ctx)?;
+        ctx.write_all(b" ")?;
+        self.value.encode_ctx(ctx)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use imap_types::{
+        command::{Command, CommandBody},
+        core::{AString, IString, Literal, LiteralMode, NString, Text, Vec1},
+        extensions::{
+            binary::Literal8,
+            metadata::{
+                Depth, Entry, EntryValue, GetMetadataOption, MetadataCode, MetadataResponse, Value,
+            },
+        },
+        mailbox::{Mailbox, MailboxOther},
+        response::{Code, Data, Response, Status, StatusBody, StatusKind},
+    };
+
+    use crate::testing::{kat_inverse_command, kat_inverse_response};
+
+    #[test]
+    fn test_kat_inverse_command_setmetadata() {
+        kat_inverse_command(&[
+            (
+                b"A SETMETADATA \"\" (/test nil)\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::SetMetadata {
+                        mailbox: Mailbox::Other(MailboxOther::try_from("").unwrap()),
+                        entry_values: Vec1::try_from(vec![EntryValue {
+                            entry: Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                            value: Value::NString(NString(None)),
+                        }])
+                        .unwrap(),
+                    },
+                )
+                .unwrap(),
+            ),
+            (
+                b"A SETMETADATA \"\" (/test \"test\")\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::SetMetadata {
+                        mailbox: Mailbox::Other(MailboxOther::try_from("").unwrap()),
+                        entry_values: Vec1::try_from(vec![EntryValue {
+                            entry: Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                            value: Value::NString(NString(Some(
+                                IString::try_from("test").unwrap(),
+                            ))),
+                        }])
+                        .unwrap(),
+                    },
+                )
+                .unwrap(),
+            ),
+            (
+                b"A SETMETADATA \"\" (/test ~{4+}\r\nt\x00st)\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::SetMetadata {
+                        mailbox: Mailbox::Other(MailboxOther::try_from("").unwrap()),
+                        entry_values: Vec1::try_from(vec![EntryValue {
+                            entry: Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                            value: Value::Literal8(Literal8 {
+                                data: b"t\x00st".as_ref().into(),
+                                mode: LiteralMode::NonSync,
+                            }),
+                        }])
+                        .unwrap(),
+                    },
+                )
+                .unwrap(),
+            ),
+        ]);
+    }
+
+    #[test]
+    fn test_kat_inverse_command_getmetadata() {
+        kat_inverse_command(&[
+            (
+                b"A GETMETADATA \"\" /test\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::GetMetadata {
+                        options: vec![],
+                        mailbox: Mailbox::Other(MailboxOther::try_from("").unwrap()),
+                        entries: Vec1::from(
+                            Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                        ),
+                    },
+                )
+                .unwrap(),
+            ),
+            (
+                b"A GETMETADATA INBOX /test\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::GetMetadata {
+                        options: vec![],
+                        mailbox: Mailbox::Inbox,
+                        entries: Vec1::from(
+                            Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                        ),
+                    },
+                )
+                .unwrap(),
+            ),
+            (
+                b"A GETMETADATA (MAXSIZE 0) INBOX /test\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::GetMetadata {
+                        options: vec![GetMetadataOption::MaxSize(0)],
+                        mailbox: Mailbox::Inbox,
+                        entries: Vec1::from(
+                            Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                        ),
+                    },
+                )
+                .unwrap(),
+            ),
+            (
+                b"A GETMETADATA (MAXSIZE 1337) INBOX /test\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::GetMetadata {
+                        options: vec![GetMetadataOption::MaxSize(1337)],
+                        mailbox: Mailbox::Inbox,
+                        entries: Vec1::from(
+                            Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                        ),
+                    },
+                )
+                .unwrap(),
+            ),
+            (
+                b"A GETMETADATA (DEPTH 0) INBOX /test\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::GetMetadata {
+                        options: vec![GetMetadataOption::Depth(Depth::Null)],
+                        mailbox: Mailbox::Inbox,
+                        entries: Vec1::from(
+                            Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                        ),
+                    },
+                )
+                .unwrap(),
+            ),
+            (
+                b"A GETMETADATA (DEPTH 1) INBOX /test\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::GetMetadata {
+                        options: vec![GetMetadataOption::Depth(Depth::One)],
+                        mailbox: Mailbox::Inbox,
+                        entries: Vec1::from(
+                            Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                        ),
+                    },
+                )
+                .unwrap(),
+            ),
+            (
+                b"A GETMETADATA (DEPTH infinity) INBOX /test\r\n".as_ref(),
+                b"".as_ref(),
+                Command::new(
+                    "A",
+                    CommandBody::GetMetadata {
+                        options: vec![GetMetadataOption::Depth(Depth::Infinity)],
+                        mailbox: Mailbox::Inbox,
+                        entries: Vec1::from(
+                            Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                        ),
+                    },
+                )
+                .unwrap(),
+            ),
+        ]);
+    }
+
+    #[test]
+    fn test_kat_inverse_response_metadata() {
+        kat_inverse_response(&[
+            (
+                b"* metadata INBOX /test /xxx\r\n".as_ref(),
+                b"".as_ref(),
+                Response::Data(Data::Metadata {
+                    mailbox: Mailbox::Inbox,
+                    items: MetadataResponse::WithoutValues(
+                        Vec1::try_from(vec![
+                            Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                            Entry::try_from(AString::try_from("/xxx").unwrap()).unwrap(),
+                        ])
+                        .unwrap(),
+                    ),
+                }),
+            ),
+            (
+                b"* metadata INBOX (/test {4}\r\nABCD)\r\n".as_ref(),
+                b"".as_ref(),
+                Response::Data(Data::Metadata {
+                    mailbox: Mailbox::Inbox,
+                    items: MetadataResponse::WithValues(
+                        Vec1::try_from(vec![EntryValue {
+                            entry: Entry::try_from(AString::try_from("/test").unwrap()).unwrap(),
+                            value: Value::NString(NString(Some(IString::Literal(
+                                Literal::try_from("ABCD").unwrap(),
+                            )))),
+                        }])
+                        .unwrap(),
+                    ),
+                }),
+            ),
+        ]);
+    }
+
+    #[test]
+    fn test_kat_inverse_response_metadata_code() {
+        kat_inverse_response(&[
+            (
+                b"* OK [metadata longentries 0] ...\r\n".as_ref(),
+                b"".as_ref(),
+                Response::Status(Status::Untagged(StatusBody {
+                    kind: StatusKind::Ok,
+                    code: Some(Code::Metadata(MetadataCode::LongEntries(0))),
+                    text: Text::try_from("...").unwrap(),
+                })),
+            ),
+            (
+                b"* OK [metadata longentries 4294967295] ...\r\n".as_ref(),
+                b"".as_ref(),
+                Response::Status(Status::Untagged(StatusBody {
+                    kind: StatusKind::Ok,
+                    code: Some(Code::Metadata(MetadataCode::LongEntries(u32::MAX))),
+                    text: Text::try_from("...").unwrap(),
+                })),
+            ),
+            (
+                b"* OK [metadatA maxSIZE 0] ...\r\n".as_ref(),
+                b"".as_ref(),
+                Response::Status(Status::Untagged(StatusBody {
+                    kind: StatusKind::Ok,
+                    code: Some(Code::Metadata(MetadataCode::MaxSize(0))),
+                    text: Text::try_from("...").unwrap(),
+                })),
+            ),
+            (
+                b"* OK [metadata maxSizE 4294967295] ...\r\n".as_ref(),
+                b"".as_ref(),
+                Response::Status(Status::Untagged(StatusBody {
+                    kind: StatusKind::Ok,
+                    code: Some(Code::Metadata(MetadataCode::MaxSize(u32::MAX))),
+                    text: Text::try_from("...").unwrap(),
+                })),
+            ),
+            (
+                b"* OK [metadata toOmaNy] ...\r\n".as_ref(),
+                b"".as_ref(),
+                Response::Status(Status::Untagged(StatusBody {
+                    kind: StatusKind::Ok,
+                    code: Some(Code::Metadata(MetadataCode::TooMany)),
+                    text: Text::try_from("...").unwrap(),
+                })),
+            ),
+            (
+                b"* OK [mEtadata noPRIvaTE] ...\r\n".as_ref(),
+                b"".as_ref(),
+                Response::Status(Status::Untagged(StatusBody {
+                    kind: StatusKind::Ok,
+                    code: Some(Code::Metadata(MetadataCode::NoPrivate)),
+                    text: Text::try_from("...").unwrap(),
+                })),
+            ),
+        ]);
+    }
+}
diff --git a/imap-codec/src/mailbox.rs b/imap-codec/src/mailbox.rs
index a9fb4218..52f19698 100644
--- a/imap-codec/src/mailbox.rs
+++ b/imap-codec/src/mailbox.rs
@@ -14,6 +14,8 @@ use nom::{
     sequence::{delimited, preceded, tuple},
 };
 
+#[cfg(feature = "ext_metadata")]
+use crate::extensions::metadata::metadata_resp;
 #[cfg(feature = "ext_sort_thread")]
 use crate::extensions::thread::thread_data;
 use crate::{
@@ -54,13 +56,16 @@ pub(crate) fn mailbox(input: &[u8]) -> IMAPResult<&[u8], Mailbox> {
     map(astring, Mailbox::from)(input)
 }
 
-/// `mailbox-data = "FLAGS" SP flag-list /
-///                 "LIST" SP mailbox-list /
-///                 "LSUB" SP mailbox-list /
-///                 "SEARCH" *(SP nz-number) /
-///                 "STATUS" SP mailbox SP "(" [status-att-list] ")" /
-///                 number SP "EXISTS" /
-///                 number SP "RECENT"`
+/// ```abnf
+/// mailbox-data = "FLAGS" SP flag-list /
+///                "LIST" SP mailbox-list /
+///                "LSUB" SP mailbox-list /
+///                "SEARCH" *(SP nz-number) /
+///                "STATUS" SP mailbox SP "(" [status-att-list] ")" /
+///                "METADATA" SP mailbox SP (entry-values / entry-list) / ; RFC 5464
+///                number SP "EXISTS" /
+///                number SP "RECENT"
+/// ```
 pub(crate) fn mailbox_data(input: &[u8]) -> IMAPResult<&[u8], Data> {
     alt((
         map(
@@ -107,6 +112,8 @@ pub(crate) fn mailbox_data(input: &[u8]) -> IMAPResult<&[u8], Data> {
                 items: items.unwrap_or_default().into(),
             },
         ),
+        #[cfg(feature = "ext_metadata")]
+        metadata_resp,
         map(
             tuple((number, sp, tag_no_case(b"EXISTS"))),
             |(num, _, _)| Data::Exists(num),
diff --git a/imap-codec/src/response.rs b/imap-codec/src/response.rs
index ab6e1ff4..b2b4e529 100644
--- a/imap-codec/src/response.rs
+++ b/imap-codec/src/response.rs
@@ -23,6 +23,8 @@ use nom::{
 
 #[cfg(feature = "ext_id")]
 use crate::extensions::id::id_response;
+#[cfg(feature = "ext_metadata")]
+use crate::extensions::metadata::metadata_code;
 use crate::{
     core::{atom, charset, nz_number, tag_imap, text},
     decode::IMAPResult,
@@ -113,20 +115,30 @@ pub(crate) fn resp_text(input: &[u8]) -> IMAPResult<&[u8], (Option<Code>, Text)>
     }
 }
 
-/// `resp-text-code = "ALERT" /
-///                   "BADCHARSET" [SP "(" charset *(SP charset) ")" ] /
-///                   capability-data /
-///                   "PARSE" /
-///                   "PERMANENTFLAGS" SP "(" [flag-perm *(SP flag-perm)] ")" /
-///                   "READ-ONLY" /
-///                   "READ-WRITE" /
-///                   "TRYCREATE" /
-///                   "UIDNEXT" SP nz-number /
-///                   "UIDVALIDITY" SP nz-number /
-///                   "UNSEEN" SP nz-number /
-///                   "COMPRESSIONACTIVE" ; RFC 4978
-///                   atom [SP 1*<any TEXT-CHAR except "]">]`
-///
+/// ```abnf
+/// resp-text-code = "ALERT" /
+///                  "BADCHARSET" [SP "(" charset *(SP charset) ")" ] /
+///                  capability-data /
+///                  "PARSE" /
+///                  "PERMANENTFLAGS" SP "(" [flag-perm *(SP flag-perm)] ")" /
+///                  "READ-ONLY" /
+///                  "READ-WRITE" /
+///                  "TRYCREATE" /
+///                  "UIDNEXT" SP nz-number /
+///                  "UIDVALIDITY" SP nz-number /
+///                  "UNSEEN" SP nz-number /
+///                  "COMPRESSIONACTIVE" / ; RFC 4978
+///                  "OVERQUOTA" /         ; RFC 9208
+///                  "TOOBIG" /            ; RFC 4469
+///                  "METADATA" SP (       ; RFC 5464
+//                     "LONGENTRIES" SP number /
+//                     "MAXSIZE" SP number /
+//                     "TOOMANY" /
+//                     "NOPRIVATE"
+//                   ) /
+///                  atom [SP 1*<any TEXT-CHAR except "]">]
+/// ```
+/// 
 /// Note: See errata id: 261
 pub(crate) fn resp_text_code(input: &[u8]) -> IMAPResult<&[u8], Code> {
     alt((
@@ -177,6 +189,11 @@ pub(crate) fn resp_text_code(input: &[u8]) -> IMAPResult<&[u8], Code> {
         value(Code::CompressionActive, tag_no_case(b"COMPRESSIONACTIVE")),
         value(Code::OverQuota, tag_no_case(b"OVERQUOTA")),
         value(Code::TooBig, tag_no_case(b"TOOBIG")),
+        #[cfg(feature = "ext_metadata")]
+        map(
+            preceded(tag_no_case("METADATA "), metadata_code),
+            Code::Metadata,
+        ),
     ))(input)
 }
 
diff --git a/imap-types/Cargo.toml b/imap-types/Cargo.toml
index 0016ef62..cfc0d58b 100644
--- a/imap-types/Cargo.toml
+++ b/imap-types/Cargo.toml
@@ -24,6 +24,7 @@ ext_mailbox_referrals = []
 ext_id = []
 ext_sort_thread = []
 ext_binary = []
+ext_metadata = ["ext_binary"]
 
 # Unlock `unvalidated` constructors.
 unvalidated = []
diff --git a/imap-types/fuzz/Cargo.toml b/imap-types/fuzz/Cargo.toml
index cbea48d9..15643a1c 100644
--- a/imap-types/fuzz/Cargo.toml
+++ b/imap-types/fuzz/Cargo.toml
@@ -21,6 +21,7 @@ ext_mailbox_referrals = ["imap-types/ext_mailbox_referrals"]
 ext_id = ["imap-types/ext_id"]
 ext_sort_thread = ["imap-types/ext_sort_thread"]
 ext_binary = ["imap-types/ext_binary"]
+ext_metadata = ["ext_binary", "imap-types/ext_metadata"]
 # </Forward to imap-types>
 
 # Use (most) IMAP extensions.
diff --git a/imap-types/src/command.rs b/imap-types/src/command.rs
index 75e13970..8a390915 100644
--- a/imap-types/src/command.rs
+++ b/imap-types/src/command.rs
@@ -13,10 +13,10 @@ use serde::{Deserialize, Serialize};
 
 #[cfg(feature = "ext_id")]
 use crate::core::{IString, NString};
+#[cfg(feature = "ext_metadata")]
+use crate::extensions::metadata::{Entry, EntryValue, GetMetadataOption};
 #[cfg(feature = "ext_sort_thread")]
-use crate::extensions::sort::SortCriterion;
-#[cfg(feature = "ext_sort_thread")]
-use crate::extensions::thread::ThreadingAlgorithm;
+use crate::extensions::{sort::SortCriterion, thread::ThreadingAlgorithm};
 use crate::{
     auth::AuthMechanism,
     command::error::{AppendError, CopyError, ListError, LoginError, RenameError},
@@ -1421,6 +1421,19 @@ pub enum CommandBody<'a> {
         /// Parameters.
         parameters: Option<Vec<(IString<'a>, NString<'a>)>>,
     },
+
+    #[cfg(feature = "ext_metadata")]
+    SetMetadata {
+        mailbox: Mailbox<'a>,
+        entry_values: Vec1<EntryValue<'a>>,
+    },
+
+    #[cfg(feature = "ext_metadata")]
+    GetMetadata {
+        options: Vec<GetMetadataOption>,
+        mailbox: Mailbox<'a>,
+        entries: Vec1<Entry<'a>>,
+    },
 }
 
 impl<'a> CommandBody<'a> {
@@ -1710,6 +1723,10 @@ impl<'a> CommandBody<'a> {
             Self::Move { .. } => "MOVE",
             #[cfg(feature = "ext_id")]
             Self::Id { .. } => "ID",
+            #[cfg(feature = "ext_metadata")]
+            Self::SetMetadata { .. } => "SETMETADATA",
+            #[cfg(feature = "ext_metadata")]
+            Self::GetMetadata { .. } => "GETMETADATA",
         }
     }
 }
diff --git a/imap-types/src/extensions.rs b/imap-types/src/extensions.rs
index 8b8c7738..b5b87cba 100644
--- a/imap-types/src/extensions.rs
+++ b/imap-types/src/extensions.rs
@@ -5,6 +5,8 @@ pub mod binary;
 pub mod compress;
 pub mod enable;
 pub mod idle;
+#[cfg(feature = "ext_metadata")]
+pub mod metadata;
 pub mod r#move;
 pub mod quota;
 #[cfg(feature = "ext_sort_thread")]
diff --git a/imap-types/src/extensions/enable.rs b/imap-types/src/extensions/enable.rs
index 61f50332..ad3cb74b 100644
--- a/imap-types/src/extensions/enable.rs
+++ b/imap-types/src/extensions/enable.rs
@@ -41,6 +41,12 @@ pub enum CapabilityEnable<'a> {
     #[cfg(feature = "ext_condstore_qresync")]
     #[cfg_attr(docsrs, doc(cfg(feature = "ext_condstore_qresync")))]
     CondStore,
+    #[cfg(feature = "ext_metadata")]
+    /// Client can handle unsolicited server annotations and mailbox annotations.
+    Metadata,
+    #[cfg(feature = "ext_metadata")]
+    /// Client can handle server annotations.
+    MetadataServer,
     Other(CapabilityEnableOther<'a>),
 }
 
@@ -59,6 +65,10 @@ impl<'a> From<Atom<'a>> for CapabilityEnable<'a> {
             "utf8=only" => Self::Utf8(Utf8Kind::Only),
             #[cfg(feature = "ext_condstore_qresync")]
             "condstore" => Self::CondStore,
+            #[cfg(feature = "ext_metadata")]
+            "metadata" => Self::Metadata,
+            #[cfg(feature = "ext_metadata")]
+            "metadata-server" => Self::MetadataServer,
             _ => Self::Other(CapabilityEnableOther(atom)),
         }
     }
@@ -70,6 +80,10 @@ impl<'a> Display for CapabilityEnable<'a> {
             Self::Utf8(kind) => write!(f, "UTF8={}", kind),
             #[cfg(feature = "ext_condstore_qresync")]
             Self::CondStore => write!(f, "CONDSTORE"),
+            #[cfg(feature = "ext_metadata")]
+            Self::Metadata => write!(f, "METADATA"),
+            #[cfg(feature = "ext_metadata")]
+            Self::MetadataServer => write!(f, "METADATA-SERVER"),
             Self::Other(other) => write!(f, "{}", other.0),
         }
     }
diff --git a/imap-types/src/extensions/metadata.rs b/imap-types/src/extensions/metadata.rs
new file mode 100644
index 00000000..3378a518
--- /dev/null
+++ b/imap-types/src/extensions/metadata.rs
@@ -0,0 +1,109 @@
+#[cfg(feature = "arbitrary")]
+use arbitrary::Arbitrary;
+#[cfg(feature = "bounded-static")]
+use bounded_static::ToStatic;
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+use crate::{
+    core::{AString, NString, Vec1},
+    error::ValidationError,
+    extensions::binary::Literal8,
+};
+
+#[cfg_attr(feature = "arbitrary", derive(Arbitrary))]
+#[cfg_attr(feature = "bounded-static", derive(ToStatic))]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub struct EntryValue<'a> {
+    pub entry: Entry<'a>,
+    pub value: Value<'a>,
+}
+
+/// Slash-separated path to entry.
+#[cfg_attr(feature = "arbitrary", derive(Arbitrary))]
+#[cfg_attr(feature = "bounded-static", derive(ToStatic))]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub struct Entry<'a>(AString<'a>);
+
+impl<'a> Entry<'a> {
+    pub fn inner(&self) -> &AString<'a> {
+        &self.0
+    }
+}
+
+impl<'a> TryFrom<AString<'a>> for Entry<'a> {
+    type Error = ValidationError;
+
+    fn try_from(value: AString<'a>) -> Result<Self, Self::Error> {
+        // TODO(#449): Currently, we do no validation. Let's gather more
+        //             practical experience before settling on a representation.
+        Ok(Self(value))
+    }
+}
+
+impl AsRef<[u8]> for Entry<'_> {
+    fn as_ref(&self) -> &[u8] {
+        self.0.as_ref()
+    }
+}
+
+#[cfg_attr(feature = "arbitrary", derive(Arbitrary))]
+#[cfg_attr(feature = "bounded-static", derive(ToStatic))]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub enum Value<'a> {
+    NString(NString<'a>),
+    Literal8(Literal8<'a>),
+}
+
+#[cfg_attr(feature = "arbitrary", derive(Arbitrary))]
+#[cfg_attr(feature = "bounded-static", derive(ToStatic))]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub enum GetMetadataOption {
+    /// Only return values that are less than or equal in octet size to the specified limit.
+    ///
+    /// If there are any entries with values  larger than the MAXSIZE limit, the server MUST include
+    /// the METADATA LONGENTRIES response code in the tagged OK response for the GETMETADATA command.
+    MaxSize(u32),
+    /// Extends the list of entry values returned by the server.
+    ///
+    /// For each entry name specified in the GETMETADATA command, the server returns the value of the
+    /// specified entry name (if it exists), plus all entries below the entry name up to the specified DEPTH.
+    Depth(Depth),
+}
+
+#[cfg_attr(feature = "arbitrary", derive(Arbitrary))]
+#[cfg_attr(feature = "bounded-static", derive(ToStatic))]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub enum Depth {
+    /// No entries below the specified entry are returned
+    Null,
+    /// Only entries immediately below the specified entry are returned
+    One,
+    /// All entries below the specified entry are returned
+    Infinity,
+}
+
+#[cfg_attr(feature = "arbitrary", derive(Arbitrary))]
+#[cfg_attr(feature = "bounded-static", derive(ToStatic))]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub enum MetadataCode {
+    LongEntries(u32),
+    MaxSize(u32),
+    TooMany,
+    NoPrivate,
+}
+
+#[cfg_attr(feature = "arbitrary", derive(Arbitrary))]
+#[cfg_attr(feature = "bounded-static", derive(ToStatic))]
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub enum MetadataResponse<'a> {
+    WithValues(Vec1<EntryValue<'a>>),
+    WithoutValues(Vec1<Entry<'a>>),
+}
diff --git a/imap-types/src/lib.rs b/imap-types/src/lib.rs
index cad55069..37fb1be5 100644
--- a/imap-types/src/lib.rs
+++ b/imap-types/src/lib.rs
@@ -122,6 +122,7 @@
 //! |ext_login_referrals  |IMAP4 Login Referrals ([RFC 2221])                                                     |Unfinished|
 //! |ext_mailbox_referrals|IMAP4 Mailbox Referrals ([RFC 2193])                                                   |Unfinished|
 //! |ext_binary           |IMAP4 Binary Content Extension ([RFC 3516])                                            |Unfinished|
+//! |ext_metadata         |The IMAP METADATA Extension ([RFC 5464])                                               |Unfinished|
 //! |starttls             |IMAP4rev1 ([RFC 3501]; section 6.2.1)                                                  |          |
 //!
 //! STARTTLS is not an IMAP extension but feature-gated because it [should be avoided](https://nostarttls.secvuln.info/).
@@ -158,6 +159,7 @@
 //! [RFC 4978]: https://datatracker.ietf.org/doc/html/rfc4978
 //! [RFC 5161]: https://datatracker.ietf.org/doc/html/rfc5161
 //! [RFC 5256]: https://datatracker.ietf.org/doc/html/rfc5256
+//! [RFC 5464]: https://datatracker.ietf.org/doc/html/rfc5464
 //! [RFC 5957]: https://datatracker.ietf.org/doc/html/rfc5957
 //! [RFC 6851]: https://datatracker.ietf.org/doc/html/rfc6851
 //! [RFC 7162]: https://datatracker.ietf.org/doc/html/rfc7162
diff --git a/imap-types/src/response.rs b/imap-types/src/response.rs
index fffa2f5d..c396d085 100644
--- a/imap-types/src/response.rs
+++ b/imap-types/src/response.rs
@@ -16,6 +16,8 @@ use serde::{Deserialize, Serialize};
 
 #[cfg(feature = "ext_id")]
 use crate::core::{IString, NString};
+#[cfg(feature = "ext_metadata")]
+use crate::extensions::metadata::{MetadataCode, MetadataResponse};
 #[cfg(feature = "ext_sort_thread")]
 use crate::extensions::sort::SortAlgorithm;
 #[cfg(feature = "ext_sort_thread")]
@@ -572,6 +574,13 @@ pub enum Data<'a> {
         /// Parameters
         parameters: Option<Vec<(IString<'a>, NString<'a>)>>,
     },
+
+    #[cfg(feature = "ext_metadata")]
+    /// Metadata response
+    Metadata {
+        mailbox: Mailbox<'a>,
+        items: MetadataResponse<'a>,
+    },
 }
 
 impl<'a> Data<'a> {
@@ -832,6 +841,10 @@ pub enum Code<'a> {
     /// Server got a non-synchronizing literal larger than 4096 bytes.
     TooBig,
 
+    #[cfg(feature = "ext_metadata")]
+    /// Metadata
+    Metadata(MetadataCode),
+
     /// Additional response codes defined by particular client or server
     /// implementations SHOULD be prefixed with an "X" until they are
     /// added to a revision of this protocol.  Client implementations
@@ -975,6 +988,12 @@ pub enum Capability<'a> {
     Sort(Option<SortAlgorithm<'a>>),
     #[cfg(feature = "ext_sort_thread")]
     Thread(ThreadingAlgorithm<'a>),
+    #[cfg(feature = "ext_metadata")]
+    /// Server supports (both) server annotations and mailbox annotations.
+    Metadata,
+    #[cfg(feature = "ext_metadata")]
+    /// Server supports (only) server annotations.
+    MetadataServer,
     /// Other/Unknown
     Other(CapabilityOther<'a>),
 }
@@ -1011,6 +1030,10 @@ impl<'a> Display for Capability<'a> {
             Self::Sort(Some(algorithm)) => write!(f, "SORT={}", algorithm),
             #[cfg(feature = "ext_sort_thread")]
             Self::Thread(algorithm) => write!(f, "THREAD={}", algorithm),
+            #[cfg(feature = "ext_metadata")]
+            Self::Metadata => write!(f, "METADATA"),
+            #[cfg(feature = "ext_metadata")]
+            Self::MetadataServer => write!(f, "METADATA-SERVER"),
             Self::Other(other) => write!(f, "{}", other.0),
         }
     }
@@ -1070,6 +1093,10 @@ impl<'a> From<Atom<'a>> for Capability<'a> {
             "id" => Self::Id,
             #[cfg(feature = "ext_sort_thread")]
             "sort" => Self::Sort(None),
+            #[cfg(feature = "ext_metadata")]
+            "metadata" => Self::Metadata,
+            #[cfg(feature = "ext_metadata")]
+            "metadata-server" => Self::MetadataServer,
             "unselect" => Self::Unselect,
             _ => {
                 // TODO(efficiency)


Test patch:
diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml
index 52ce61bc..228933d2 100644
--- a/.github/workflows/build_and_test.yml
+++ b/.github/workflows/build_and_test.yml
@@ -38,7 +38,7 @@ jobs:
           --workspace \
           --exclude tokio-client --exclude tokio-server --exclude tokio-support \
           --feature-powerset \
-          --group-features starttls,ext_condstore_qresync,ext_login_referrals,ext_mailbox_referrals,ext_id,ext_sort_thread,ext_binary \
+          --group-features starttls,ext_condstore_qresync,ext_login_referrals,ext_mailbox_referrals,ext_id,ext_sort_thread,ext_binary,ext_metadata \
           --exclude-features ext,split
 
   test:
diff --git a/imap-codec/src/testing.rs b/imap-codec/src/testing.rs
index 1c9bf543..776a60cb 100644
--- a/imap-codec/src/testing.rs
+++ b/imap-codec/src/testing.rs
@@ -57,7 +57,9 @@ macro_rules! impl_kat_inverse {
             {
                 println!("# {no}");
 
-                let (got_remainder, got_object) = $decoder::default().decode(test_input).unwrap();
+                let (got_remainder, got_object) = $decoder::default()
+                    .decode(test_input)
+                    .expect("first parsing failed");
                 assert_eq!(*expected_object, got_object);
                 assert_eq!(*expected_remainder, got_remainder);
 
@@ -68,8 +70,9 @@ macro_rules! impl_kat_inverse {
 
                 // This second `decode` makes using generic bounds more complicated due to the
                 // different lifetime.
-                let (got_remainder, got_object_again) =
-                    $decoder::default().decode(&got_output).unwrap();
+                let (got_remainder, got_object_again) = $decoder::default()
+                    .decode(&got_output)
+                    .expect("second parsing failed");
                 assert_eq!(got_object, got_object_again);
                 assert!(got_remainder.is_empty());
             }