Fixes #5815 - Allow usage of extracted text from regex

This commit is contained in:
Moritz Klasen 2026-03-09 09:56:07 +01:00 committed by Mantas Masalskis
parent d09a993145
commit 332b0d8c72
14 changed files with 510 additions and 321 deletions

View file

@ -263,7 +263,6 @@ Metrics/CyclomaticComplexity:
- 'app/models/channel/email_parser.rb'
- 'app/models/channel/filter/auto_response_check.rb'
- 'app/models/channel/filter/bounce_delivery_permanent_failed.rb'
- 'app/models/channel/filter/database.rb'
- 'app/models/channel/filter/follow_up_check.rb'
- 'app/models/channel/filter/identify_sender.rb'
- 'app/models/channel/filter/monitoring_base.rb'
@ -299,6 +298,7 @@ Metrics/CyclomaticComplexity:
- 'lib/external_credential/microsoft_base.rb'
- 'lib/facebook.rb'
- 'lib/fill_db.rb'
- 'lib/filter_processor.rb'
- 'lib/models.rb'
- 'lib/notification_factory/mailer.rb'
- 'lib/notification_factory/renderer.rb'

View file

@ -1,141 +1,11 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
# process all database filter
module Channel::Filter::Database # rubocop:disable Metrics/ModuleLength
OPERATORS_WITH_MULTIPLE_VALUES = [
'is any of',
'is none of',
'starts with one of',
'ends with one of',
].freeze
module Channel::Filter::Database
def self.run(_channel, mail, _transaction_params)
PostmasterFilter.where(active: true, channel: 'email').reorder(:name, :created_at).each do |filter|
Rails.logger.debug { " process filter #{filter.name} ..." }
perform_filter_changes(mail, filter) if filter_matches?(mail, filter)
FilterProcessor.new(filter, mail).process
end
end
def self.filter_matches?(mail, filter)
min_one_rule_exists = false
filter[:match].each do |key, meta|
next if meta.blank? || meta['value'].blank?
value = mail[ key.downcase.to_sym ]
match_rule = meta['value']
min_one_rule_exists = true
operator = meta[:operator]
human_match_rule = match_rule
if OPERATORS_WITH_MULTIPLE_VALUES.include?(operator) && !match_rule.instance_of?(Array)
match_rule = [match_rule]
human_match_rule = match_rule.join(', ')
end
if !rule_matches?(operator, match_rule, value)
Rails.logger.debug { " not matching: key '#{key.downcase}' #{operator} '#{human_match_rule}'" }
return false
end
Rails.logger.info { " matching: key '#{key.downcase}' #{operator} '#{human_match_rule}'" }
rescue => e
Rails.logger.error "can't use match rule '#{human_match_rule}' on '#{value}'"
Rails.logger.error e.inspect
return false
end
min_one_rule_exists
end
def self.rule_matches?(operator, match_rule, value)
case operator
when 'contains not'
value.blank? || !Channel::Filter::Match::Contains.match(value: value, match_rule: match_rule)
when 'contains'
value.present? && Channel::Filter::Match::Contains.match(value: value, match_rule: match_rule)
when 'is any of'
match_rule.any?(value)
when 'is none of'
match_rule.none?(value)
when 'starts with one of'
match_rule.any? { |rule_value| value.downcase.start_with? rule_value.downcase }
when 'ends with one of'
match_rule.any? { |rule_value| value.downcase.end_with? rule_value.downcase }
when 'matches regex'
value.present? && Channel::Filter::Match::EmailRegex.match(value: value, match_rule: match_rule)
when 'does not match regex'
value.blank? || !Channel::Filter::Match::EmailRegex.match(value: value, match_rule: match_rule)
else
Rails.logger.info { " Invalid operator in match #{meta.inspect}" }
false
end
end
def self.perform_filter_changes(mail, filter)
filter[:perform].each do |key, meta|
next if !Channel::EmailParser.check_attributes_by_x_headers(key, meta['value'])
Rails.logger.debug { " perform '#{key.downcase}' = '#{meta.inspect}'" }
next if perform_filter_changes_tags(mail: mail, filter: filter, key: key, meta: meta)
next if perform_filter_changes_date(mail: mail, filter: filter, key: key, meta: meta)
mail[ key.downcase.to_sym ] = meta['value']
mail[:"#{key.downcase}-source"] = filter
end
end
def self.perform_filter_changes_tags(mail:, filter:, key:, meta:)
return if %w[x-zammad-ticket-tags x-zammad-ticket-followup-tags].exclude?(key.downcase)
mail_header_key = key.downcase.to_sym
current_tags = mail[mail_header_key].to_s.split(',').map(&:strip).compact_blank
change_tags = meta['value'].split(',').map(&:strip).compact_blank
case meta['operator']
when 'add'
change_tags.each do |tag|
current_tags |= [tag]
mail[:"#{key.downcase}-source"] = filter
end
when 'remove'
change_tags.each do |tag|
current_tags -= [tag]
mail[:"#{key.downcase}-source"] = filter
end
end
mail[mail_header_key] = current_tags.join(',')
true
end
def self.perform_filter_changes_date(mail:, filter:, key:, meta:)
return if key !~ %r{x-zammad-ticket-(?:followup-)?(.*)}
object_attribute = ObjectManager::Attribute.for_object('Ticket').find_by(name: $1, data_type: %w[datetime date])
return if object_attribute.blank?
new_value = if meta['operator'] == 'relative'
TimeRangeHelper.relative(range: meta['range'], value: meta['value'])
else
meta['value']
end
if new_value
mail[ key.downcase.to_sym ] = if object_attribute[:data_type] == 'datetime'
new_value.to_datetime
else
new_value.to_date
end
mail[:"#{key.downcase}-source"] = filter
end
true
end
end

