diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png
index 4a682c1f0ed07e7f0cd771fe8e89e1439e202cdb..a099f6a117abe1106202058efae675b955f3c28b 100644
Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png
index f98e9d0f63e8864efa00d4a9be038a01762b653e..1b27a5bcb6320ca94a324961eb2c7391da039ef6 100644
Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png
index 6e51c3f9faf7d2549527b57d34884efbe9408d28..8fc889f31be72822c673d068bd2dd2d688a0af6f 100644
Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png differ
diff --git a/packages/desktop-client/src/components/filters/FilterExpression.tsx b/packages/desktop-client/src/components/filters/FilterExpression.tsx
index d649fd09c3f7375469af5f2c461ac6748bc7dd8f..34df7694a76bd11a8b0c29aa043d0272562a7d26 100644
--- a/packages/desktop-client/src/components/filters/FilterExpression.tsx
+++ b/packages/desktop-client/src/components/filters/FilterExpression.tsx
@@ -79,7 +79,8 @@ export function FilterExpression<T extends RuleConditionEntity>({
                 valueIsRaw={
                   op === 'contains' ||
                   op === 'matches' ||
-                  op === 'doesNotContain'
+                  op === 'doesNotContain' ||
+                  op === 'hasTags'
                 }
               />
             </>
diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx
index 61720dff6dd019669b6b458d47abbd6add023676..8baacffe99ebe8901edbc2ff5c01529baf3bc5dc 100644
--- a/packages/desktop-client/src/components/filters/FiltersMenu.jsx
+++ b/packages/desktop-client/src/components/filters/FiltersMenu.jsx
@@ -201,7 +201,10 @@ function ConfigureField({
             subfield={subfield}
             type={
               type === 'id' &&
-              (op === 'contains' || op === 'matches' || op === 'doesNotContain')
+              (op === 'contains' ||
+                op === 'matches' ||
+                op === 'doesNotContain' ||
+                op === 'hasTags')
                 ? 'string'
                 : type
             }
diff --git a/packages/desktop-client/src/components/filters/updateFilterReducer.ts b/packages/desktop-client/src/components/filters/updateFilterReducer.ts
index 5cb3727367e5e28702d4e73ada2747a474a51574..4f6286f83834157ca5fbc7e250f4e1b05f48bd80 100644
--- a/packages/desktop-client/src/components/filters/updateFilterReducer.ts
+++ b/packages/desktop-client/src/components/filters/updateFilterReducer.ts
@@ -18,7 +18,8 @@ export function updateFilterReducer(
           action.op === 'matches' ||
           action.op === 'is' ||
           action.op === 'doesNotContain' ||
-          action.op === 'isNot')
+          action.op === 'isNot' ||
+          action.op === 'hasTags')
       ) {
         // Clear out the value if switching between contains or
         // is/oneof for the id or string type
diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx
index b896846fcfe90f325f57c81a2af6cf9534debcb7..c36e331dc95c9dc1fc51232e86ed082666c9bdde 100644
--- a/packages/desktop-client/src/components/modals/EditRule.jsx
+++ b/packages/desktop-client/src/components/modals/EditRule.jsx
@@ -116,7 +116,7 @@ export function OpSelect({
       // TODO: Add matches op support for payees, accounts, categories.
       .filter(op =>
         type === 'id'
-          ? !['contains', 'matches', 'doesNotContain'].includes(op)
+          ? !['contains', 'matches', 'doesNotContain', 'hasTags'].includes(op)
           : true,
       )
       .map(op => [op, formatOp(op, type)]);
diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx
index c0ed8630347958b7d6d8ee91d8e7b332192f90df..df09f6eed2715d15e303e43fc6f9d65a5bf25904 100644
--- a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx
+++ b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx
@@ -155,7 +155,7 @@ export function CustomReport() {
     !!conditions.find(
       ({ field, op }) =>
         field === 'category' &&
-        ['contains', 'doesNotContain', 'matches'].includes(op),
+        ['contains', 'doesNotContain', 'matches', 'hasTags'].includes(op),
     ) || conditions.filter(({ field }) => field === 'category').length >= 2;
 
   const setSelectedCategories = (newCategories: CategoryEntity[]) => {
diff --git a/packages/desktop-client/src/components/transactions/TransactionList.jsx b/packages/desktop-client/src/components/transactions/TransactionList.jsx
index 9b3eb7895fb7293cc8206b3f9b027365965e2372..14fee62c9a5759e61b7733cf31f1f853970bd2fb 100644
--- a/packages/desktop-client/src/components/transactions/TransactionList.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx
@@ -1,8 +1,6 @@
 import React, { useRef, useCallback, useLayoutEffect } from 'react';
 import { useDispatch } from 'react-redux';
 
-import escapeRegExp from 'lodash/escapeRegExp';
-
 import { pushModal } from 'loot-core/client/actions';
 import { send } from 'loot-core/src/platform/client/fetch';
 import {
@@ -191,8 +189,8 @@ export function TransactionList({
   const onNotesTagClick = useCallback(tag => {
     onApplyFilter({
       field: 'notes',
-      op: 'matches',
-      value: `(^|\\s|\\w)${escapeRegExp(tag)}($|\\s|#)`,
+      op: 'hasTags',
+      value: tag,
       type: 'string',
     });
   });
diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts
index 467dc7bc80133d5fa7aef6ff42dfee71e1600d2b..e97fa8ef2f39b67f809f673fa843586ebcccc7fa 100644
--- a/packages/loot-core/src/server/accounts/rules.ts
+++ b/packages/loot-core/src/server/accounts/rules.ts
@@ -147,6 +147,46 @@ const CONDITION_TYPES = {
     },
   },
   string: {
+    ops: [
+      'is',
+      'contains',
+      'matches',
+      'oneOf',
+      'isNot',
+      'doesNotContain',
+      'notOneOf',
+      'hasTags',
+    ],
+    nullable: true,
+    parse(op, value, fieldName) {
+      if (op === 'oneOf' || op === 'notOneOf') {
+        assert(
+          Array.isArray(value),
+          'no-empty-array',
+          `oneOf must have an array value (field: ${fieldName}): ${JSON.stringify(
+            value,
+          )}`,
+        );
+        return value.filter(Boolean).map(val => val.toLowerCase());
+      }
+
+      if (
+        op === 'contains' ||
+        op === 'matches' ||
+        op === 'doesNotContain' ||
+        op === 'hasTags'
+      ) {
+        assert(
+          typeof value === 'string' && value.length > 0,
+          'no-empty-string',
+          `contains must have non-empty string (field: ${fieldName})`,
+        );
+      }
+
+      return value.toLowerCase();
+    },
+  },
+  imported_payee: {
     ops: [
       'is',
       'contains',
@@ -379,6 +419,13 @@ export class Condition {
           return false;
         }
         return this.value.indexOf(fieldValue) !== -1;
+
+      case 'hasTags':
+        if (fieldValue === null) {
+          return false;
+        }
+        return fieldValue.indexOf(this.value) !== -1;
+
       case 'notOneOf':
         if (fieldValue === null) {
           return false;
@@ -863,6 +910,7 @@ const OP_SCORES: Record<RuleConditionEntity['op'], number> = {
   contains: 0,
   doesNotContain: 0,
   matches: 0,
+  hasTags: 0,
 };
 
 function computeScore(rule) {
diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts
index 66024acf8d0ca1166e6f7bd94b552d2c6997423e..e22697ea40789e75e851cdee394f197ebb051ad4 100644
--- a/packages/loot-core/src/server/accounts/transaction-rules.ts
+++ b/packages/loot-core/src/server/accounts/transaction-rules.ts
@@ -472,6 +472,19 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
           return { id: null };
         }
         return { $or: values.map(v => apply(field, '$eq', v)) };
+
+      case 'hasTags':
+        const tagValues = value
+          .split(/(?<!#)(#[\w\d\p{Emoji}-]+)(?=\s|$)/gu)
+          .filter(tag => tag.startsWith('#'));
+
+        return {
+          $and: tagValues.map(v => {
+            const regex = new RegExp(`(^|\\s)${v}(\\s|$)`);
+            return apply(field, '$regexp', regex.source);
+          }),
+        };
+
       case 'notOneOf':
         const notValues = value;
         if (notValues.length === 0) {
diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts
index b920d1b0cb849072a2bab293285bd07e498fe775..76eaa2926054b643cc5a5139bbb071a0f4427f02 100644
--- a/packages/loot-core/src/shared/rules.ts
+++ b/packages/loot-core/src/shared/rules.ts
@@ -17,6 +17,7 @@ export const TYPE_INFO = {
       'isNot',
       'doesNotContain',
       'notOneOf',
+      'hasTags',
     ],
     nullable: true,
   },
@@ -25,6 +26,19 @@ export const TYPE_INFO = {
     nullable: false,
   },
   string: {
+    ops: [
+      'is',
+      'contains',
+      'matches',
+      'oneOf',
+      'isNot',
+      'doesNotContain',
+      'notOneOf',
+      'hasTags',
+    ],
+    nullable: true,
+  },
+  imported_payee: {
     ops: [
       'is',
       'contains',
@@ -48,7 +62,7 @@ export const TYPE_INFO = {
 
 export const FIELD_TYPES = new Map(
   Object.entries({
-    imported_payee: 'string',
+    imported_payee: 'imported_payee',
     payee: 'id',
     date: 'date',
     notes: 'string',
@@ -107,6 +121,8 @@ export function friendlyOp(op, type?) {
       return 'is between';
     case 'contains':
       return 'contains';
+    case 'hasTags':
+      return 'has tag(s)';
     case 'matches':
       return 'matches';
     case 'doesNotContain':
diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts
index 3c61ebf70a39d662eedea9f8de491348377c40a0..bacb94ca5520dbe5a83bba02e9cfc80c94878f64 100644
--- a/packages/loot-core/src/types/models/rule.d.ts
+++ b/packages/loot-core/src/types/models/rule.d.ts
@@ -25,6 +25,7 @@ export type RuleConditionOp =
   | 'lte'
   | 'contains'
   | 'doesNotContain'
+  | 'hasTags'
   | 'matches';
 
 type FieldValueTypes = {
@@ -96,6 +97,7 @@ export type RuleConditionEntity =
       | 'contains'
       | 'doesNotContain'
       | 'matches'
+      | 'hasTags'
     >
   | BaseConditionEntity<
       'payee',
diff --git a/upcoming-release-notes/3290.md b/upcoming-release-notes/3290.md
new file mode 100644
index 0000000000000000000000000000000000000000..5cbcce408cdfc1ea20ee20fe09f22ef79adb897a
--- /dev/null
+++ b/upcoming-release-notes/3290.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [lelemm]
+---
+
+Add new 'has tag(s)' filter to filter note tags.