Repository: ruma/ruma
Pull request #114 (2020-07-07 12:54:45)

Redacted events: Use Any*Event enums for redacted events
Sorry if opening all these is annoying, implementing it even if it's a bit off is the best way for me to understand something and I'm hoping having something to see and talk about helps everyone.

commit 1: Add most of the logic to generate the correct AnyRedactedEvent enums.
commit 2: Add the needed event content types for the 5 state events that have a content field. Create full, stub, and stripped versions of state events and full and stub versions of message event structs. Uncomment and fix redacted event tests.

farodin91: You need to update cargo first of all to travis building this.
I'll look into this later today.
jimmycuadra: I managed to figure this out by just removing a bunch of generic code and using macros to reduce repetition in implementing conversions. I have one failing test which I still need to figure out. Once there is a nightly Rust without that Cargo TLS bug, I will push up my changes.
jimmycuadra: Merged in c8a6832.
jplatte: > implementing it even if it's a bit off is the best way for me to understand something and I'm hoping having something to see and talk about helps everyone

It definitely helps, no worries!
jplatte: I think I mentioned this before, but the (currently only) case I actually think we should have `custom_redacted` for (although it could be solved differently too) is `m.room.aliases`: its redact function actually needs a parameter for whether to redact the content or not. Thinking about this more, this also means the `has_field` method I thought of doesn't work! We actually need a method, taking `&self`, to determine whether to write the `content` field. That shouldn't make things any more complicated, though.
DevinR528: We also need `redact` methods on the `Any*Event` enums and something to deal with the fact that `redacted_because` is only a `RedactedEvent` so doesn't allow stub or stripped events. I tried out what we have so far in matrix-sdk and these were the only awkward bits.

> I actually think we should have custom_redacted for (although it could be solved differently too) is m.room.aliases

You have said that before but a good reminder, I was thinking that implementations could redact their own made-up events but this can't actually happen can it (a server has no way to know what a client is up to and vice-versa)? If the only reason we have custom redacted events is aliases should we maybe remove the custom and add a `RedactedV1AliasesEventContent` or something like that? Do we need to have a type for the old redacted aliases event, if we got sent the JSON for an old aliases event it would (de)serialize correctly? Having the catch all and semitry is nice.

I'll start working on the `RedactedEventContent` and see if that opens any posibillities that would make custom easier to implement.
jplatte: > We also need `redact` methods on the `Any*Event` enums

This PR is already quite big, let's do this separately.



> and something to deal with the fact that `redacted_because` is only a `RedactedEvent` so doesn't allow stub or stripped events

I don't understand what you mean, can you expand on that?

> I was thinking that implementations could redact their own made-up events but this can't actually happen can it (a server has no way to know what a client is up to and vice-versa)?

Again not sure what you mean, sorry 😅 

> If the only reason we have custom redacted events is aliases should we maybe remove the custom and add a `RedactedV1AliasesEventContent` or something like that? Do we need to have a type for the old redacted aliases event, if we got sent the JSON for an old aliases event it would (de)serialize correctly? Having the catch all and semitry is nice.

I don't fully understand what your idea is here, but if you want, try it out.
DevinR528: >and something to deal with the fact that redacted_because is only a RedactedEvent so doesn't allow stub or stripped events

According to spec the redaction event should be inserted into the `redacted_because` field of `unsigned`, what I was getting at is that we should have a way (an enum maybe see example below) that allows this to be a stub or full event (are there stripped redaction event?)

```rust
enum AnyRedactionEvent {
    Full(RedactionEvent),
    Stub(RedactionEventStub),
    // stripped if needed
}
// then
struct UnsignedData {
    redacted_because: Option<AnyRedactionEvent>,
}
```

>I was thinking that implementations could redact their own made-up events but this can't actually happen can it (a server has no way to know what a client is up to and vice-versa)?

I see now you weren't misunderstanding I read your comment completely wrong you were just talking about the attribute not custom events in general. So disregard that paragraph.
jplatte: > According to spec the redaction event should be inserted into the `redacted_because` field of `unsigned`, what I was getting at is that we should have a way (an enum maybe see example below) that allows this to be a stub or full event (are there stripped redaction event?)

I see! I guess the spec could be clearer about this. It definitely makes sense in my mind that `redacted_because` could itself contain a redacted (redaction) event. I'm not so sure about the others though. I will ask on #matrix-spec:matrix.org.
DevinR528: Ah sorry about that I squashed a commit the wrong way, I should have squashed the last one into the one I ended up squashing.
```rust
oldest commit
middle // I squashed this into the commit above it
new // I should have squashed this and rewrote the commit?
```