View file

@ -1,7 +1,6 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
module Channel::Filter::Match::Contains
def self.match(value:, match_rule:)
match_rule_quoted = Regexp.quote(match_rule).gsub(%r{\\\*}, '.*')

View file

@ -3,7 +3,7 @@
module Channel::Filter::Match::EmailRegex
def self.match(value:, match_rule:, check_mode: false)
begin
return value.match?(%r{#{match_rule}}i)
return value.match(%r{#{match_rule}}i)
rescue => e
message = "Can't use regex '#{match_rule}' on '#{value}': #{e.message}"
Rails.logger.error message

View file

@ -0,0 +1,7 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
module Channel::Filter::Match::EndsWith
def self.match(value:, match_rule:)
match_rule.any? { |rule_value| value.downcase.end_with? rule_value.downcase }
end
end

View file

@ -0,0 +1,7 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
module Channel::Filter::Match::IsAnyOf
def self.match(value:, match_rule:)
match_rule.any?(value)
end
end

View file

@ -0,0 +1,7 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
module Channel::Filter::Match::StartsWith
def self.match(value:, match_rule:)
match_rule.any? { |rule_value| value.downcase.start_with?(rule_value.downcase) }
end
end

171
lib/filter_processor.rb Normal file
View file

@ -0,0 +1,171 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class FilterProcessor
OPERATORS_WITH_MULTIPLE_VALUES = [
'is any of',
'is none of',
'starts with one of',
'ends with one of',
].freeze
attr_reader :filter, :mail, :match_data
def initialize(filter, mail)
@filter = filter
@mail = mail
@match_data = {}
end
def process
return if !filter_matches?
perform_filter_changes
end
def filter_matches?
min_one_rule_exists = false
@filter[:match].each do |key, meta|
next if meta.blank? || meta['value'].blank?
value = @mail[ key.downcase.to_sym ]
match_rule = meta['value']
min_one_rule_exists = true
operator = meta[:operator]
human_match_rule = match_rule
if OPERATORS_WITH_MULTIPLE_VALUES.include?(operator) && !match_rule.instance_of?(Array)
match_rule = [match_rule]
human_match_rule = match_rule.join(', ')
end
if !rule_matches?(operator, match_rule, value)
Rails.logger.debug { " not matching: key '#{key.downcase}' #{operator} '#{human_match_rule}'" }
return false
end
Rails.logger.info { " matching: key '#{key.downcase}' #{operator} '#{human_match_rule}'" }
rescue => e
Rails.logger.error "can't use match rule '#{human_match_rule}' on '#{value}'"
Rails.logger.error e.inspect
return false
end
min_one_rule_exists
end
def rule_matches?(operator, match_rule, value)
case operator
when 'contains not'
value.blank? || !Channel::Filter::Match::Contains.match(value: value, match_rule: match_rule)
when 'contains'
value.present? && Channel::Filter::Match::Contains.match(value: value, match_rule: match_rule)
when 'is any of'
Channel::Filter::Match::IsAnyOf.match(value: value, match_rule: match_rule)
when 'is none of'
!Channel::Filter::Match::IsAnyOf.match(value: value, match_rule: match_rule)
when 'starts with one of'
Channel::Filter::Match::StartsWith.match(value: value, match_rule: match_rule)
when 'ends with one of'
Channel::Filter::Match::EndsWith.match(value: value, match_rule: match_rule)
when 'matches regex'
return false if value.blank?
match_data = Channel::Filter::Match::EmailRegex.match(value: value, match_rule: match_rule)
if match_data.respond_to?(:names)
match_data.names.each do |key|
@match_data[key] = match_data[key]
end
end
match_data
when 'does not match regex'
value.blank? || !Channel::Filter::Match::EmailRegex.match(value: value, match_rule: match_rule)
else
Rails.logger.info { " Invalid operator in match #{meta.inspect}" }
false
end
end
def perform_filter_changes
@filter[:perform].each do |key, meta|
next if !Channel::EmailParser.check_attributes_by_x_headers(key, meta['value'])
Rails.logger.debug { " perform '#{key.downcase}' = '#{meta.inspect}'" }
next if perform_filter_changes_tags(key: key, meta: meta)
next if perform_filter_changes_date(key: key, meta: meta)
perform_filter_changes_regular(key: key, meta: meta)
end
end
def perform_filter_changes_general(key:, meta:)
@mail[ key.downcase.to_sym ] = meta['value']
@mail[:"#{key.downcase}-source"] = @filter
end
def perform_filter_changes_tags(key:, meta:)
return if %w[x-zammad-ticket-tags x-zammad-ticket-followup-tags].exclude?(key.downcase)
mail_header_key = key.downcase.to_sym
current_tags = @mail[mail_header_key].to_s.split(',').map(&:strip).compact_blank
change_tags = meta['value'].split(',').map(&:strip).compact_blank
case meta['operator']
when 'add'
change_tags.each do |tag|
current_tags |= [tag]
@mail[:"#{key.downcase}-source"] = @filter
end
when 'remove'
change_tags.each do |tag|
current_tags -= [tag]
@mail[:"#{key.downcase}-source"] = @filter
end
end
@mail[mail_header_key] = current_tags.join(',')
true
end
def perform_filter_changes_regular(key:, meta:)
value = meta['value']
if value.respond_to?(:gsub)
value.gsub!(%r{#\{(.+?)\}}) do
@match_data[$1] || ''
end
end
@mail[ key.downcase.to_sym ] = value
@mail[:"#{key.downcase}-source"] = @filter
end
def perform_filter_changes_date(key:, meta:)
return if key !~ %r{x-zammad-ticket-(?:followup-)?(.*)}
object_attribute = ObjectManager::Attribute.for_object('Ticket').find_by(name: $1, data_type: %w[datetime date])
return if object_attribute.blank?
new_value = if meta['operator'] == 'relative'
TimeRangeHelper.relative(range: meta['range'], value: meta['value'])
else
meta['value']
end
if new_value
@mail[ key.downcase.to_sym ] = if object_attribute[:data_type] == 'datetime'
new_value.to_datetime
else
new_value.to_date
end
@mail[:"#{key.downcase}-source"] = @filter
end
true
end
end

View file

@ -0,0 +1,193 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe FilterProcessor, type: :channel_filter do
let(:mail_hash) { Channel::EmailParser.new.parse(<<~RAW.chomp) }
From: daffy.duck@acme.corp
To: batman@marvell.com
Subject: Anvil
I can haz anvil!
RAW
describe '.filter_matches?' do
let(:filter) { create(:postmaster_filter, match: { 'from' => { 'operator' => operator, 'value' => value } }) }
shared_examples 'the filter matches' do
it 'matches' do
expect(described_class.new(filter, mail_hash).filter_matches?).to be true
end
end
shared_examples 'the filter does not match' do
it 'matches' do
expect(described_class.new(filter, mail_hash).filter_matches?).to be false
end
end
context "with operator 'contains'" do
let(:operator) { 'contains' }
context 'with matching string' do
let(:value) { 'a' }
include_examples 'the filter matches'
end
context 'with matching upcased string' do
let(:value) { 'A' }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { 'x' }
include_examples 'the filter does not match'
end
end
context "with operator 'contains not'" do
let(:operator) { 'contains not' }
context 'with matching string' do
let(:value) { 'a' }
include_examples 'the filter does not match'
end
context 'with matching upcased string' do
let(:value) { 'A' }
include_examples 'the filter does not match'
end
context 'with non-matching string' do
let(:value) { 'x' }
include_examples 'the filter matches'
end
end
context "with operator 'matches regex'" do
let(:operator) { 'matches regex' }
context 'with matching string' do
let(:value) { 'daffy.duck@.*' }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { 'daffy.duck.+@' }
include_examples 'the filter does not match'
end
end
context "with operator 'does not match regex'" do
let(:operator) { 'does not match regex' }
context 'with matching string' do
let(:value) { 'daffy.duck@.*' }
include_examples 'the filter does not match'
end
context 'with non-matching string' do
let(:value) { 'daffy.duck.+@' }
include_examples 'the filter matches'
end
end
context "with operator 'is any of'" do
let(:operator) { 'is any of' }
context 'with matching string' do
let(:value) { ['daffy.duck@acme.corp', 'elmer.fudd@acme.corp'] }
include_examples 'the filter matches'
end
context 'with matching upcased string' do
let(:value) { ['Daffy.Duck@acme.corp', 'Elmer.Fudd@acme.corp'] }
include_examples 'the filter does not match'
end
context 'with non-matching string' do
let(:value) { ['other.address@example.com', 'mail@example.com'] }
include_examples 'the filter does not match'
end
end
context "with operator 'is none of'" do
let(:operator) { 'is none of' }
context 'with matching string' do
let(:value) { ['daffy.duck@acme.corp', 'elmer.fudd@acme.corp'] }
include_examples 'the filter does not match'
end
context 'with matching upcased string' do
let(:value) { ['Daffy.Duck@acme.corp', 'Elmer.Fudd@acme.corp'] }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { ['other.address@example.com', 'mail@example.com'] }
include_examples 'the filter matches'
end
end
context "with operator 'starts with one of'" do
let(:operator) { 'starts with one of' }
context 'with matching string' do
let(:value) { ['daffy.duck', 'elmer.fudd'] }
include_examples 'the filter matches'
end
context 'with matching upcased string' do
let(:value) { ['Daffy.Duck', 'Elmer.Fudd'] }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { ['other.address', 'zammad.org'] }
include_examples 'the filter does not match'
end
end
context "with operator 'ends with one of'" do
let(:operator) { 'ends with one of' }
context 'with matching string' do
let(:value) { ['acme.corp', 'example.com'] }
include_examples 'the filter matches'
end
context 'with matching upcased string' do
let(:value) { ['ACME.corp', 'EXAMPLE.com'] }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { ['example.com', 'zammad.org'] }
include_examples 'the filter does not match'
end
end
end
end

View file

@ -11,186 +11,6 @@ RSpec.describe Channel::Filter::Database, type: :channel_filter do
I can haz anvil!
RAW
describe '.filter_matches?' do
let(:filter) { create(:postmaster_filter, match: { 'from' => { 'operator' => operator, 'value' => value } }) }
shared_examples 'the filter matches' do
it 'matches' do
expect(described_class.filter_matches?(mail_hash, filter)).to be true
end
end
shared_examples 'the filter does not match' do
it 'matches' do
expect(described_class.filter_matches?(mail_hash, filter)).to be false
end
end
context "with operator 'contains'" do
let(:operator) { 'contains' }
context 'with matching string' do
let(:value) { 'a' }
include_examples 'the filter matches'
end
context 'with matching upcased string' do
let(:value) { 'A' }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { 'x' }
include_examples 'the filter does not match'
end
end
context "with operator 'contains not'" do
let(:operator) { 'contains not' }
context 'with matching string' do
let(:value) { 'a' }
include_examples 'the filter does not match'
end
context 'with matching upcased string' do
let(:value) { 'A' }
include_examples 'the filter does not match'
end
context 'with non-matching string' do
let(:value) { 'x' }
include_examples 'the filter matches'
end
end
context "with operator 'matches regex'" do
let(:operator) { 'matches regex' }
context 'with matching string' do
let(:value) { 'daffy.duck@.*' }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { 'daffy.duck.+@' }
include_examples 'the filter does not match'
end
end
context "with operator 'does not match regex'" do
let(:operator) { 'does not match regex' }
context 'with matching string' do
let(:value) { 'daffy.duck@.*' }
include_examples 'the filter does not match'
end
context 'with non-matching string' do
let(:value) { 'daffy.duck.+@' }
include_examples 'the filter matches'
end
end
context "with operator 'is any of'" do
let(:operator) { 'is any of' }
context 'with matching string' do
let(:value) { ['daffy.duck@acme.corp', 'elmer.fudd@acme.corp'] }
include_examples 'the filter matches'
end
context 'with matching upcased string' do
let(:value) { ['Daffy.Duck@acme.corp', 'Elmer.Fudd@acme.corp'] }
include_examples 'the filter does not match'
end
context 'with non-matching string' do
let(:value) { ['other.address@example.com', 'mail@example.com'] }
include_examples 'the filter does not match'
end
end
context "with operator 'is none of'" do
let(:operator) { 'is none of' }
context 'with matching string' do
let(:value) { ['daffy.duck@acme.corp', 'elmer.fudd@acme.corp'] }
include_examples 'the filter does not match'
end
context 'with matching upcased string' do
let(:value) { ['Daffy.Duck@acme.corp', 'Elmer.Fudd@acme.corp'] }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { ['other.address@example.com', 'mail@example.com'] }
include_examples 'the filter matches'
end
end
context "with operator 'starts with one of'" do
let(:operator) { 'starts with one of' }
context 'with matching string' do
let(:value) { ['daffy.duck', 'elmer.fudd'] }
include_examples 'the filter matches'
end
context 'with matching upcased string' do
let(:value) { ['Daffy.Duck', 'Elmer.Fudd'] }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { ['other.address', 'zammad.org'] }
include_examples 'the filter does not match'
end
end
context "with operator 'ends with one of'" do
let(:operator) { 'ends with one of' }
context 'with matching string' do
let(:value) { ['acme.corp', 'example.com'] }
include_examples 'the filter matches'
end
context 'with matching upcased string' do
let(:value) { ['ACME.corp', 'EXAMPLE.com'] }
include_examples 'the filter matches'
end
context 'with non-matching string' do
let(:value) { ['example.com', 'zammad.org'] }
include_examples 'the filter does not match'
end
end
end
describe 'Cannot set date for pending close status in postmaster filter #4206', db_strategy: :reset do
before do
freeze_time
@ -360,4 +180,32 @@ RSpec.describe Channel::Filter::Database, type: :channel_filter do
end
end
end
describe 'postmaster filter with regex and named capture', db_strategy: :reset do
context 'when the filter uses a named capture group' do
let(:mail_hash) { Channel::EmailParser.new.parse(<<~RAW.chomp) }
From: daffy.duck@acme.corp
To: batman@marvell.com
Subject: Anvil
Customers Formular Message is:
ContractID: 1234abcd
CustomerEmail: customer@example.com
RAW
before do
create(:postmaster_filter,
match: { 'body' => { 'operator' => 'matches regex', 'value' => 'ContractID:\s(?<CONTRACT_ID>.*)' } },
perform: { 'x-zammad-ticket-title' => { 'value' => '#{CONTRACT_ID}' } }, # rubocop:disable Lint/InterpolationCheck
channel: 'email',
active: true)
end
it 'sets the ticket title using the captured CONTRACT_ID' do
described_class.run({}, mail_hash, {})
expect(mail_hash[:'x-zammad-ticket-title']).to eq('1234abcd')
end
end
end
end

View file

@ -14,19 +14,19 @@ RSpec.describe Channel::Filter::Match::EmailRegex do
context 'with empty string' do
let(:sender) { '' }
it { is_expected.to be(true) }
it { is_expected.to be_a(MatchData) }
end
context 'and matching regex' do
let(:sender) { 'foobar@.*' }
it { is_expected.to be(true) }
it { is_expected.to be_a(MatchData) }
end
context 'and non-matching regex' do
let(:sender) { 'nagios@.*' }
it { is_expected.to be(false) }
it { is_expected.to be_nil }
end
context 'and invalid regex (misused ? repeat operator)' do
@ -54,19 +54,19 @@ RSpec.describe Channel::Filter::Match::EmailRegex do
context 'with empty string' do
let(:sender) { '' }
it { is_expected.to be(true) }
it { is_expected.to be_a(MatchData) }
end
context 'and matching regex' do
let(:sender) { 'foobar@.*' }
it { is_expected.to be(true) }
it { is_expected.to be_a(MatchData) }
end
context 'and non-matching regex' do
let(:sender) { 'nagios@.*' }
it { is_expected.to be(false) }
it { is_expected.to be_nil }
end
context 'and invalid regex (misused ? repeat operator)' do

View file

@ -0,0 +1,29 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Channel::Filter::Match::EndsWith do
describe '.match' do
subject(:match) { described_class.match(value: from, match_rule: rules) }
let(:from) { 'foobar@foo.bar' }
context 'when the value ends with a matching rule' do
let(:rules) { ['bar'] }
it { is_expected.to be(true) }
end
context 'with a correct beginning but upper letter matching single rule' do
let(:rules) { ['Bar'] }
it { is_expected.to be(true) }
end
context 'when the value does not end with any rule' do
let(:rules) { ['far'] }
it { is_expected.to be(false) }
end
end
end

View file

@ -0,0 +1,29 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Channel::Filter::Match::IsAnyOf do
describe '.match' do
subject(:match) { described_class.match(value: subject_value, match_rule: subject_rule) }
let(:subject_value) { 'Subject1' }
context 'when the rule includes the exact same subject' do
let(:subject_rule) { %w[Subject1 Subject2] }
it { is_expected.to be(true) }
end
context 'when the rule does not include the subject' do
let(:subject_rule) { %w[Subject01 Subject02] }
it { is_expected.to be(false) }
end
context 'when the rule includes a case-insensitive match' do
let(:subject_rule) { %w[subject1 subject2] }
it { is_expected.to be(false) }
end
end
end

View file

@ -0,0 +1,29 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Channel::Filter::Match::StartsWith do
describe '.match' do
subject(:match) { described_class.match(value: from, match_rule: rules) }
let(:from) { 'foobar@foo.bar' }
context 'when the value starts with a matching rule' do
let(:rules) { ['foo'] }
it { is_expected.to be(true) }
end
context 'when the value starts with a matching rule in a different case' do
let(:rules) { ['Foo'] }
it { is_expected.to be(true) }
end
context 'when the value does not start with any rule' do
let(:rules) { ['doo'] }
it { is_expected.to be(false) }
end
end
end