diff --git a/CMakeLists.txt b/CMakeLists.txt
index 21dcfddbc3adc7b5f571152a77b10d66dcb0efcb..1826ab5212edaff7896e2600db14bdc63688c637 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -33,6 +33,7 @@ option(USE_BUNDLED_COEURL "Use a bundled version of the Curl wrapper"
 	${HUNTER_ENABLED})
 option(USE_BUNDLED_LIBEVENT "Use the bundled version of libevent." ${HUNTER_ENABLED})
 option(USE_BUNDLED_LIBCURL "Use the bundled version of libcurl." ${HUNTER_ENABLED})
+option(USE_BUNDLED_RE2 "Use the bundled version of re2." ${HUNTER_ENABLED})
 
 
 if (CMAKE_VERSION VERSION_GREATER_EQUAL 3.12)
@@ -126,6 +127,16 @@ set_package_properties(nlohmann_json PROPERTIES
 	TYPE REQUIRED
 	)
 
+if(USE_BUNDLED_RE2)
+	hunter_add_package(re2)
+	find_package(re2 CONFIG REQUIRED)
+	target_link_libraries(matrix_client PRIVATE re2::re2)
+else()
+	find_package(PkgConfig REQUIRED) 
+	pkg_check_modules(re2 REQUIRED IMPORTED_TARGET re2)
+	target_link_libraries(matrix_client PRIVATE PkgConfig::re2)
+endif()
+
 ## Need to repeat all libevent deps?!?
 # libevent
 if (USE_BUNDLED_LIBEVENT)