Patch:
diff --git a/ruma-events-macros/src/event.rs b/ruma-events-macros/src/event.rs
index 40bb3ae7..565ffb17 100644
--- a/ruma-events-macros/src/event.rs
+++ b/ruma-events-macros/src/event.rs
@@ -37,7 +37,13 @@ pub fn expand_event(input: DeriveInput) -> syn::Result<TokenStream> {
         .iter()
         .map(|field| {
             let name = field.ident.as_ref().unwrap();
-            if name == "prev_content" {
+            if name == "content" && ident.to_string().contains("Redacted") {
+                quote! {
+                    if ::ruma_events::RedactedEventContent::has_serialize_fields(&self.content) {
+                        state.serialize_field("content", &self.content)?;
+                    }
+                }
+            } else if name == "prev_content" {
                 quote! {
                     if let Some(content) = self.prev_content.as_ref() {
                         state.serialize_field("prev_content", content)?;
@@ -143,7 +149,16 @@ fn expand_deserialize_event(
     .map(|field| {
         let name = field.ident.as_ref().unwrap();
         if name == "content" {
-            if is_generic {
+            if is_generic && ident.to_string().contains("Redacted") {
+                quote! {
+                    let content = if !C::has_deserialize_fields() {
+                        C::empty(&event_type).map_err(A::Error::custom)?
+                    } else {
+                        let json = content.ok_or_else(|| ::serde::de::Error::missing_field("content"))?;
+                        C::from_parts(&event_type, json).map_err(A::Error::custom)?
+                    };
+                }
+            } else if is_generic {
                 quote! {
                     let json = content.ok_or_else(|| ::serde::de::Error::missing_field("content"))?;
                     let content = C::from_parts(&event_type, json).map_err(A::Error::custom)?;
diff --git a/ruma-events-macros/src/event_content.rs b/ruma-events-macros/src/event_content.rs
index 940c1364..ab01ddc3 100644
--- a/ruma-events-macros/src/event_content.rs
+++ b/ruma-events-macros/src/event_content.rs
@@ -4,70 +4,197 @@ use proc_macro2::{Span, TokenStream};
 use quote::quote;
 use syn::{
     parse::{Parse, ParseStream},
-    DeriveInput, LitStr, Token,
+    DeriveInput, Ident, LitStr, Token,
 };
 
+mod kw {
+    // This `content` field is kept when the event is redacted.
+    syn::custom_keyword!(skip_redaction);
+    // Do not emit any redacted event code.
+    syn::custom_keyword!(custom_redacted);
+}
+
 /// Parses attributes for `*EventContent` derives.
 ///
 /// `#[ruma_event(type = "m.room.alias")]`
+#[derive(Eq, PartialEq)]
 enum EventMeta {
     /// Variant holds the "m.whatever" event type.
     Type(LitStr),
+
+    /// Fields marked with `#[ruma_event(skip_redaction)]` are kept when the event is
+    /// redacted.
+    SkipRedacted,
+
+    /// This attribute signals that the events redacted form is manually implemented and should
+    /// not be generated.
+    CustomRedacted,
+}
+
+impl EventMeta {
+    fn get_event_type(&self) -> Option<&LitStr> {
+        if let Self::Type(lit) = self {
+            Some(lit)
+        } else {
+            None
+        }
+    }
 }
 
 impl Parse for EventMeta {
     fn parse(input: ParseStream) -> syn::Result<Self> {
-        input.parse::<Token![type]>()?;
-        input.parse::<Token![=]>()?;
-        Ok(EventMeta::Type(input.parse::<LitStr>()?))
+        if input.parse::<Token![type]>().is_ok() {
+            input.parse::<Token![=]>()?;
+            Ok(EventMeta::Type(input.parse::<LitStr>()?))
+        } else if input.parse::<kw::skip_redaction>().is_ok() {
+            Ok(EventMeta::SkipRedacted)
+        } else if input.parse::<kw::custom_redacted>().is_ok() {
+            Ok(EventMeta::CustomRedacted)
+        } else {
+            Err(syn::Error::new(input.span(), "not a recognized `ruma_event` attribute"))
+        }
     }
 }
 
 /// Create an `EventContent` implementation for a struct.
-pub fn expand_event_content(input: DeriveInput) -> syn::Result<TokenStream> {
+pub fn expand_event_content(input: &DeriveInput, emit_redacted: bool) -> syn::Result<TokenStream> {
     let ident = &input.ident;
 
-    let event_type_attr =
-        input.attrs.iter().find(|attr| attr.path.is_ident("ruma_event")).ok_or_else(|| {
-            let msg = "no event type attribute found, \
+    let content_attr = input
+        .attrs
+        .iter()
+        .filter(|attr| attr.path.is_ident("ruma_event"))
+        .map(|attr| attr.parse_args::<EventMeta>())
+        .collect::<syn::Result<Vec<_>>>()?;
+
+    let event_type = content_attr.iter().find_map(|a| a.get_event_type()).ok_or_else(|| {
+        let msg = "no event type attribute found, \
             add `#[ruma_event(type = \"any.room.event\")]` \
             below the event content derive";
 
-            syn::Error::new(Span::call_site(), msg)
-        })?;
+        syn::Error::new(Span::call_site(), msg)
+    })?;
 
-    let event_type = {
-        let event_meta = event_type_attr.parse_args::<EventMeta>()?;
-        let EventMeta::Type(lit) = event_meta;
-        lit
-    };
+    let redacted = if emit_redacted && needs_redacted(input) {
+        let doc = format!("The payload for a redacted `{}`", ident);
+        let redacted_ident = quote::format_ident!("Redacted{}", ident);
+        let kept_redacted_fields = if let syn::Data::Struct(syn::DataStruct {
+            fields: syn::Fields::Named(syn::FieldsNamed { named, .. }),
+            ..
+        }) = &input.data
+        {
+            // this is to validate the `#[ruma_event(skip_redaction)]` attribute
+            named
+                .iter()
+                .flat_map(|f| &f.attrs)
+                .filter(|a| a.path.is_ident("ruma_event"))
+                .find_map(|a| {
+                    if let Err(e) = a.parse_args::<EventMeta>() {
+                        Some(Err(e))
+                    } else {
+                        None
+                    }
+                })
+                .unwrap_or(Ok(()))?;
 
-    Ok(quote! {
-        impl ::ruma_events::EventContent for #ident {
-            fn event_type(&self) -> &str {
-                #event_type
+            let mut fields = named
+                .iter()
+                .filter(|f| {
+                    f.attrs.iter().find_map(|a| a.parse_args::<EventMeta>().ok())
+                        == Some(EventMeta::SkipRedacted)
+                })
+                .cloned()
+                .collect::<Vec<_>>();
+
+            // don't re-emit our `ruma_event` attributes
+            for f in &mut fields {
+                f.attrs.retain(|a| !a.path.is_ident("ruma_event"));
             }
+            fields
+        } else {
+            vec![]
+        };
+        let redaction_struct_fields = kept_redacted_fields.iter().flat_map(|f| &f.ident);
 
-            fn from_parts(
-                ev_type: &str,
-                content: Box<::serde_json::value::RawValue>
-            ) -> Result<Self, ::serde_json::Error> {
-                if ev_type != #event_type {
-                    return Err(::serde::de::Error::custom(
-                        format!("expected event type `{}`, found `{}`", #event_type, ev_type)
-                    ));
+        // redacted_fields allows one to declare an empty redacted event without braces,
+        // otherwise `RedactedWhateverEventContent {}` is needed.
+        // The redacted_return is used in `EventContent::redacted` which only returns
+        // zero sized types (unit structs).
+        let (redacted_fields, redacted_return) = if kept_redacted_fields.is_empty() {
+            (quote! { ; }, quote! { Ok(#redacted_ident {}) })
+        } else {
+            (
+                quote! {
+                    { #( #kept_redacted_fields, )* }
+                },
+                quote! {
+                    Err(::serde::de::Error::custom(
+                        format!("this redacted event has fields that cannot be constructed")
+                    ))
+                },
+            )
+        };
+
+        let has_fields = if kept_redacted_fields.is_empty() {
+            quote! { false }
+        } else {
+            quote! { true }
+        };
+
+        let redacted_event_content = generate_event_content_impl(&redacted_ident, event_type);
+
+        quote! {
+            // this is the non redacted event content's impl
+            impl #ident {
+                /// Transforms the full event content into a redacted content according to spec.
+                pub fn redact(self) -> #redacted_ident {
+                    #redacted_ident { #( #redaction_struct_fields: self.#redaction_struct_fields, )* }
+                }
+            }
+
+            #[doc = #doc]
+            #[derive(Clone, Debug, ::serde::Deserialize, ::serde::Serialize)]
+            pub struct #redacted_ident #redacted_fields
+
+            #redacted_event_content
+
+            impl ::ruma_events::RedactedEventContent for #redacted_ident {
+                fn empty(ev_type: &str) -> Result<Self, ::serde_json::Error> {
+                    if ev_type != #event_type {
+                        return Err(::serde::de::Error::custom(
+                            format!("expected event type `{}`, found `{}`", #event_type, ev_type)
+                        ));
+                    }
+
+                    #redacted_return
                 }
 
-                ::serde_json::from_str(content.get())
+                fn has_serialize_fields(&self) -> bool {
+                    #has_fields
+                }
+
+                fn has_deserialize_fields() -> bool {
+                    #has_fields
+                }
             }
         }
+    } else {
+        TokenStream::new()
+    };
+
+    let event_content = generate_event_content_impl(ident, event_type);
+
+    Ok(quote! {
+        #event_content
+
+        #redacted
     })
 }
 
 /// Create a `BasicEventContent` implementation for a struct
-pub fn expand_basic_event_content(input: DeriveInput) -> syn::Result<TokenStream> {
+pub fn expand_basic_event_content(input: &DeriveInput) -> syn::Result<TokenStream> {
     let ident = input.ident.clone();
-    let event_content_impl = expand_event_content(input)?;
+    let event_content_impl = expand_event_content(input, false)?;
 
     Ok(quote! {
         #event_content_impl
@@ -77,9 +204,9 @@ pub fn expand_basic_event_content(input: DeriveInput) -> syn::Result<TokenStream
 }
 
 /// Create a `EphemeralRoomEventContent` implementation for a struct
-pub fn expand_ephemeral_room_event_content(input: DeriveInput) -> syn::Result<TokenStream> {
+pub fn expand_ephemeral_room_event_content(input: &DeriveInput) -> syn::Result<TokenStream> {
     let ident = input.ident.clone();
-    let event_content_impl = expand_event_content(input)?;
+    let event_content_impl = expand_event_content(input, false)?;
 
     Ok(quote! {
         #event_content_impl
@@ -89,9 +216,9 @@ pub fn expand_ephemeral_room_event_content(input: DeriveInput) -> syn::Result<To
 }
 
 /// Create a `RoomEventContent` implementation for a struct.
-pub fn expand_room_event_content(input: DeriveInput) -> syn::Result<TokenStream> {
+pub fn expand_room_event_content(input: &DeriveInput) -> syn::Result<TokenStream> {
     let ident = input.ident.clone();
-    let event_content_impl = expand_event_content(input)?;
+    let event_content_impl = expand_event_content(input, true)?;
 
     Ok(quote! {
         #event_content_impl
@@ -101,25 +228,79 @@ pub fn expand_room_event_content(input: DeriveInput) -> syn::Result<TokenStream>
 }
 
 /// Create a `MessageEventContent` implementation for a struct
-pub fn expand_message_event_content(input: DeriveInput) -> syn::Result<TokenStream> {
+pub fn expand_message_event_content(input: &DeriveInput) -> syn::Result<TokenStream> {
     let ident = input.ident.clone();
     let room_ev_content = expand_room_event_content(input)?;
 
+    let redacted_marker_trait = if needs_redacted(input) {
+        let ident = quote::format_ident!("Redacted{}", &ident);
+        quote! {
+            impl ::ruma_events::RedactedMessageEventContent for #ident { }
+        }
+    } else {
+        TokenStream::new()
+    };
+
     Ok(quote! {
         #room_ev_content
 
         impl ::ruma_events::MessageEventContent for #ident { }
+
+        #redacted_marker_trait
     })
 }
 
 /// Create a `StateEventContent` implementation for a struct
-pub fn expand_state_event_content(input: DeriveInput) -> syn::Result<TokenStream> {
+pub fn expand_state_event_content(input: &DeriveInput) -> syn::Result<TokenStream> {
     let ident = input.ident.clone();
     let room_ev_content = expand_room_event_content(input)?;
 
+    let redacted_marker_trait = if needs_redacted(input) {
+        let ident = quote::format_ident!("Redacted{}", input.ident);
+        quote! {
+            impl ::ruma_events::RedactedStateEventContent for #ident { }
+        }
+    } else {
+        TokenStream::new()
+    };
+
     Ok(quote! {
         #room_ev_content
 
         impl ::ruma_events::StateEventContent for #ident { }
+
+        #redacted_marker_trait
     })
 }
+
+fn generate_event_content_impl(ident: &Ident, event_type: &LitStr) -> TokenStream {
+    quote! {
+        impl ::ruma_events::EventContent for #ident {
+            fn event_type(&self) -> &str {
+                #event_type
+            }
+
+            fn from_parts(
+                ev_type: &str,
+                content: Box<::serde_json::value::RawValue>
+            ) -> Result<Self, ::serde_json::Error> {
+                if ev_type != #event_type {
+                    return Err(::serde::de::Error::custom(
+                        format!("expected event type `{}`, found `{}`", #event_type, ev_type)
+                    ));
+                }
+
+                ::serde_json::from_str(content.get())
+            }
+        }
+    }
+}
+
+fn needs_redacted(input: &DeriveInput) -> bool {
+    input
+        .attrs
+        .iter()
+        .flat_map(|a| a.parse_args::<EventMeta>().ok())
+        .find(|a| a == &EventMeta::CustomRedacted)
+        .is_none()
+}
diff --git a/ruma-events-macros/src/event_enum.rs b/ruma-events-macros/src/event_enum.rs
index 953c7014..19b1dba1 100644
--- a/ruma-events-macros/src/event_enum.rs
+++ b/ruma-events-macros/src/event_enum.rs
@@ -10,16 +10,40 @@ use syn::{
 use crate::event_names::{
     ANY_BASIC_EVENT, ANY_EPHEMERAL_EVENT, ANY_MESSAGE_EVENT, ANY_STATE_EVENT,
     ANY_STRIPPED_STATE_EVENT, ANY_SYNC_MESSAGE_EVENT, ANY_SYNC_STATE_EVENT, ANY_TO_DEVICE_EVENT,
+    REDACTED_MESSAGE_EVENT, REDACTED_STATE_EVENT, REDACTED_STRIPPED_STATE_EVENT,
+    REDACTED_SYNC_MESSAGE_EVENT, REDACTED_SYNC_STATE_EVENT,
 };
 
 // Arrays of event enum names grouped by a field they share in common.
-const ROOM_EVENT_KIND: &[&str] =
-    &[ANY_MESSAGE_EVENT, ANY_SYNC_MESSAGE_EVENT, ANY_STATE_EVENT, ANY_SYNC_STATE_EVENT];
+const ROOM_EVENT_KIND: &[&str] = &[
+    ANY_MESSAGE_EVENT,
+    ANY_SYNC_MESSAGE_EVENT,
+    ANY_STATE_EVENT,
+    ANY_SYNC_STATE_EVENT,
+    REDACTED_MESSAGE_EVENT,
+    REDACTED_STATE_EVENT,
+    REDACTED_SYNC_MESSAGE_EVENT,
+    REDACTED_SYNC_STATE_EVENT,
+];
 
-const ROOM_ID_KIND: &[&str] = &[ANY_MESSAGE_EVENT, ANY_STATE_EVENT, ANY_EPHEMERAL_EVENT];
+const ROOM_ID_KIND: &[&str] = &[
+    ANY_MESSAGE_EVENT,
+    ANY_STATE_EVENT,
+    ANY_EPHEMERAL_EVENT,
+    REDACTED_STATE_EVENT,
+    REDACTED_MESSAGE_EVENT,
+];
 
-const EVENT_ID_KIND: &[&str] =
-    &[ANY_MESSAGE_EVENT, ANY_SYNC_MESSAGE_EVENT, ANY_STATE_EVENT, ANY_SYNC_STATE_EVENT];
+const EVENT_ID_KIND: &[&str] = &[
+    ANY_MESSAGE_EVENT,
+    ANY_SYNC_MESSAGE_EVENT,
+    ANY_STATE_EVENT,
+    ANY_SYNC_STATE_EVENT,
+    REDACTED_SYNC_STATE_EVENT,
+    REDACTED_SYNC_MESSAGE_EVENT,
+    REDACTED_STATE_EVENT,
+    REDACTED_MESSAGE_EVENT,
+];
 
 const SENDER_KIND: &[&str] = &[
     ANY_MESSAGE_EVENT,
@@ -28,11 +52,31 @@ const SENDER_KIND: &[&str] = &[
     ANY_TO_DEVICE_EVENT,
     ANY_SYNC_MESSAGE_EVENT,
     ANY_STRIPPED_STATE_EVENT,
+    REDACTED_MESSAGE_EVENT,
+    REDACTED_STATE_EVENT,
+    REDACTED_STRIPPED_STATE_EVENT,
+    REDACTED_SYNC_MESSAGE_EVENT,
+    REDACTED_SYNC_STATE_EVENT,
 ];
 
 const PREV_CONTENT_KIND: &[&str] = &[ANY_STATE_EVENT, ANY_SYNC_STATE_EVENT];
 
-const STATE_KEY_KIND: &[&str] = &[ANY_STATE_EVENT, ANY_SYNC_STATE_EVENT, ANY_STRIPPED_STATE_EVENT];
+const STATE_KEY_KIND: &[&str] = &[
+    ANY_STATE_EVENT,
+    ANY_SYNC_STATE_EVENT,
+    ANY_STRIPPED_STATE_EVENT,
+    REDACTED_SYNC_STATE_EVENT,
+    REDACTED_STRIPPED_STATE_EVENT,
+    REDACTED_STATE_EVENT,
+];
+
+const REDACTED_EVENT_KIND: &[&str] = &[
+    ANY_STATE_EVENT,
+    ANY_SYNC_STATE_EVENT,
+    ANY_STRIPPED_STATE_EVENT,
+    ANY_MESSAGE_EVENT,
+    ANY_SYNC_MESSAGE_EVENT,
+];
 
 /// This const is used to generate the accessor methods for the `Any*Event` enums.
 ///
@@ -69,8 +113,14 @@ pub fn expand_event_enum(input: EventEnumInput) -> syn::Result<TokenStream> {
     let event_stripped_enum =
         if needs_stripped_event { expand_stripped_enum(&input)? } else { TokenStream::new() };
 
+    let redacted_event_enums = if needs_redacted(ident) {
+        expand_any_redacted_enum_with_deserialize(&input, ident)?
+    } else {
+        TokenStream::new()
+    };
+
     let event_content_enum =
-        if needs_event_content { expand_content_enum(&input)? } else { TokenStream::new() };
+        if needs_event_content { expand_content_enum(&input, ident)? } else { TokenStream::new() };
 
     Ok(quote! {
         #event_enum
@@ -79,6 +129,8 @@ pub fn expand_event_enum(input: EventEnumInput) -> syn::Result<TokenStream> {
 
         #event_stripped_enum
 
+        #redacted_event_enums
+
         #event_content_enum
     })
 }
@@ -97,10 +149,59 @@ pub fn expand_stripped_enum(input: &EventEnumInput) -> syn::Result<TokenStream>
     expand_any_enum_with_deserialize(input, &ident)
 }
 
+/// Generates the 3 redacted state enums, 2 redacted message enums,
+/// and `Deserialize` implementations.
+///
+/// No content enums are generated since no part of the API deals with
+/// redacted event's content. There are only five state variants that contain content.
+fn expand_any_redacted_enum_with_deserialize(
+    input: &EventEnumInput,
+    ident: &Ident,
+) -> syn::Result<TokenStream> {
+    let name = ident.to_string().trim_start_matches("Any").to_string();
+
+    let redacted_enums_deserialize = if ident.to_string().contains("State") {
+        let ident = Ident::new(&format!("AnyRedacted{}", name), ident.span());
+
+        let full = expand_any_enum_with_deserialize(input, &ident)?;
+
+        let ident = Ident::new(&format!("AnyRedacted{}Stub", name), ident.span());
+        let stub = expand_any_enum_with_deserialize(input, &ident)?;
+
+        let ident = Ident::new(&format!("AnyRedactedStripped{}Stub", name), ident.span());
+        let stripped = expand_any_enum_with_deserialize(input, &ident)?;
+
+        quote! {
+            #full
+
+            #stub
+
+            #stripped
+        }
+    } else {
+        let ident = Ident::new(&format!("AnyRedacted{}", name), ident.span());
+
+        let full = expand_any_enum_with_deserialize(input, &ident)?;
+
+        let ident = Ident::new(&format!("AnyRedacted{}Stub", name), ident.span());
+        let stub = expand_any_enum_with_deserialize(input, &ident)?;
+
+        quote! {
+            #full
+
+            #stub
+        }
+    };
+
+    Ok(quote! {
+        #redacted_enums_deserialize
+    })
+}
+
 /// Create a content enum from `EventEnumInput`.
-pub fn expand_content_enum(input: &EventEnumInput) -> syn::Result<TokenStream> {
+pub fn expand_content_enum(input: &EventEnumInput, ident: &Ident) -> syn::Result<TokenStream> {
     let attrs = &input.attrs;
-    let ident = Ident::new(&format!("{}Content", input.name.to_string()), input.name.span());
+    let ident = Ident::new(&format!("{}Content", ident), ident.span());
     let event_type_str = &input.events;
 
     let variants = input.events.iter().map(to_camel_case).collect::<syn::Result<Vec<_>>>()?;
@@ -135,12 +236,12 @@ pub fn expand_content_enum(input: &EventEnumInput) -> syn::Result<TokenStream> {
                     #(
                         #event_type_str => {
                             let content = #content::from_parts(event_type, input)?;
-                            Ok(#ident::#variants(content))
+                            Ok(Self::#variants(content))
                         },
                     )*
                     ev_type => {
                         let content = ::ruma_events::custom::CustomEventContent::from_parts(ev_type, input)?;
-                        Ok(#ident::Custom(content))
+                        Ok(Self::Custom(content))
                     },
                 }
             }
@@ -164,12 +265,14 @@ fn expand_any_enum_with_deserialize(
 ) -> syn::Result<TokenStream> {
     let attrs = &input.attrs;
     let event_type_str = &input.events;
-    let event_struct = Ident::new(&ident.to_string().trim_start_matches("Any"), ident.span());
+    let event_struct = Ident::new(&ident.to_string().replace("Any", ""), ident.span());
 
     let variants = input.events.iter().map(to_camel_case).collect::<syn::Result<Vec<_>>>()?;
     let content =
         input.events.iter().map(|event| to_event_path(event, &event_struct)).collect::<Vec<_>>();
 
+    let (custom_variant, custom_deserialize) = expand_custom_variant(ident, &event_struct);
+
     let any_enum = quote! {
         #( #attrs )*
         #[derive(Clone, Debug, ::serde::Serialize)]
@@ -180,8 +283,7 @@ fn expand_any_enum_with_deserialize(
                 #[doc = #event_type_str]
                 #variants(#content),
             )*
-            /// An event not defined by the Matrix specification
-            Custom(::ruma_events::#event_struct<::ruma_events::custom::CustomEventContent>),
+            #custom_variant
         }
     };
 
@@ -199,16 +301,10 @@ fn expand_any_enum_with_deserialize(
                     #(
                         #event_type_str => {
                             let event = ::serde_json::from_str::<#content>(json.get()).map_err(D::Error::custom)?;
-                            Ok(#ident::#variants(event))
+                            Ok(Self::#variants(event))
                         },
                     )*
-                    event => {
-                        let event =
-                            ::serde_json::from_str::<::ruma_events::#event_struct<::ruma_events::custom::CustomEventContent>>(json.get())
-                                .map_err(D::Error::custom)?;
-
-                        Ok(Self::Custom(event))
-                    },
+                    #custom_deserialize
                 }
             }
         }
@@ -225,6 +321,43 @@ fn expand_any_enum_with_deserialize(
     })
 }
 
+fn expand_custom_variant(ident: &Ident, event_struct: &Ident) -> (TokenStream, TokenStream) {
+    if ident.to_string().contains("Redacted") {
+        (
+            quote! {
+                /// A redacted event not defined by the Matrix specification
+                Custom(::ruma_events::#event_struct<::ruma_events::custom::RedactedCustomEventContent>),
+            },
+            quote! {
+                event => {
+                    let event = ::serde_json::from_str::<
+                        ::ruma_events::#event_struct<::ruma_events::custom::RedactedCustomEventContent>,
+                    >(json.get())
+                    .map_err(D::Error::custom)?;
+
+                    Ok(Self::Custom(event))
+                },
+            },
+        )
+    } else {
+        (
+            quote! {
+                /// An event not defined by the Matrix specification
+                Custom(::ruma_events::#event_struct<::ruma_events::custom::CustomEventContent>),
+            },
+            quote! {
+                event => {
+                    let event =
+                        ::serde_json::from_str::<::ruma_events::#event_struct<::ruma_events::custom::CustomEventContent>>(json.get())
+                            .map_err(D::Error::custom)?;
+
+                    Ok(Self::Custom(event))
+                },
+            },
+        )
+    }
+}
+
 fn marker_traits(ident: &Ident) -> TokenStream {
     match ident.to_string().as_str() {
         "AnyStateEventContent" => quote! {
@@ -246,6 +379,10 @@ fn marker_traits(ident: &Ident) -> TokenStream {
 }
 
 fn accessor_methods(ident: &Ident, variants: &[Ident]) -> TokenStream {
+    if ident.to_string().contains("Redacted") {
+        return redacted_accessor_methods(ident, variants);
+    }
+
     let fields = EVENT_FIELDS
         .iter()
         .map(|(name, has_field)| generate_accessor(name, ident, *has_field, variants));
@@ -296,6 +433,20 @@ fn accessor_methods(ident: &Ident, variants: &[Ident]) -> TokenStream {
     }
 }
 
+/// Redacted events do NOT generate `content` or `prev_content` methods like
+/// un-redacted events; otherwise, they are the same.
+fn redacted_accessor_methods(ident: &Ident, variants: &[Ident]) -> TokenStream {
+    let fields = EVENT_FIELDS
+        .iter()
+        .map(|(name, has_field)| generate_accessor(name, ident, *has_field, variants));
+
+    quote! {
+        impl #ident {
+            #( #fields )*
+        }
+    }
+}
+
 fn to_event_path(name: &LitStr, struct_name: &Ident) -> TokenStream {
     let span = name.span();
     let name = name.value();
@@ -325,6 +476,10 @@ fn to_event_path(name: &LitStr, struct_name: &Ident) -> TokenStream {
             let content = Ident::new(&format!("{}EventContent", event), span);
             quote! { ::ruma_events::#struct_name<::ruma_events::#( #path )::*::#content> }
         }
+        struct_str if struct_str.contains("Redacted") => {
+            let content = Ident::new(&format!("Redacted{}EventContent", event), span);
+            quote! { ::ruma_events::#struct_name<::ruma_events::#( #path )::*::#content> }
+        }
         _ => {
             let event_name = Ident::new(&format!("{}Event", event), span);
             quote! { ::ruma_events::#( #path )::*::#event_name }
@@ -401,6 +556,11 @@ fn generate_accessor(
     }
 }
 
+/// Returns true if the `ident` is a state or message event.
+fn needs_redacted(ident: &Ident) -> bool {
+    REDACTED_EVENT_KIND.contains(&ident.to_string().as_str())
+}
+
 fn field_return_type(name: &str) -> TokenStream {
     match name {
         "origin_server_ts" => quote! { ::std::time::SystemTime },
diff --git a/ruma-events-macros/src/event_names.rs b/ruma-events-macros/src/event_names.rs
index 9148b5c6..c8f1341a 100644
--- a/ruma-events-macros/src/event_names.rs
+++ b/ruma-events-macros/src/event_names.rs
@@ -2,6 +2,11 @@
 //! certain code for certain enums. If the names change this is the one source of truth,
 //! most comparisons and branching uses these constants.
 
+#![allow(dead_code)]
+
+// Those marked with (UNUSED) are not used but, left for completeness sake.
+// If you change this please remove the (UNUSED) comment.
+
 // State events
 pub const ANY_STATE_EVENT: &str = "AnyStateEvent";
 
@@ -9,17 +14,27 @@ pub const ANY_SYNC_STATE_EVENT: &str = "AnyStateEventStub";
 
 pub const ANY_STRIPPED_STATE_EVENT: &str = "AnyStrippedStateEventStub";
 
+// Redacted state events
+pub const REDACTED_STATE_EVENT: &str = "AnyRedactedStateEvent"; // (UNUSED)
+
+pub const REDACTED_SYNC_STATE_EVENT: &str = "AnyRedactedStateEventStub"; // (UNUSED)
+
+pub const REDACTED_STRIPPED_STATE_EVENT: &str = "AnyRedactedStrippedStateEventStub"; // (UNUSED)
+
 // Message events
 pub const ANY_MESSAGE_EVENT: &str = "AnyMessageEvent";
 
 pub const ANY_SYNC_MESSAGE_EVENT: &str = "AnyMessageEventStub";
 
+// Redacted message events
+pub const REDACTED_MESSAGE_EVENT: &str = "AnyRedactedMessageEvent"; // (UNUSED)
+
+pub const REDACTED_SYNC_MESSAGE_EVENT: &str = "AnyRedactedMessageEventStub"; // (UNUSED)
+
 // Ephemeral events
 pub const ANY_EPHEMERAL_EVENT: &str = "AnyEphemeralRoomEvent";
 
-#[allow(dead_code)]
-// This is currently not used but, left for completeness sake.
-pub const ANY_SYNC_EPHEMERAL_EVENT: &str = "AnyEphemeralRoomEventStub";
+pub const ANY_SYNC_EPHEMERAL_EVENT: &str = "AnyEphemeralRoomEventStub"; // (UNUSED)
 
 // Basic event
 pub const ANY_BASIC_EVENT: &str = "AnyBasicEvent";
diff --git a/ruma-events-macros/src/lib.rs b/ruma-events-macros/src/lib.rs
index 657897ab..7893903b 100644
--- a/ruma-events-macros/src/lib.rs
+++ b/ruma-events-macros/src/lib.rs
@@ -52,42 +52,42 @@ pub fn event_enum(input: TokenStream) -> TokenStream {
 #[proc_macro_derive(EventContent, attributes(ruma_event))]
 pub fn derive_event_content(input: TokenStream) -> TokenStream {
     let input = parse_macro_input!(input as DeriveInput);
-    expand_event_content(input).unwrap_or_else(|err| err.to_compile_error()).into()
+    expand_event_content(&input, true).unwrap_or_else(|err| err.to_compile_error()).into()
 }
 
 /// Generates an implementation of `ruma_events::BasicEventContent` and it's super traits.
 #[proc_macro_derive(BasicEventContent, attributes(ruma_event))]
 pub fn derive_basic_event_content(input: TokenStream) -> TokenStream {
     let input = parse_macro_input!(input as DeriveInput);
-    expand_basic_event_content(input).unwrap_or_else(|err| err.to_compile_error()).into()
+    expand_basic_event_content(&input).unwrap_or_else(|err| err.to_compile_error()).into()
 }
 
 /// Generates an implementation of `ruma_events::RoomEventContent` and it's super traits.
 #[proc_macro_derive(RoomEventContent, attributes(ruma_event))]
 pub fn derive_room_event_content(input: TokenStream) -> TokenStream {
     let input = parse_macro_input!(input as DeriveInput);
-    expand_room_event_content(input).unwrap_or_else(|err| err.to_compile_error()).into()
+    expand_room_event_content(&input).unwrap_or_else(|err| err.to_compile_error()).into()
 }
 
 /// Generates an implementation of `ruma_events::MessageEventContent` and it's super traits.
 #[proc_macro_derive(MessageEventContent, attributes(ruma_event))]
 pub fn derive_message_event_content(input: TokenStream) -> TokenStream {
     let input = parse_macro_input!(input as DeriveInput);
-    expand_message_event_content(input).unwrap_or_else(|err| err.to_compile_error()).into()
+    expand_message_event_content(&input).unwrap_or_else(|err| err.to_compile_error()).into()
 }
 
 /// Generates an implementation of `ruma_events::StateEventContent` and it's super traits.
 #[proc_macro_derive(StateEventContent, attributes(ruma_event))]
 pub fn derive_state_event_content(input: TokenStream) -> TokenStream {
     let input = parse_macro_input!(input as DeriveInput);
-    expand_state_event_content(input).unwrap_or_else(|err| err.to_compile_error()).into()
+    expand_state_event_content(&input).unwrap_or_else(|err| err.to_compile_error()).into()
 }
 
 /// Generates an implementation of `ruma_events::EphemeralRoomEventContent` and it's super traits.
 #[proc_macro_derive(EphemeralRoomEventContent, attributes(ruma_event))]
 pub fn derive_ephemeral_room_event_content(input: TokenStream) -> TokenStream {
     let input = parse_macro_input!(input as DeriveInput);
-    expand_ephemeral_room_event_content(input).unwrap_or_else(|err| err.to_compile_error()).into()
+    expand_ephemeral_room_event_content(&input).unwrap_or_else(|err| err.to_compile_error()).into()
 }
 
 /// Generates implementations needed to serialize and deserialize Matrix events.
diff --git a/ruma-events/src/custom.rs b/ruma-events/src/custom.rs
index 1cc717fd..ac7d7e7e 100644
--- a/ruma-events/src/custom.rs
+++ b/ruma-events/src/custom.rs
@@ -5,7 +5,8 @@ use serde_json::{value::RawValue as RawJsonValue, Value as JsonValue};
 
 use crate::{
     BasicEventContent, EphemeralRoomEventContent, EventContent, MessageEventContent,
-    RoomEventContent, StateEventContent,
+    RedactedEventContent, RedactedMessageEventContent, RedactedStateEventContent, RoomEventContent,
+    StateEventContent,
 };
 
 /// A custom event's type and `content` JSON object.
@@ -20,6 +21,13 @@ pub struct CustomEventContent {
     pub json: JsonValue,
 }
 
+impl CustomEventContent {
+    /// Transforms the full event content into a redacted content according to spec.
+    pub fn redact(self) -> RedactedCustomEventContent {
+        RedactedCustomEventContent { event_type: self.event_type }
+    }
+}
+
 impl EventContent for CustomEventContent {
     fn event_type(&self) -> &str {
         &self.event_type
@@ -42,3 +50,44 @@ impl EphemeralRoomEventContent for CustomEventContent {}
 impl MessageEventContent for CustomEventContent {}
 
 impl StateEventContent for CustomEventContent {}
+
+/// A custom event that has been redacted.
+#[derive(Clone, Debug, Serialize)]
+pub struct RedactedCustomEventContent {
+    // This field is marked skipped but will be present because deserialization
+    // passes the `type` field of the JSON event to the events `EventContent::from_parts` method.
+    /// The event type string for this custom event "m.whatever".
+    #[serde(skip)]
+    pub event_type: String,
+}
+
+impl EventContent for RedactedCustomEventContent {
+    fn event_type(&self) -> &str {
+        &self.event_type
+    }
+
+    fn from_parts(
+        event_type: &str,
+        _content: Box<RawJsonValue>,
+    ) -> Result<Self, serde_json::Error> {
+        Ok(Self { event_type: event_type.to_string() })
+    }
+}
+
+impl RedactedEventContent for RedactedCustomEventContent {
+    fn empty(event_type: &str) -> Result<Self, serde_json::Error> {
+        Ok(Self { event_type: event_type.to_string() })
+    }
+
+    fn has_serialize_fields(&self) -> bool {
+        false
+    }
+
+    fn has_deserialize_fields() -> bool {
+        false
+    }
+}
+
+impl RedactedMessageEventContent for RedactedCustomEventContent {}
+
+impl RedactedStateEventContent for RedactedCustomEventContent {}
diff --git a/ruma-events/src/dummy.rs b/ruma-events/src/dummy.rs
index 7fd2e5d2..b7862db8 100644
--- a/ruma-events/src/dummy.rs
+++ b/ruma-events/src/dummy.rs
@@ -19,9 +19,9 @@ use crate::BasicEvent;
 /// sending client receiving keys over the newly established session.
 pub type DummyEvent = BasicEvent<DummyEventContent>;
 
+/// The payload for `DummyEvent`.
 #[derive(Clone, Debug, Deserialize, Serialize, BasicEventContent)]
 #[ruma_event(type = "m.dummy")]
-/// The payload for `DummyEvent`.
 pub struct DummyEventContent(pub Empty);
 
 impl Deref for DummyEventContent {
diff --git a/ruma-events/src/enums.rs b/ruma-events/src/enums.rs
index 25c52b01..36daad56 100644
--- a/ruma-events/src/enums.rs
+++ b/ruma-events/src/enums.rs
@@ -97,6 +97,10 @@ pub enum AnyEvent {
     Message(AnyMessageEvent),
     /// Any state event.
     State(AnyStateEvent),
+    /// Any message event that has been redacted.
+    RedactedMessage(AnyRedactedMessageEvent),
+    /// Any state event that has been redacted.
+    RedactedState(AnyRedactedStateEvent),
 }
 
 /// Any room event.
@@ -107,6 +111,10 @@ pub enum AnyRoomEvent {
     Message(AnyMessageEvent),
     /// Any state event.
     State(AnyStateEvent),
+    /// Any message event that has been redacted.
+    RedactedMessage(AnyRedactedMessageEvent),
+    /// Any state event that has been redacted.
+    RedactedState(AnyRedactedStateEvent),
 }
 
 /// Any room event stub (room event without a `room_id`, as returned in `/sync` responses)
@@ -117,6 +125,10 @@ pub enum AnyRoomEventStub {
     Message(AnyMessageEventStub),
     /// Any state event stub
     State(AnyStateEventStub),
+    /// Any message event stub that has been redacted.
+    RedactedMessage(AnyRedactedMessageEventStub),
+    /// Any state event stub that has been redacted.
+    RedactedState(AnyRedactedStateEventStub),
 }
 
 // FIXME `#[serde(untagged)]` deserialization fails for these enums which
@@ -127,14 +139,25 @@ impl<'de> de::Deserialize<'de> for AnyEvent {
         D: de::Deserializer<'de>,
     {
         let json = Box::<RawJsonValue>::deserialize(deserializer)?;
-        let EventDeHelper { state_key, event_id, room_id, .. } = from_raw_json_value(&json)?;
+        let EventDeHelper { state_key, event_id, room_id, unsigned, .. } =
+            from_raw_json_value(&json)?;
 
         // Determine whether the event is a state, message, ephemeral, or basic event
         // based on the fields present.
         if state_key.is_some() {
-            Ok(AnyEvent::State(from_raw_json_value(&json)?))
+            Ok(match unsigned {
+                Some(unsigned) if unsigned.redacted_because.is_some() => {
+                    AnyEvent::RedactedState(from_raw_json_value(&json)?)
+                }
+                _ => AnyEvent::State(from_raw_json_value(&json)?),
+            })
         } else if event_id.is_some() {
-            Ok(AnyEvent::Message(from_raw_json_value(&json)?))
+            Ok(match unsigned {
+                Some(unsigned) if unsigned.redacted_because.is_some() => {
+                    AnyEvent::RedactedMessage(from_raw_json_value(&json)?)
+                }
+                _ => AnyEvent::Message(from_raw_json_value(&json)?),
+            })
         } else if room_id.is_some() {
             Ok(AnyEvent::Ephemeral(from_raw_json_value(&json)?))
         } else {
@@ -149,12 +172,22 @@ impl<'de> de::Deserialize<'de> for AnyRoomEvent {
         D: de::Deserializer<'de>,
     {
         let json = Box::<RawJsonValue>::deserialize(deserializer)?;
-        let EventDeHelper { state_key, .. } = from_raw_json_value(&json)?;
+        let EventDeHelper { state_key, unsigned, .. } = from_raw_json_value(&json)?;
 
         if state_key.is_some() {
-            Ok(AnyRoomEvent::State(from_raw_json_value(&json)?))
+            Ok(match unsigned {
+                Some(unsigned) if unsigned.redacted_because.is_some() => {
+                    AnyRoomEvent::RedactedState(from_raw_json_value(&json)?)
+                }
+                _ => AnyRoomEvent::State(from_raw_json_value(&json)?),
+            })
         } else {
-            Ok(AnyRoomEvent::Message(from_raw_json_value(&json)?))
+            Ok(match unsigned {
+                Some(unsigned) if unsigned.redacted_because.is_some() => {
+                    AnyRoomEvent::RedactedMessage(from_raw_json_value(&json)?)
+                }
+                _ => AnyRoomEvent::Message(from_raw_json_value(&json)?),
+            })
         }
     }
 }
@@ -165,12 +198,22 @@ impl<'de> de::Deserialize<'de> for AnyRoomEventStub {
         D: de::Deserializer<'de>,
     {
         let json = Box::<RawJsonValue>::deserialize(deserializer)?;
-        let EventDeHelper { state_key, .. } = from_raw_json_value(&json)?;
+        let EventDeHelper { state_key, unsigned, .. } = from_raw_json_value(&json)?;
 
         if state_key.is_some() {
-            Ok(AnyRoomEventStub::State(from_raw_json_value(&json)?))
+            Ok(match unsigned {
+                Some(unsigned) if unsigned.redacted_because.is_some() => {
+                    AnyRoomEventStub::RedactedState(from_raw_json_value(&json)?)
+                }
+                _ => AnyRoomEventStub::State(from_raw_json_value(&json)?),
+            })
         } else {
-            Ok(AnyRoomEventStub::Message(from_raw_json_value(&json)?))
+            Ok(match unsigned {
+                Some(unsigned) if unsigned.redacted_because.is_some() => {
+                    AnyRoomEventStub::RedactedMessage(from_raw_json_value(&json)?)
+                }
+                _ => AnyRoomEventStub::Message(from_raw_json_value(&json)?),
+            })
         }
     }
 }
diff --git a/ruma-events/src/event_kinds.rs b/ruma-events/src/event_kinds.rs
index 347f6ff8..13c6ae80 100644
--- a/ruma-events/src/event_kinds.rs
+++ b/ruma-events/src/event_kinds.rs
@@ -5,7 +5,7 @@ use ruma_identifiers::{EventId, RoomId, UserId};
 
 use crate::{
     BasicEventContent, EphemeralRoomEventContent, EventContent, MessageEventContent,
-    StateEventContent, UnsignedData,
+    RedactedMessageEventContent, RedactedStateEventContent, StateEventContent, UnsignedData,
 };
 
 /// A basic event – one that consists only of it's type and the `content` object.
@@ -73,6 +73,48 @@ pub struct MessageEventStub<C: MessageEventContent> {
     pub unsigned: UnsignedData,
 }
 
+/// A redacted message event.
+#[derive(Clone, Debug, Event)]
+pub struct RedactedMessageEvent<C: RedactedMessageEventContent> {
+    /// Data specific to the event type.
+    pub content: C,
+
+    /// The globally unique event identifier for the user who sent the event.
+    pub event_id: EventId,
+
+    /// The fully-qualified ID of the user who sent this event.
+    pub sender: UserId,
+
+    /// Timestamp in milliseconds on originating homeserver when this event was sent.
+    pub origin_server_ts: SystemTime,
+
+    /// The ID of the room associated with this event.
+    pub room_id: RoomId,
+
+    /// Additional key-value pairs not signed by the homeserver.
+    pub unsigned: UnsignedData,
+}
+
+/// A redacted message event without a `room_id`.
+#[derive(Clone, Debug, Event)]
+pub struct RedactedMessageEventStub<C: RedactedMessageEventContent> {
+    /// Data specific to the event type.
+    // #[serde(default, skip_serializing_if = "is_zst")]
+    pub content: C,
+
+    /// The globally unique event identifier for the user who sent the event.
+    pub event_id: EventId,
+
+    /// The fully-qualified ID of the user who sent this event.
+    pub sender: UserId,
+
+    /// Timestamp in milliseconds on originating homeserver when this event was sent.
+    pub origin_server_ts: SystemTime,
+
+    /// Additional key-value pairs not signed by the homeserver.
+    pub unsigned: UnsignedData,
+}
+
 /// State event.
 #[derive(Clone, Debug, Event)]
 pub struct StateEvent<C: StateEventContent> {
@@ -149,6 +191,76 @@ pub struct StrippedStateEventStub<C: StateEventContent> {
     pub state_key: String,
 }
 
+/// A redacted state event.
+#[derive(Clone, Debug, Event)]
+pub struct RedactedStateEvent<C: RedactedStateEventContent> {
+    /// Data specific to the event type.
+    pub content: C,
+
+    /// The globally unique event identifier for the user who sent the event.
+    pub event_id: EventId,
+
+    /// The fully-qualified ID of the user who sent this event.
+    pub sender: UserId,
+
+    /// Timestamp in milliseconds on originating homeserver when this event was sent.
+    pub origin_server_ts: SystemTime,
+
+    /// The ID of the room associated with this event.
+    pub room_id: RoomId,
+
+    /// A unique key which defines the overwriting semantics for this piece of room state.
+    ///
+    /// This is often an empty string, but some events send a `UserId` to show
+    /// which user the event affects.
+    pub state_key: String,
+
+    /// Additional key-value pairs not signed by the homeserver.
+    pub unsigned: UnsignedData,
+}
+
+/// A redacted state event without a `room_id`.
+#[derive(Clone, Debug, Event)]
+pub struct RedactedStateEventStub<C: RedactedStateEventContent> {
+    /// Data specific to the event type.
+    // #[serde(default, skip_serializing_if = "is_zst")]
+    pub content: C,
+
+    /// The globally unique event identifier for the user who sent the event.
+    pub event_id: EventId,
+
+    /// The fully-qualified ID of the user who sent this event.
+    pub sender: UserId,
+
+    /// Timestamp in milliseconds on originating homeserver when this event was sent.
+    pub origin_server_ts: SystemTime,
+
+    /// A unique key which defines the overwriting semantics for this piece of room state.
+    ///
+    /// This is often an empty string, but some events send a `UserId` to show
+    /// which user the event affects.
+    pub state_key: String,
+
+    /// Additional key-value pairs not signed by the homeserver.
+    pub unsigned: UnsignedData,
+}
+
+/// A stripped-down redacted state event.
+#[derive(Clone, Debug, Event)]
+pub struct RedactedStrippedStateEventStub<C: RedactedStateEventContent> {
+    /// Data specific to the event type.
+    pub content: C,
+
+    /// The fully-qualified ID of the user who sent this event.
+    pub sender: UserId,
+
+    /// A unique key which defines the overwriting semantics for this piece of room state.
+    ///
+    /// This is often an empty string, but some events send a `UserId` to show
+    /// which user the event affects.
+    pub state_key: String,
+}
+
 /// An event sent using send-to-device messaging.
 #[derive(Clone, Debug, Event)]
 pub struct ToDeviceEvent<C: EventContent> {
diff --git a/ruma-events/src/lib.rs b/ruma-events/src/lib.rs
index 25cebfc1..b38c122e 100644
--- a/ruma-events/src/lib.rs
+++ b/ruma-events/src/lib.rs
@@ -166,13 +166,17 @@ pub use self::{
     enums::{
         AnyBasicEvent, AnyBasicEventContent, AnyEphemeralRoomEvent, AnyEphemeralRoomEventContent,
         AnyEphemeralRoomEventStub, AnyEvent, AnyMessageEvent, AnyMessageEventContent,
-        AnyMessageEventStub, AnyRoomEvent, AnyRoomEventStub, AnyStateEvent, AnyStateEventContent,
-        AnyStateEventStub, AnyStrippedStateEventStub, AnyToDeviceEvent, AnyToDeviceEventContent,
+        AnyMessageEventStub, AnyRedactedMessageEvent, AnyRedactedMessageEventStub,
+        AnyRedactedStateEvent, AnyRedactedStateEventStub, AnyRedactedStrippedStateEventStub,
+        AnyRoomEvent, AnyRoomEventStub, AnyStateEvent, AnyStateEventContent, AnyStateEventStub,
+        AnyStrippedStateEventStub, AnyToDeviceEvent, AnyToDeviceEventContent,
     },
     error::{FromStrError, InvalidInput},
     event_kinds::{
         BasicEvent, EphemeralRoomEvent, EphemeralRoomEventStub, MessageEvent, MessageEventStub,
-        StateEvent, StateEventStub, StrippedStateEventStub, ToDeviceEvent,
+        RedactedMessageEvent, RedactedMessageEventStub, RedactedStateEvent, RedactedStateEventStub,
+        RedactedStrippedStateEventStub, StateEvent, StateEventStub, StrippedStateEventStub,
+        ToDeviceEvent,
     },
     event_type::EventType,
     json::EventJson,
@@ -237,6 +241,38 @@ pub trait MessageEventContent: RoomEventContent {}
 /// Marker trait for the content of a state event.
 pub trait StateEventContent: RoomEventContent {}
 
+/// The base trait that all redacted event content types implement.
+///
+/// Implementing this trait allows content types to be serialized as well as deserialized.
+pub trait RedactedEventContent: EventContent {
+    /// Constructs the redacted event content.
+    ///
+    /// If called for anything but "empty" redacted content this will error.
+    fn empty(_event_type: &str) -> Result<Self, serde_json::Error> {
+        Err(serde::de::Error::custom("this event is not redacted"))
+    }
+
+    /// Determines if the redacted event content needs to serialize fields.
+    fn has_serialize_fields(&self) -> bool;
+
+    /// Determines if the redacted event content needs to deserialize fields.
+    fn has_deserialize_fields() -> bool;
+}
+
+/// Marker trait for the content of a redacted message event.
+pub trait RedactedMessageEventContent: RedactedEventContent {}
+
+/// Marker trait for the content of a redacted state event.
+pub trait RedactedStateEventContent: RedactedEventContent {}
+
+/// Helper struct to determine if the event has been redacted.
+#[doc(hidden)]
+#[derive(Debug, Deserialize)]
+pub struct UnsignedDeHelper {
+    /// This is the field that signals an event has been redacted.
+    pub redacted_because: Option<IgnoredAny>,
+}
+
 /// Helper struct to determine the event kind from a serde_json::value::RawValue.
 #[doc(hidden)]
 #[derive(Debug, Deserialize)]
@@ -255,6 +291,10 @@ pub struct EventDeHelper {
     /// If no `event_id` or `state_key` are found but a `room_id` is present
     /// the event will be deserialized as a ephemeral event.
     pub room_id: Option<IgnoredAny>,
+
+    /// If this `UnsignedData` contains a redacted_because key the event is
+    /// immediately deserialized as a redacted event.
+    pub unsigned: Option<UnsignedDeHelper>,
 }
 
 /// Helper function for serde_json::value::RawValue deserialization.
diff --git a/ruma-events/src/room/aliases.rs b/ruma-events/src/room/aliases.rs
index 5dbe5673..cd1508aa 100644
--- a/ruma-events/src/room/aliases.rs
+++ b/ruma-events/src/room/aliases.rs
@@ -3,8 +3,9 @@
 use ruma_events_macros::StateEventContent;
 use ruma_identifiers::RoomAliasId;
 use serde::{Deserialize, Serialize};
+use serde_json::value::RawValue as RawJsonValue;
 
-use crate::StateEvent;
+use crate::{EventContent, RedactedEventContent, RedactedStateEventContent, StateEvent};
 
 /// Informs the room about what room aliases it has been given.
 pub type AliasesEvent = StateEvent<AliasesEventContent>;
@@ -12,7 +13,49 @@ pub type AliasesEvent = StateEvent<AliasesEventContent>;
 /// The payload for `AliasesEvent`.
 #[derive(Clone, Debug, Deserialize, Serialize, StateEventContent)]
 #[ruma_event(type = "m.room.aliases")]
+#[ruma_event(custom_redacted)]
 pub struct AliasesEventContent {
     /// A list of room aliases.
     pub aliases: Vec<RoomAliasId>,
 }
+
+/// An aliases event that has been redacted.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct RedactedAliasesEventContent {
+    /// A list of room aliases.
+    ///
+    /// According to the Matrix spec version 1 redaction rules allowed this field to be
+    /// kept after redaction, this was changed in version 6.
+    pub aliases: Option<Vec<RoomAliasId>>,
+}
+
+impl EventContent for RedactedAliasesEventContent {
+    fn event_type(&self) -> &str {
+        "m.room.aliases"
+    }
+
+    fn from_parts(event_type: &str, content: Box<RawJsonValue>) -> Result<Self, serde_json::Error> {
+        if event_type != "m.room.aliases" {
+            return Err(::serde::de::Error::custom(format!(
+                "expected event type `m.room.aliases`, found `{}`",
+                event_type
+            )));
+        }
+
+        serde_json::from_str(content.get())
+    }
+}
+
+// Since this redacted event has fields we leave the default `empty` method
+// that will error if called.
+impl RedactedEventContent for RedactedAliasesEventContent {
+    fn has_serialize_fields(&self) -> bool {
+        self.aliases.is_some()
+    }
+
+    fn has_deserialize_fields() -> bool {
+        true
+    }
+}
+
+impl RedactedStateEventContent for RedactedAliasesEventContent {}
diff --git a/ruma-events/src/room/create.rs b/ruma-events/src/room/create.rs
index 7895e9cd..198f3fca 100644
--- a/ruma-events/src/room/create.rs
+++ b/ruma-events/src/room/create.rs
@@ -17,6 +17,7 @@ pub type CreateEvent = StateEvent<CreateEventContent>;
 #[ruma_event(type = "m.room.create")]
 pub struct CreateEventContent {
     /// The `user_id` of the room creator. This is set by the homeserver.
+    #[ruma_event(skip_redaction)]
     pub creator: UserId,
 
     /// Whether or not this room's data should be transferred to other homeservers.
diff --git a/ruma-events/src/room/history_visibility.rs b/ruma-events/src/room/history_visibility.rs
index 8142266b..4062bbfa 100644
--- a/ruma-events/src/room/history_visibility.rs
+++ b/ruma-events/src/room/history_visibility.rs
@@ -15,6 +15,7 @@ pub type HistoryVisibilityEvent = StateEvent<HistoryVisibilityEventContent>;
 #[ruma_event(type = "m.room.history_visibility")]
 pub struct HistoryVisibilityEventContent {
     /// Who can see the room history.
+    #[ruma_event(skip_redaction)]
     pub history_visibility: HistoryVisibility,
 }
 
diff --git a/ruma-events/src/room/join_rules.rs b/ruma-events/src/room/join_rules.rs
index 2a212f20..d48d7cc0 100644
--- a/ruma-events/src/room/join_rules.rs
+++ b/ruma-events/src/room/join_rules.rs
@@ -14,6 +14,7 @@ pub type JoinRulesEvent = StateEvent<JoinRulesEventContent>;
 #[ruma_event(type = "m.room.join_rules")]
 pub struct JoinRulesEventContent {
     /// The type of rules used for users wishing to join this room.
+    #[ruma_event(skip_redaction)]
     pub join_rule: JoinRule,
 }
 
diff --git a/ruma-events/src/room/member.rs b/ruma-events/src/room/member.rs
index 63ea364d..96f85b41 100644
--- a/ruma-events/src/room/member.rs
+++ b/ruma-events/src/room/member.rs
@@ -54,6 +54,7 @@ pub struct MemberEventContent {
     pub is_direct: Option<bool>,
 
     /// The membership state of this user.
+    #[ruma_event(skip_redaction)]
     pub membership: MembershipState,
 
     /// If this member event is the successor to a third party invitation, this field will
diff --git a/ruma-events/src/room/power_levels.rs b/ruma-events/src/room/power_levels.rs
index 8ca5ebc0..e2ec01dc 100644
--- a/ruma-events/src/room/power_levels.rs
+++ b/ruma-events/src/room/power_levels.rs
@@ -18,16 +18,19 @@ pub type PowerLevelsEvent = StateEvent<PowerLevelsEventContent>;
 pub struct PowerLevelsEventContent {
     /// The level required to ban a user.
     #[serde(default = "default_power_level", skip_serializing_if = "is_default_power_level")]
+    #[ruma_event(skip_redaction)]
     pub ban: Int,
 
     /// The level required to send specific event types.
     ///
     /// This is a mapping from event type to power level required.
     #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    #[ruma_event(skip_redaction)]
     pub events: BTreeMap<EventType, Int>,
 
     /// The default level required to send message events.
     #[serde(default, skip_serializing_if = "ruma_serde::is_default")]
+    #[ruma_event(skip_redaction)]
     pub events_default: Int,
 
     /// The level required to invite a user.
@@ -36,24 +39,29 @@ pub struct PowerLevelsEventContent {
 
     /// The level required to kick a user.
     #[serde(default = "default_power_level", skip_serializing_if = "is_default_power_level")]
+    #[ruma_event(skip_redaction)]
     pub kick: Int,
 
     /// The level required to redact an event.
     #[serde(default = "default_power_level", skip_serializing_if = "is_default_power_level")]
+    #[ruma_event(skip_redaction)]
     pub redact: Int,
 
     /// The default level required to send state events.
     #[serde(default = "default_power_level", skip_serializing_if = "is_default_power_level")]
+    #[ruma_event(skip_redaction)]
     pub state_default: Int,
 
     /// The power levels for specific users.
     ///
     /// This is a mapping from `user_id` to power level for that user.
     #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    #[ruma_event(skip_redaction)]
     pub users: BTreeMap<UserId, Int>,
 
     /// The default power level for every user in the room.
     #[serde(default, skip_serializing_if = "ruma_serde::is_default")]
+    #[ruma_event(skip_redaction)]
     pub users_default: Int,
 
     /// The power level requirements for specific notification types.
diff --git a/ruma-events/src/room/redaction.rs b/ruma-events/src/room/redaction.rs
index eaa7e581..d43d3a12 100644
--- a/ruma-events/src/room/redaction.rs
+++ b/ruma-events/src/room/redaction.rs
@@ -6,7 +6,10 @@ use ruma_events_macros::{Event, EventContent};
 use ruma_identifiers::{EventId, RoomId, UserId};
 use serde::{Deserialize, Serialize};
 
-use crate::UnsignedData;
+use crate::{
+    MessageEventContent, RedactedMessageEventContent, RedactedStateEventContent, RoomEventContent,
+    UnsignedData,
+};
 
 /// Redaction event.
 #[derive(Clone, Debug, Event)]
@@ -64,84 +67,10 @@ pub struct RedactionEventContent {
     pub reason: Option<String>,
 }
 
-impl ruma_events::RoomEventContent for RedactionEventContent {}
-
-impl ruma_events::MessageEventContent for RedactionEventContent {}
-
-#[cfg(test)]
-mod tests {
-    use std::{
-        convert::TryFrom,
-        time::{Duration, UNIX_EPOCH},
-    };
-
-    use matches::assert_matches;
-    use ruma_identifiers::{EventId, RoomId, UserId};
-    use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
-
-    use super::{RedactionEvent, RedactionEventContent};
-    use crate::{EventJson, UnsignedData};
-
-    #[test]
-    fn serialization() {
-        let event = RedactionEvent {
-            content: RedactionEventContent { reason: Some("redacted because".into()) },
-            redacts: EventId::try_from("$h29iv0s8:example.com").unwrap(),
-            event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(),
-            origin_server_ts: UNIX_EPOCH + Duration::from_millis(1),
-            room_id: RoomId::try_from("!roomid:room.com").unwrap(),
-            sender: UserId::try_from("@carl:example.com").unwrap(),
-            unsigned: UnsignedData::default(),
-        };
-
-        let json = json!({
-            "content": {
-                "reason": "redacted because"
-            },
-            "event_id": "$h29iv0s8:example.com",
-            "origin_server_ts": 1,
-            "redacts": "$h29iv0s8:example.com",
-            "room_id": "!roomid:room.com",
-            "sender": "@carl:example.com",
-            "type": "m.room.redaction",
-        });
-
-        assert_eq!(to_json_value(&event).unwrap(), json);
-    }
-
-    #[test]
-    fn deserialization() {
-        let e_id = EventId::try_from("$h29iv0s8:example.com").unwrap();
-
-        let json = json!({
-            "content": {
-                "reason": "redacted because"
-            },
-            "event_id": "$h29iv0s8:example.com",
-            "origin_server_ts": 1,
-            "redacts": "$h29iv0s8:example.com",
-            "room_id": "!roomid:room.com",
-            "sender": "@carl:example.com",
-            "type": "m.room.redaction",
-        });
-
-        assert_matches!(
-            from_json_value::<EventJson<RedactionEvent>>(json)
-                .unwrap()
-                .deserialize()
-                .unwrap(),
-            RedactionEvent {
-                content: RedactionEventContent {
-                    reason: Some(reason),
-                },
-                sender,
-                event_id, origin_server_ts, redacts, room_id, unsigned,
-            } if reason == "redacted because" && redacts == e_id
-                && event_id == e_id
-                && sender == "@carl:example.com"
-                && origin_server_ts == UNIX_EPOCH + Duration::from_millis(1)
-                && room_id == RoomId::try_from("!roomid:room.com").unwrap()
-                && unsigned.is_empty()
-        );
-    }
-}
+impl RoomEventContent for RedactionEventContent {}
+
+impl MessageEventContent for RedactionEventContent {}
+
+impl RedactedMessageEventContent for RedactedRedactionEventContent {}
+
+impl RedactedStateEventContent for RedactedRedactionEventContent {}
diff --git a/ruma-events/src/tag.rs b/ruma-events/src/tag.rs
index 84191b57..2f828947 100644
--- a/ruma-events/src/tag.rs
+++ b/ruma-events/src/tag.rs
@@ -9,6 +9,7 @@ use crate::BasicEvent;
 
 /// Informs the client of tags on a room.
 pub type TagEvent = BasicEvent<TagEventContent>;
+
 /// Map of tag names to tag info.
 pub type Tags = BTreeMap<String, TagInfo>;
 


Test patch:
diff --git a/ruma-events/tests/redacted.rs b/ruma-events/tests/redacted.rs
new file mode 100644
index 00000000..6e043fa0
--- /dev/null
+++ b/ruma-events/tests/redacted.rs
@@ -0,0 +1,268 @@
+use std::{
+    convert::TryFrom,
+    time::{Duration, UNIX_EPOCH},
+};
+
+use matches::assert_matches;
+use ruma_events::{
+    custom::RedactedCustomEventContent,
+    room::{
+        aliases::RedactedAliasesEventContent,
+        create::RedactedCreateEventContent,
+        message::RedactedMessageEventContent,
+        redaction::{RedactionEvent, RedactionEventContent},
+    },
+    AnyRedactedMessageEvent, AnyRedactedMessageEventStub, AnyRedactedStateEventStub, AnyRoomEvent,
+    AnyRoomEventStub, EventJson, RedactedMessageEvent, RedactedMessageEventStub,
+    RedactedStateEventStub, UnsignedData,
+};
+use ruma_identifiers::{EventId, RoomId, UserId};
+use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
+
+fn is_zst<T>(_: &T) -> bool {
+    std::mem::size_of::<T>() == 0
+}
+
+#[test]
+fn redacted_message_event_serialize() {
+    let redacted = RedactedMessageEventStub {
+        content: RedactedMessageEventContent,
+        event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        origin_server_ts: UNIX_EPOCH + Duration::from_millis(1),
+        sender: UserId::try_from("@carl:example.com").unwrap(),
+        unsigned: UnsignedData::default(),
+    };
+
+    let expected = json!({
+      "event_id": "$h29iv0s8:example.com",
+      "origin_server_ts": 1,
+      "sender": "@carl:example.com",
+      "type": "m.room.message"
+    });
+
+    let actual = to_json_value(&redacted).unwrap();
+    assert_eq!(actual, expected);
+}
+
+#[test]
+fn redacted_aliases_event_serialize() {
+    let redacted = RedactedStateEventStub {
+        content: RedactedAliasesEventContent { aliases: None },
+        event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        state_key: "".to_string(),
+        origin_server_ts: UNIX_EPOCH + Duration::from_millis(1),
+        sender: UserId::try_from("@carl:example.com").unwrap(),
+        unsigned: UnsignedData::default(),
+    };
+
+    let expected = json!({
+      "event_id": "$h29iv0s8:example.com",
+      "state_key": "",
+      "origin_server_ts": 1,
+      "sender": "@carl:example.com",
+      "type": "m.room.aliases"
+    });
+
+    let actual = to_json_value(&redacted).unwrap();
+    assert_eq!(actual, expected);
+}
+
+#[test]
+fn redacted_deserialize_any_room() {
+    let mut unsigned = UnsignedData::default();
+    // The presence of `redacted_because` triggers the event enum (AnyRoomEvent in this case)
+    // to return early with `RedactedContent` instead of failing to deserialize according
+    // to the event type string.
+    unsigned.redacted_because = Some(EventJson::from(RedactionEvent {
+        content: RedactionEventContent { reason: Some("redacted because".into()) },
+        redacts: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        origin_server_ts: UNIX_EPOCH + Duration::from_millis(1),
+        room_id: RoomId::try_from("!roomid:room.com").unwrap(),
+        sender: UserId::try_from("@carl:example.com").unwrap(),
+        unsigned: UnsignedData::default(),
+    }));
+
+    let redacted = json!({
+      "event_id": "$h29iv0s8:example.com",
+      "room_id": "!roomid:room.com",
+      "origin_server_ts": 1,
+      "sender": "@carl:example.com",
+      "unsigned": unsigned,
+      "type": "m.room.message"
+    });
+
+    let actual = to_json_value(&redacted).unwrap();
+
+    assert_matches!(
+        from_json_value::<EventJson<AnyRoomEvent>>(actual)
+            .unwrap()
+            .deserialize()
+            .unwrap(),
+        AnyRoomEvent::RedactedMessage(AnyRedactedMessageEvent::RoomMessage(RedactedMessageEvent {
+            event_id, room_id, content, ..
+        })) if event_id == EventId::try_from("$h29iv0s8:example.com").unwrap()
+            && room_id == RoomId::try_from("!roomid:room.com").unwrap()
+            && is_zst(&content)
+    )
+}
+
+#[test]
+fn redacted_deserialize_any_room_stub() {
+    let mut unsigned = UnsignedData::default();
+    // The presence of `redacted_because` triggers the event enum (AnyRoomEventStub in this case)
+    // to return early with `RedactedContent` instead of failing to deserialize according
+    // to the event type string.
+    unsigned.redacted_because = Some(EventJson::from(RedactionEvent {
+        content: RedactionEventContent { reason: Some("redacted because".into()) },
+        redacts: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        origin_server_ts: UNIX_EPOCH + Duration::from_millis(1),
+        room_id: RoomId::try_from("!roomid:room.com").unwrap(),
+        sender: UserId::try_from("@carl:example.com").unwrap(),
+        unsigned: UnsignedData::default(),
+    }));
+
+    let redacted = json!({
+      "event_id": "$h29iv0s8:example.com",
+      "origin_server_ts": 1,
+      "sender": "@carl:example.com",
+      "unsigned": unsigned,
+      "type": "m.room.message"
+    });
+
+    let actual = to_json_value(&redacted).unwrap();
+
+    assert_matches!(
+        from_json_value::<EventJson<AnyRoomEventStub>>(actual)
+            .unwrap()
+            .deserialize()
+            .unwrap(),
+        AnyRoomEventStub::RedactedMessage(AnyRedactedMessageEventStub::RoomMessage(RedactedMessageEventStub {
+            event_id, content, ..
+        })) if event_id == EventId::try_from("$h29iv0s8:example.com").unwrap()
+            && is_zst(&content)
+    )
+}
+
+#[test]
+fn redacted_state_event_deserialize() {
+    let mut unsigned = UnsignedData::default();
+    unsigned.redacted_because = Some(EventJson::from(RedactionEvent {
+        content: RedactionEventContent { reason: Some("redacted because".into()) },
+        redacts: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        origin_server_ts: UNIX_EPOCH + Duration::from_millis(1),
+        room_id: RoomId::try_from("!roomid:room.com").unwrap(),
+        sender: UserId::try_from("@carl:example.com").unwrap(),
+        unsigned: UnsignedData::default(),
+    }));
+
+    let redacted = json!({
+      "content": {
+        "creator": "@carl:example.com",
+      },
+      "event_id": "$h29iv0s8:example.com",
+      "origin_server_ts": 1,
+      "sender": "@carl:example.com",
+      "state_key": "hello there",
+      "unsigned": unsigned,
+      "type": "m.room.create"
+    });
+
+    assert_matches!(
+        from_json_value::<EventJson<AnyRoomEventStub>>(redacted)
+            .unwrap()
+            .deserialize()
+            .unwrap(),
+        AnyRoomEventStub::RedactedState(AnyRedactedStateEventStub::RoomCreate(RedactedStateEventStub {
+            content: RedactedCreateEventContent {
+                creator,
+            },
+            event_id, state_key, unsigned, ..
+        })) if event_id == EventId::try_from("$h29iv0s8:example.com").unwrap()
+            && unsigned.redacted_because.is_some()
+            && state_key == "hello there"
+            && creator == UserId::try_from("@carl:example.com").unwrap()
+    )
+}
+
+#[test]
+fn redacted_custom_event_serialize() {
+    let mut unsigned = UnsignedData::default();
+    unsigned.redacted_because = Some(EventJson::from(RedactionEvent {
+        content: RedactionEventContent { reason: Some("redacted because".into()) },
+        redacts: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        origin_server_ts: UNIX_EPOCH + Duration::from_millis(1),
+        room_id: RoomId::try_from("!roomid:room.com").unwrap(),
+        sender: UserId::try_from("@carl:example.com").unwrap(),
+        unsigned: UnsignedData::default(),
+    }));
+
+    let redacted = json!({
+        "event_id": "$h29iv0s8:example.com",
+        "origin_server_ts": 1,
+        "sender": "@carl:example.com",
+        "state_key": "hello there",
+        "unsigned": unsigned,
+        "type": "m.made.up"
+    });
+
+    assert_matches!(
+        from_json_value::<EventJson<AnyRoomEventStub>>(redacted.clone())
+            .unwrap()
+            .deserialize()
+            .unwrap(),
+        AnyRoomEventStub::RedactedState(AnyRedactedStateEventStub::Custom(RedactedStateEventStub {
+            content: RedactedCustomEventContent {
+                event_type,
+            },
+            event_id, state_key, unsigned, ..
+        })) if event_id == EventId::try_from("$h29iv0s8:example.com").unwrap()
+            && unsigned.redacted_because.is_some()
+            && state_key == "hello there"
+            && event_type == "m.made.up"
+    );
+
+    let x = from_json_value::<EventJson<crate::AnyRedactedStateEventStub>>(redacted)
+        .unwrap()
+        .deserialize()
+        .unwrap();
+    assert_eq!(x.event_id(), &EventId::try_from("$h29iv0s8:example.com").unwrap())
+}
+
+#[test]
+fn redacted_custom_event_deserialize() {
+    let mut unsigned = UnsignedData::default();
+    unsigned.redacted_because = Some(EventJson::from(RedactionEvent {
+        content: RedactionEventContent { reason: Some("redacted because".into()) },
+        redacts: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        origin_server_ts: UNIX_EPOCH + Duration::from_millis(1),
+        room_id: RoomId::try_from("!roomid:room.com").unwrap(),
+        sender: UserId::try_from("@carl:example.com").unwrap(),
+        unsigned: UnsignedData::default(),
+    }));
+
+    let redacted = RedactedStateEventStub {
+        content: RedactedCustomEventContent { event_type: "m.made.up".to_string() },
+        event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(),
+        sender: UserId::try_from("@carl:example.com").unwrap(),
+        state_key: "hello there".to_string(),
+        origin_server_ts: UNIX_EPOCH + Duration::from_millis(1),
+        unsigned: unsigned.clone(),
+    };
+
+    let expected = json!({
+      "event_id": "$h29iv0s8:example.com",
+      "origin_server_ts": 1,
+      "sender": "@carl:example.com",
+      "state_key": "hello there",
+      "unsigned": unsigned,
+      "type": "m.made.up"
+    });
+
+    let actual = to_json_value(&redacted).unwrap();
+    assert_eq!(actual, expected);
+}
diff --git a/ruma-events/tests/ui/03-invalid-event-type.stderr b/ruma-events/tests/ui/03-invalid-event-type.stderr
index d4fc4a3b..0569550d 100644
--- a/ruma-events/tests/ui/03-invalid-event-type.stderr
+++ b/ruma-events/tests/ui/03-invalid-event-type.stderr
@@ -1,4 +1,4 @@
-error: expected `type`
+error: not a recognized `ruma_event` attribute
   --> $DIR/03-invalid-event-type.rs:11:14
    |
 11 | #[ruma_event(event = "m.macro.test")]