diff --git a/README.md b/README.md
index bc46b1d665aaeaf2f48a90f9b634f7c570db51d1..de6c50b91ce617640d195d58e589c0cae9d44697 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ If you are missing some or all of those above dependencies, you can add `-DHUNTE
 | USE_BUNDLED_GTEST   | Use the bundled version of Google Test. |
 | USE_BUNDLED_JSON    | Use the bundled version of nlohmann json. |
 | USE_BUNDLED_OPENSSL | Use the bundled version of OpenSSL. |
+| USE_BUNDLED_RE2 | Use the bundled version of re2. |
 
 Below is an example which will build the library along with the tests & examples.
 
diff --git a/include/mtx/events/collections.hpp b/include/mtx/events/collections.hpp
index f48aed6959f1dae312bd67394171cb6cac71fdb9..6cbcea38eb9edd6ab6401f6f3def39dbee905d9c 100644
--- a/include/mtx/events/collections.hpp
+++ b/include/mtx/events/collections.hpp
@@ -199,6 +199,7 @@ struct TimelineEvent
     TimelineEvents data;
 
     friend void from_json(const nlohmann::json &obj, TimelineEvent &e);
+    friend void to_json(nlohmann::json &obj, const TimelineEvent &e);
 };
 
 } // namespace collections
diff --git a/include/mtx/events_impl.hpp b/include/mtx/events_impl.hpp
index fca8e289b1d3c6fd8d8883c727b65af164b5ca36..e8e3a6fe10fe51c18ddfff3f1ed81384886830e0 100644
--- a/include/mtx/events_impl.hpp
+++ b/include/mtx/events_impl.hpp
@@ -10,14 +10,12 @@ namespace detail {
 
 template<typename, typename = void>
 struct can_edit : std::false_type
-{
-};
+{};
 
 template<typename Content>
 struct can_edit<Content, std::void_t<decltype(Content::relations)>>
   : std::is_same<decltype(Content::relations), mtx::common::Relations>
-{
-};
+{};
 }
 
 template<class Content>
diff --git a/include/mtx/pushrules.hpp b/include/mtx/pushrules.hpp
index 155928952918c7c6d0d20f571a020dcb372facf0..bcc20de8ceb85bee36e6a35c0d724564bfbfa0dd 100644
--- a/include/mtx/pushrules.hpp
+++ b/include/mtx/pushrules.hpp
@@ -9,11 +9,20 @@
 #include <nlohmann/json.hpp>
 #endif
 
+#include <compare>
 #include <string>
 #include <variant>
 #include <vector>
 
+#include "mtx/events/power_levels.hpp"
+
 namespace mtx {
+namespace events {
+namespace collections {
+struct TimelineEvent;
+}
+}
+
 //! Namespace for the pushrules specific endpoints.
 namespace pushrules {
 //! A condition to match pushrules on.
@@ -45,27 +54,37 @@ struct PushCondition
 namespace actions {
 //! Notify the user.
 struct notify
-{};
+{
+    bool operator==(const notify &) const noexcept = default;
+};
 //! Don't notify the user.
 struct dont_notify
-{};
+{
+    bool operator==(const dont_notify &) const noexcept = default;
+};
 /// @brief This enables notifications for matching events but activates homeserver specific
 /// behaviour to intelligently coalesce multiple events into a single notification.
 ///
 /// Not all homeservers may support this. Those that do not support it should treat it as the notify
 /// action.
 struct coalesce
-{};
+{
+    bool operator==(const coalesce &) const noexcept = default;
+};
 //! Play a sound.
 struct set_tweak_sound
 {
     //! The sound to play.
     std::string value = "default";
+
+    bool operator==(const set_tweak_sound &) const noexcept = default;
 };
 //! Highlight the message.
 struct set_tweak_highlight
 {
     bool value = true;
+
+    bool operator==(const set_tweak_highlight &) const noexcept = default;
 };
 
 //! A collection for the different actions.
@@ -88,6 +107,8 @@ struct Actions
 
     friend void to_json(nlohmann::json &obj, const Actions &action);
     friend void from_json(const nlohmann::json &obj, Actions &action);
+
+    bool operator==(const Actions &) const noexcept = default;
 };
 }
 
@@ -153,5 +174,40 @@ struct Enabled
     friend void to_json(nlohmann::json &obj, const Enabled &enabled);
     friend void from_json(const nlohmann::json &obj, Enabled &enabled);
 };
+
+//! An optimized structure to calculate notifications for events.
+///
+/// You will want to cache this for as long as possible (until the pushrules change), since
+/// constructing this is somewhat expensive.
+class PushRuleEvaluator
+{
+public:
+    //! Construct a new push evaluator. Pass the current set of pushrules to evaluate.
+    PushRuleEvaluator(const Ruleset &rules);
+    ~PushRuleEvaluator();
+
+    //! Additional room information needed to evaluate push rules.
+    struct RoomContext
+    {
+        //! The displayname of the user in the room.
+        std::string user_display_name;
+        //! the membercount of the room
+        std::size_t member_count = 0;
+        //! The powerlevels event in this room
+        mtx::events::state::PowerLevels power_levels;
+    };
+
+    //! Evaluate the pushrules for @event .
+    ///
+    /// You need to have the room_id set for the event.
+    /// \returns the actions to apply.
+    [[nodiscard]] std::vector<actions::Action> evaluate(
+      const mtx::events::collections::TimelineEvent &event,
+      const RoomContext &ctx) const;
+
+private:
+    struct OptimizedRules;
+    std::unique_ptr<OptimizedRules> rules;
+};
 }
 }
diff --git a/include/mtxclient/crypto/objects.hpp b/include/mtxclient/crypto/objects.hpp
index 3babd189d30cee03a1a53e5b523e54609bb050bc..2bb3ceca451b6f9d4ddca96de24e98f0969c7b0e 100644
--- a/include/mtxclient/crypto/objects.hpp
+++ b/include/mtxclient/crypto/objects.hpp
@@ -31,49 +31,49 @@ struct OlmDeleter
     void operator()(OlmAccount *ptr)
     {
         olm_clear_account(ptr);
-        delete[](reinterpret_cast<uint8_t *>(ptr));
+        delete[] (reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmUtility *ptr)
     {
         olm_clear_utility(ptr);
-        delete[](reinterpret_cast<uint8_t *>(ptr));
+        delete[] (reinterpret_cast<uint8_t *>(ptr));
     }
 
     void operator()(OlmPkDecryption *ptr)
     {
         olm_clear_pk_decryption(ptr);
-        delete[](reinterpret_cast<uint8_t *>(ptr));
+        delete[] (reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmPkEncryption *ptr)
     {
         olm_clear_pk_encryption(ptr);
-        delete[](reinterpret_cast<uint8_t *>(ptr));
+        delete[] (reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmPkSigning *ptr)
     {
         olm_clear_pk_signing(ptr);
-        delete[](reinterpret_cast<uint8_t *>(ptr));
+        delete[] (reinterpret_cast<uint8_t *>(ptr));
     }
 
     void operator()(OlmSession *ptr)
     {
         olm_clear_session(ptr);
-        delete[](reinterpret_cast<uint8_t *>(ptr));
+        delete[] (reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmOutboundGroupSession *ptr)
     {
         olm_clear_outbound_group_session(ptr);
-        delete[](reinterpret_cast<uint8_t *>(ptr));
+        delete[] (reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmInboundGroupSession *ptr)
     {
         olm_clear_inbound_group_session(ptr);
-        delete[](reinterpret_cast<uint8_t *>(ptr));
+        delete[] (reinterpret_cast<uint8_t *>(ptr));
     }
     void operator()(OlmSAS *ptr)
     {
         olm_clear_sas(ptr);
-        delete[](reinterpret_cast<uint8_t *>(ptr));
+        delete[] (reinterpret_cast<uint8_t *>(ptr));
     }
 };
 
diff --git a/lib/structs/events/collections.cpp b/lib/structs/events/collections.cpp
index 8211b759894da339be1f6bd31362ec2f3e229aab..096d43d3bf67e2ce1136a3c716eda3f8f777f47d 100644
--- a/lib/structs/events/collections.cpp
+++ b/lib/structs/events/collections.cpp
@@ -126,6 +126,12 @@ MTXCLIENT_INSTANTIATE_JSON_FUNCTIONS(events::RedactionEvent, msg::Redaction)
 }
 
 namespace mtx::events::collections {
+void
+to_json(nlohmann::json &obj, const TimelineEvent &e)
+{
+    std::visit([&obj](const auto &ev) { return to_json(obj, ev); }, e.data);
+}
+
 void
 from_json(const nlohmann::json &obj, TimelineEvent &e)
 {
diff --git a/lib/structs/pushrules.cpp b/lib/structs/pushrules.cpp
index 3d9a2af0a0f65f2f62c784731e3b4755e80556b5..9840b0a854bd8e2aad14cab602e0e85ffbebffaa 100644
--- a/lib/structs/pushrules.cpp
+++ b/lib/structs/pushrules.cpp
@@ -1,7 +1,11 @@
 #include "mtx/pushrules.hpp"
 
+#include <charconv>
+
 #include <nlohmann/json.hpp>
+#include <re2/re2.h>
 
+#include "mtx/events/collections.hpp"
 namespace mtx {
 namespace pushrules {
 
@@ -165,5 +169,337 @@ from_json(const nlohmann::json &obj, Enabled &enabled)
 {
     enabled.enabled = obj.value("enabled", true);
 }
+
+struct PushRuleEvaluator::OptimizedRules
+{
+    //! The individual rule to apply
+    struct OptimizedRule
+    {
+        //! a pattern condition to match
+        struct PatternCondition
+        {
+            std::unique_ptr<re2::RE2> pattern; //< the pattern
+            std::string field;                 //< the field to match with pattern
+        };
+        // TODO(Nico): Sort by field for faster matching?
+        std::vector<PatternCondition> patterns; //< conditions that match on a field
+
+        //! a member count condition
+        struct MemberCountCondition
+        {
+            //! the count to compare against
+            std::size_t count = 0;
+            //! the comparison operation
+            enum Comp
+            {
+                Eq, //< ==
+                Lt, //< <
+                Le, //< <=
+                Ge, //< >=
+                Gt, //< >
+            };
+
+            Comp op = Comp::Eq;
+        };
+        std::vector<MemberCountCondition> membercounts; //< conditions that match on member count
+
+        std::vector<std::string> notification_levels;
+
+        //! evaluate contains_display_name condition
+        bool check_displayname = false;
+
+        std::vector<actions::Action> actions; //< the actions to apply on match
+
+        [[nodiscard]] bool matches(const std::unordered_map<std::string, std::string> &ev,
+                                   const PushRuleEvaluator::RoomContext &ctx) const
+        {
+            for (const auto &cond : membercounts) {
+                if (![&cond, &ctx] {
+                        switch (cond.op) {
+                        case MemberCountCondition::Eq:
+                            return ctx.member_count == cond.count;
+                        case MemberCountCondition::Le:
+                            return ctx.member_count <= cond.count;
+                        case MemberCountCondition::Ge:
+                            return ctx.member_count >= cond.count;
+                        case MemberCountCondition::Lt:
+                            return ctx.member_count < cond.count;
+                        case MemberCountCondition::Gt:
+                            return ctx.member_count > cond.count;
+                        default:
+                            return false;
+                        }
+                    }())
+                    return false;
+            }
+
+            if (!notification_levels.empty()) {
+                auto sender = ev.find("sender");
+                if (sender == ev.end())
+                    return false;
+
+                auto sender_level = ctx.power_levels.user_level(sender->second);
+
+                for (const auto &n : notification_levels) {
+                    if (sender_level < ctx.power_levels.notification_level(n))
+                        return false;
+                }
+            }
+
+            for (const auto &cond : patterns) {
+                if (auto it = ev.find(cond.field); it != ev.end()) {
+                    if (cond.pattern) {
+                        if (cond.field == "content.body") {
+                            if (!re2::RE2::PartialMatch(it->second, *cond.pattern))
+                                return false;
+                        } else {
+                            if (!re2::RE2::FullMatch(it->second, *cond.pattern))
+                                return false;
+                        }
+                    }
+                } else {
+                    return false;
+                }
+            }
+
+            if (check_displayname) {
+                if (auto it = ev.find("content.body"); it != ev.end()) {
+                    re2::RE2::Options opts;
+                    opts.set_case_sensitive(false);
+
+                    if (!re2::RE2::PartialMatch(
+                          it->second,
+                          re2::RE2("(\\W|^)" + re2::RE2::QuoteMeta(ctx.user_display_name) +
+                                     "(\\W|$)",
+                                   opts)))
+                        return false;
+                }
+            }
+
+            return true;
+        }
+    };
+
+    std::vector<OptimizedRule> override_;
+    std::unordered_map<std::string, OptimizedRule> room;
+    std::unordered_map<std::string, OptimizedRule> sender;
+    std::vector<OptimizedRule> content;
+    std::vector<OptimizedRule> underride;
+};
+
+static std::unique_ptr<re2::RE2>
+construct_re_from_pattern(std::string pat, const std::string &field)
+{
+    pat = re2::RE2::QuoteMeta(pat);
+
+    // Quote also espaces the globs, so we need to match them including the backslash
+    static re2::RE2 matchGlobStar("\\*");
+    re2::RE2::GlobalReplace(&pat, matchGlobStar, ".*");
+
+    static re2::RE2 matchGlobQuest("\\?");
+    re2::RE2::GlobalReplace(&pat, matchGlobQuest, ".");
+
+    re2::RE2::Options opts;
+    opts.set_case_sensitive(false);
+
+    if (field == "content.body")
+        return std::make_unique<re2::RE2>("(\\W|^)" + pat + "(\\W|$)", opts);
+    else
+        return std::make_unique<re2::RE2>(pat, opts);
+}
+
+PushRuleEvaluator::~PushRuleEvaluator() = default;
+PushRuleEvaluator::PushRuleEvaluator(const Ruleset &rules_)
+  : rules(std::make_unique<OptimizedRules>())
+{
+    auto add_conditions_to_rule = [](OptimizedRules::OptimizedRule &rule,
+                                     const std::vector<PushCondition> &conditions) {
+        for (const auto &cond : conditions) {
+            if (cond.kind == "event_match") {
+                OptimizedRules::OptimizedRule::PatternCondition c;
+                c.field   = cond.key;
+                c.pattern = construct_re_from_pattern(cond.pattern, cond.key);
+                if (c.pattern)
+                    rule.patterns.push_back(std::move(c));
+            } else if (cond.kind == "contains_display_name") {
+                rule.check_displayname = true;
+            } else if (cond.kind == "room_member_count") {
+                OptimizedRules::OptimizedRule::MemberCountCondition c;
+                std::string_view is = cond.is;
+                if (is.starts_with("==")) {
+                    c.op = c.Comp::Eq;
+                    is   = is.substr(2);
+                } else if (is.starts_with(">=")) {
+                    c.op = c.Comp::Ge;
+                    is   = is.substr(2);
+                } else if (is.starts_with("<=")) {
+                    c.op = c.Comp::Le;
+                    is   = is.substr(2);
+                } else if (is.starts_with('<')) {
+                    c.op = c.Comp::Lt;
+                    is   = is.substr(1);
+                } else if (is.starts_with('>')) {
+                    c.op = c.Comp::Gt;
+                    is   = is.substr(1);
+                }
+
+                std::from_chars(is.begin(), is.end(), c.count);
+                rule.membercounts.push_back(c);
+            } else if (cond.kind == "sender_notification_permission") {
+                rule.notification_levels.push_back(cond.key);
+            }
+        }
+    };
+
+    for (const auto &rule_ : rules_.override_) {
+        if (!rule_.enabled)
+            continue;
+
+        OptimizedRules::OptimizedRule rule;
+        rule.actions = rule_.actions;
+
+        add_conditions_to_rule(rule, rule_.conditions);
+
+        rules->override_.push_back(std::move(rule));
+    }
+
+    for (const auto &rule_ : rules_.underride) {
+        if (!rule_.enabled)
+            continue;
+
+        OptimizedRules::OptimizedRule rule;
+        rule.actions = rule_.actions;
+
+        add_conditions_to_rule(rule, rule_.conditions);
+
+        rules->underride.push_back(std::move(rule));
+    }
+
+    for (const auto &rule_ : rules_.room) {
+        if (!rule_.enabled)
+            continue;
+
+        if (!rule_.rule_id.starts_with("!"))
+            continue;
+
+        OptimizedRules::OptimizedRule rule;
+        rule.actions               = rule_.actions;
+        rules->room[rule_.rule_id] = std::move(rule);
+    }
+
+    for (const auto &rule_ : rules_.sender) {
+        if (!rule_.enabled)
+            continue;
+
+        if (!rule_.rule_id.starts_with("@"))
+            continue;
+
+        OptimizedRules::OptimizedRule rule;
+        rule.actions                 = rule_.actions;
+        rules->sender[rule_.rule_id] = std::move(rule);
+    }
+
+    for (const auto &rule_ : rules_.content) {
+        if (!rule_.enabled)
+            continue;
+
+        OptimizedRules::OptimizedRule rule;
+        rule.actions = rule_.actions;
+
+        std::vector<PushCondition> conditions{
+          PushCondition{.kind = "event_match", .key = "content.body", .pattern = rule_.pattern},
+        };
+
+        add_conditions_to_rule(rule, conditions);
+
+        rules->content.push_back(std::move(rule));
+    }
+}
+
+static void
+flatten_impl(const nlohmann::json &value,
+             std::unordered_map<std::string, std::string> &result,
+             const std::string &current_path,
+             int current_depth)
+{
+    if (current_depth > 100)
+        return;
+
+    switch (value.type()) {
+    case nlohmann::json::value_t::object: {
+        // iterate object and use keys as reference string
+        std::string prefix;
+        if (!current_path.empty())
+            prefix = current_path + ".";
+        for (const auto &element : value.items()) {
+            flatten_impl(element.value(), result, prefix + element.key(), current_depth + 1);
+        }
+        break;
+    }
+
+    case nlohmann::json::value_t::string: {
+        // add primitive value with its reference string
+        result[current_path] = value.get<std::string>();
+        break;
+    }
+
+        // currently we only match strings
+    case nlohmann::json::value_t::array:
+    case nlohmann::json::value_t::null:
+    case nlohmann::json::value_t::boolean:
+    case nlohmann::json::value_t::number_integer:
+    case nlohmann::json::value_t::number_unsigned:
+    case nlohmann::json::value_t::number_float:
+    case nlohmann::json::value_t::binary:
+    case nlohmann::json::value_t::discarded:
+    default:
+        break;
+    }
+}
+
+static std::unordered_map<std::string, std::string>
+flatten_event(const nlohmann::json &j)
+{
+    std::unordered_map<std::string, std::string> flat;
+    flatten_impl(j, flat, "", 0);
+    return flat;
+}
+
+std::vector<actions::Action>
+PushRuleEvaluator::evaluate(const mtx::events::collections::TimelineEvent &event,
+                            const RoomContext &ctx) const
+{
+    auto event_json = nlohmann::json(event);
+    auto flat_event = flatten_event(event_json);
+
+    for (const auto &rule : rules->override_) {
+        if (rule.matches(flat_event, ctx))
+            return rule.actions;
+    }
+
+    // room rule always matches if present
+    if (auto room_rule = rules->room.find(event_json.value("room_id", ""));
+        room_rule != rules->room.end()) {
+        return room_rule->second.actions;
+    }
+
+    // sender rule always matches if present
+    if (auto sender_rule = rules->sender.find(event_json.value("sender", ""));
+        sender_rule != rules->sender.end()) {
+        return sender_rule->second.actions;
+    }
+
+    for (const auto &rule : rules->content) {
+        if (rule.matches(flat_event, ctx))
+            return rule.actions;
+    }
+
+    for (const auto &rule : rules->underride) {
+        if (rule.matches(flat_event, ctx))
+            return rule.actions;
+    }
+    return {};
+}
+
 }
 }
diff --git a/meson.build b/meson.build
index 9c55364e7dd8efaa54c9673e3e8a97a6ec4e792c..29feb3008b271fe7028fc278e047f9a116b9475b 100644
--- a/meson.build
+++ b/meson.build
@@ -18,6 +18,7 @@ coeurl_dep = dependency('coeurl', version: '>=0.1.1', required: true)
 thread_dep = dependency('threads', required: true)
 openssl_dep = dependency('openssl', version: '>=1.1', required: true)
 spdlog_dep = dependency('spdlog', fallback: ['spdlog', 'spdlog_dep'])
+re2_dep = dependency('re2', required: true)
 
 json_dep = dependency('nlohmann_json', version: '>=3.2.0', required: true)
 
@@ -48,7 +49,8 @@ deps = [
   olm_dep,
   openssl_dep,
   json_dep,
-  spdlog_dep
+  spdlog_dep,
+  re2_dep,
 ]
 
 inc = include_directories('include')
diff --git a/subprojects/re2.wrap b/subprojects/re2.wrap
new file mode 100644
index 0000000000000000000000000000000000000000..fc77b7d6ad05a10ccd55049eacb73c887d861c57
--- /dev/null
+++ b/subprojects/re2.wrap
@@ -0,0 +1,12 @@
+[wrap-file]
+directory = re2-2022-04-01
+source_url = https://github.com/google/re2/archive/2022-04-01.tar.gz
+source_filename = re2-2022-04-01.tar.gz
+source_hash = 1ae8ccfdb1066a731bba6ee0881baad5efd2cd661acd9569b689f2586e1a50e9
+patch_filename = re2_20220401-1_patch.zip
+patch_url = https://wrapdb.mesonbuild.com/v2/re2_20220401-1/get_patch
+patch_hash = e27e17c208b5870483b6fa46f85bb540d974a2cf1c89eaff55fa3d0574cd3c2e
+
+[provide]
+re2 = re2_dep
+
diff --git a/tests/pushrules.cpp b/tests/pushrules.cpp
index 19f805ff1e5dd5065c9cabe4a7ca75561111c090..8da4793249d7f14d8f3037a04d2cd5b07bc312b2 100644
--- a/tests/pushrules.cpp
+++ b/tests/pushrules.cpp
@@ -412,3 +412,253 @@ TEST(Pushrules, RoomRuleMentions)
     });
     client->close();
 }
+
+TEST(Pushrules, EventMatches)
+{
+    mtx::pushrules::PushRule event_match_rule;
+    event_match_rule.actions = {
+      mtx::pushrules::actions::notify{},
+      mtx::pushrules::actions::set_tweak_highlight{},
+    };
+    event_match_rule.conditions.push_back(mtx::pushrules::PushCondition{
+      .kind    = "event_match",
+      .key     = "content.body",
+      .pattern = "honk",
+      .is      = "",
+    });
+
+    mtx::events::RoomEvent<mtx::events::msg::Text> textEv{};
+    textEv.content.body = "abc def ghi honk jkl";
+    textEv.room_id      = "!abc:def.ghi";
+    textEv.event_id     = "$abc1234567890:def.ghi";
+    textEv.sender       = "@me:def.ghi";
+
+    auto testEval = [actions = event_match_rule.actions,
+                     &textEv](const mtx::pushrules::PushRuleEvaluator &evaluator) {
+        mtx::pushrules::PushRuleEvaluator::RoomContext ctx{};
+        EXPECT_EQ(evaluator.evaluate({textEv}, ctx), actions);
+
+        auto textEvEnd         = textEv;
+        textEvEnd.content.body = "abc honk";
+        EXPECT_EQ(evaluator.evaluate({textEvEnd}, ctx), actions);
+        auto textEvStart         = textEv;
+        textEvStart.content.body = "honk abc";
+        EXPECT_EQ(evaluator.evaluate({textEvStart}, ctx), actions);
+        auto textEvNL         = textEv;
+        textEvNL.content.body = "abc\nhonk\nabc";
+        EXPECT_EQ(evaluator.evaluate({textEvNL}, ctx), actions);
+        auto textEvFull         = textEv;
+        textEvFull.content.body = "honk";
+        EXPECT_EQ(evaluator.evaluate({textEvFull}, ctx), actions);
+        auto textEvCase         = textEv;
+        textEvCase.content.body = "HoNk";
+        EXPECT_EQ(evaluator.evaluate({textEvCase}, ctx), actions);
+        auto textEvNo         = textEv;
+        textEvNo.content.body = "HoN";
+        EXPECT_TRUE(evaluator.evaluate({textEvNo}, ctx).empty());
+        auto textEvNo2         = textEv;
+        textEvNo2.content.body = "honkb";
+        EXPECT_TRUE(evaluator.evaluate({textEvNo2}, ctx).empty());
+        auto textEvWordBoundaries         = textEv;
+        textEvWordBoundaries.content.body = "@honk:";
+        EXPECT_EQ(evaluator.evaluate({textEvWordBoundaries}, ctx), actions);
+
+        // It is what the spec says ¯\_(ツ)_/¯
+        auto textEvWordBoundaries2         = textEv;
+        textEvWordBoundaries2.content.body = "ähonkü";
+        EXPECT_EQ(evaluator.evaluate({textEvWordBoundaries2}, ctx), actions);
+    };
+
+    mtx::pushrules::Ruleset override_ruleset;
+    override_ruleset.override_.push_back(event_match_rule);
+    mtx::pushrules::PushRuleEvaluator over_evaluator{override_ruleset};
+    testEval(over_evaluator);
+
+    mtx::pushrules::Ruleset underride_ruleset;
+    underride_ruleset.underride.push_back(event_match_rule);
+    mtx::pushrules::PushRuleEvaluator under_evaluator{underride_ruleset};
+    testEval(under_evaluator);
+
+    mtx::pushrules::Ruleset room_ruleset;
+    auto room_rule    = event_match_rule;
+    room_rule.rule_id = "!abc:def.ghi";
+    room_ruleset.room.push_back(room_rule);
+    mtx::pushrules::PushRuleEvaluator room_evaluator{room_ruleset};
+    EXPECT_EQ(room_evaluator.evaluate({textEv}, {}), room_rule.actions);
+
+    mtx::pushrules::Ruleset sender_ruleset;
+    auto sender_rule    = event_match_rule;
+    sender_rule.rule_id = "@me:def.ghi";
+    sender_ruleset.sender.push_back(sender_rule);
+    mtx::pushrules::PushRuleEvaluator sender_evaluator{sender_ruleset};
+    EXPECT_EQ(sender_evaluator.evaluate({textEv}, {}), sender_rule.actions);
+
+    mtx::pushrules::Ruleset content_ruleset;
+    mtx::pushrules::PushRule content_match_rule;
+    content_match_rule.actions = event_match_rule.actions;
+    content_match_rule.pattern = "honk";
+    content_ruleset.content.push_back(content_match_rule);
+    mtx::pushrules::PushRuleEvaluator content_evaluator{content_ruleset};
+    testEval(content_evaluator);
+}
+
+TEST(Pushrules, DisplaynameMatches)
+{
+    mtx::pushrules::PushRule event_match_rule;
+    event_match_rule.actions = {
+      mtx::pushrules::actions::notify{},
+      mtx::pushrules::actions::set_tweak_highlight{},
+    };
+    event_match_rule.conditions.push_back(mtx::pushrules::PushCondition{
+      .kind    = "contains_display_name",
+      .key     = "",
+      .pattern = "",
+      .is      = "",
+    });
+
+    mtx::events::RoomEvent<mtx::events::msg::Text> textEv{};
+    textEv.content.body = "abc def ghi honk jkl";
+    textEv.room_id      = "!abc:def.ghi";
+    textEv.event_id     = "$abc1234567890:def.ghi";
+    textEv.sender       = "@me:def.ghi";
+
+    auto testEval = [actions = event_match_rule.actions,
+                     &textEv](const mtx::pushrules::PushRuleEvaluator &evaluator) {
+        mtx::pushrules::PushRuleEvaluator::RoomContext ctx{};
+        ctx.user_display_name = "honk";
+
+        EXPECT_EQ(evaluator.evaluate({textEv}, ctx), actions);
+
+        auto textEvEnd         = textEv;
+        textEvEnd.content.body = "abc honk";
+        EXPECT_EQ(evaluator.evaluate({textEvEnd}, ctx), actions);
+        auto textEvStart         = textEv;
+        textEvStart.content.body = "honk abc";
+        EXPECT_EQ(evaluator.evaluate({textEvStart}, ctx), actions);
+        auto textEvNL         = textEv;
+        textEvNL.content.body = "abc\nhonk\nabc";
+        EXPECT_EQ(evaluator.evaluate({textEvNL}, ctx), actions);
+        auto textEvFull         = textEv;
+        textEvFull.content.body = "honk";
+        EXPECT_EQ(evaluator.evaluate({textEvFull}, ctx), actions);
+        auto textEvCase         = textEv;
+        textEvCase.content.body = "HoNk";
+        EXPECT_EQ(evaluator.evaluate({textEvCase}, ctx), actions);
+        auto textEvNo         = textEv;
+        textEvNo.content.body = "HoN";
+        EXPECT_TRUE(evaluator.evaluate({textEvNo}, ctx).empty());
+        auto textEvNo2         = textEv;
+        textEvNo2.content.body = "honkb";
+        EXPECT_TRUE(evaluator.evaluate({textEvNo2}, ctx).empty());
+        auto textEvWordBoundaries         = textEv;
+        textEvWordBoundaries.content.body = "@honk:";
+        EXPECT_EQ(evaluator.evaluate({textEvWordBoundaries}, ctx), actions);
+
+        // It is what the spec says ¯\_(ツ)_/¯
+        auto textEvWordBoundaries2         = textEv;
+        textEvWordBoundaries2.content.body = "ähonkü";
+        EXPECT_EQ(evaluator.evaluate({textEvWordBoundaries2}, ctx), actions);
+    };
+
+    mtx::pushrules::Ruleset override_ruleset;
+    override_ruleset.override_.push_back(event_match_rule);
+    mtx::pushrules::PushRuleEvaluator over_evaluator{override_ruleset};
+    testEval(over_evaluator);
+
+    mtx::pushrules::Ruleset underride_ruleset;
+    underride_ruleset.underride.push_back(event_match_rule);
+    mtx::pushrules::PushRuleEvaluator under_evaluator{underride_ruleset};
+    testEval(under_evaluator);
+}
+
+TEST(Pushrules, PowerLevelMatches)
+{
+    mtx::pushrules::PushRule event_match_rule;
+    event_match_rule.actions = {
+      mtx::pushrules::actions::notify{},
+      mtx::pushrules::actions::set_tweak_highlight{},
+    };
+    event_match_rule.conditions.push_back(mtx::pushrules::PushCondition{
+      .kind    = "sender_notification_permission",
+      .key     = "room",
+      .pattern = "",
+      .is      = "",
+    });
+
+    auto testEval =
+      [actions = event_match_rule.actions](const mtx::pushrules::PushRuleEvaluator &evaluator) {
+          mtx::events::RoomEvent<mtx::events::msg::Text> textEv{};
+          textEv.content.body = "abc def ghi honk @room jkl";
+          textEv.room_id      = "!abc:def.ghi";
+          textEv.event_id     = "$abc1234567890:def.ghi";
+          textEv.sender       = "@me:def.ghi";
+
+          mtx::events::state::PowerLevels pls;
+          pls.notifications["room"] = 1;
+          pls.users["@me:def.ghi"]  = 1;
+          mtx::pushrules::PushRuleEvaluator::RoomContext ctx{
+            .user_display_name = "me",
+            .member_count      = 100,
+            .power_levels      = pls,
+          };
+
+          EXPECT_EQ(evaluator.evaluate({textEv}, ctx), actions);
+
+          ctx.power_levels.users["@me:def.ghi"] = 0;
+          EXPECT_TRUE(evaluator.evaluate({textEv}, ctx).empty());
+      };
+
+    mtx::pushrules::Ruleset override_ruleset;
+    override_ruleset.override_.push_back(event_match_rule);
+    mtx::pushrules::PushRuleEvaluator over_evaluator{override_ruleset};
+    testEval(over_evaluator);
+
+    mtx::pushrules::Ruleset underride_ruleset;
+    underride_ruleset.underride.push_back(event_match_rule);
+    mtx::pushrules::PushRuleEvaluator under_evaluator{underride_ruleset};
+    testEval(under_evaluator);
+}
+
+TEST(Pushrules, MemberCountMatches)
+{
+    auto testEval = [](const std::string &is, bool lt, bool eq, bool gt) {
+        mtx::events::RoomEvent<mtx::events::msg::Text> textEv{};
+        textEv.content.body = "abc def ghi honk @room jkl";
+        textEv.room_id      = "!abc:def.ghi";
+        textEv.event_id     = "$abc1234567890:def.ghi";
+        textEv.sender       = "@me:def.ghi";
+
+        mtx::pushrules::PushRule event_match_rule;
+        event_match_rule.actions = {
+          mtx::pushrules::actions::notify{},
+          mtx::pushrules::actions::set_tweak_highlight{},
+        };
+        event_match_rule.conditions = {
+          mtx::pushrules::PushCondition{
+            .kind    = "room_member_count",
+            .key     = "",
+            .pattern = "",
+            .is      = is,
+          },
+        };
+        mtx::pushrules::Ruleset ruleset;
+        ruleset.override_.push_back(event_match_rule);
+        mtx::pushrules::PushRuleEvaluator evaluator{ruleset};
+
+        mtx::pushrules::PushRuleEvaluator::RoomContext ctx{};
+
+        ctx.member_count = 99;
+        EXPECT_EQ(!evaluator.evaluate({textEv}, ctx).empty(), lt);
+        ctx.member_count = 100;
+        EXPECT_EQ(!evaluator.evaluate({textEv}, ctx).empty(), eq);
+        ctx.member_count = 101;
+        EXPECT_EQ(!evaluator.evaluate({textEv}, ctx).empty(), gt);
+    };
+
+    testEval("100", false, true, false);
+    testEval("==100", false, true, false);
+    testEval(">=100", false, true, true);
+    testEval("<=100", true, true, false);
+    testEval(">100", false, false, true);
+    testEval("<100", true, false, false);
+}