imap_mogura 0.1.1.pre.dev

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 63948c26de8c10fedd38588189779fd6eeee167f3ab8fe54be42ab3ae37eac76
4
+ data.tar.gz: 1b86c8501895ea103f4627a34cb3057eda23f42d0722aeceb2d6ea1630ed7472
5
+ SHA512:
6
+ metadata.gz: 902ce048023623971dd7c26e5512888eea1275e1160895f8fa0d6a36d91386f76e8f5d3f3a3b2cef42709b0d76d77bd08cf8c4381ea03d4dbaa62a9691b25b82
7
+ data.tar.gz: 72055ee14145ec3a81ccfe02f50bc903f060368e933fbce27e63f0d45ffdd5f0ec1829ffa054ec4f0179d77bec9f4948c4dc372d408943f1332f7973b81e12ef
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,21 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+ NewCops: enable
4
+
5
+ Gemspec/RequireMFA:
6
+ Enabled: false
7
+
8
+ Layout/LineLength:
9
+ Enabled: false
10
+
11
+ Metrics:
12
+ Enabled: false
13
+
14
+ Style/Documentation:
15
+ Enabled: false
16
+
17
+ Style/StringLiterals:
18
+ EnforcedStyle: double_quotes
19
+
20
+ Style/StringLiteralsInInterpolation:
21
+ EnforcedStyle: double_quotes
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 ysk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # ImapMogura
2
+
3
+ A mail filtering tool for IMAP.
4
+
5
+ ## Installation
6
+
7
+ Check out this repository first and enter the directory.
8
+
9
+ ```console
10
+ $ git clone https://github.com/yskuniv/imap_mogura.git
11
+ $ cd imap_mogura/
12
+ ```
13
+
14
+ Install this gem and add to the application's Gemfile by executing:
15
+
16
+ $ bundle install
17
+
18
+ ## Usage
19
+
20
+ Create `rules.yml` and write rules like following.
21
+
22
+ ```yaml
23
+ rules:
24
+ - destination: test
25
+ rule:
26
+ and:
27
+ - subject: "^\\[TEST "
28
+ - or:
29
+ - sender: "test@example\\.com"
30
+ - x-test: "X-TEST"
31
+
32
+ - destination: bar
33
+ rule:
34
+ from: "no-reply@bar\\.example\\.com"
35
+
36
+ - destination: Trash
37
+ rule:
38
+ subject: "i'm trash-like email!!"
39
+ ```
40
+
41
+ As following, `start` command will start monitoring RECENT mails on "INBOX". If a mail is coming
42
+ and it's RECENT, it will be filtered.
43
+
44
+ ```console
45
+ $ mogura start <host> -u <user> --password-base64=<password-base64-encoded> -c rules.yml -b INBOX
46
+ ```
47
+
48
+ You can specify a mailbox which to be monitored by `-b` option.
49
+
50
+ If you want to just filter mails on a specific mailbox, run the `filter` command as following.
51
+
52
+ ```console
53
+ $ mogura filter <host> -u <user> --password-base64=<password-base64-encoded> -c rules.yml -b <mailbox>
54
+ ```
55
+
56
+ You can check your config by `check-config` command. It returns just OK if no errors in the config.
57
+
58
+ ```console
59
+ $ mogura check-config -c rules.yml
60
+ OK
61
+ $
62
+ ```
63
+
64
+ About more features, see `--help`.
65
+
66
+ ## Development
67
+
68
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
69
+
70
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
71
+
72
+ ## Contributing
73
+
74
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yskuniv/imap_mogura.
75
+
76
+ ## License
77
+
78
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/exe/mogura ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "imap_mogura"
6
+
7
+ ImapMogura::CLI.start(ARGV)
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "base64"
5
+
6
+ module ImapMogura
7
+ class CustomOptionError < Thor::Error
8
+ def initialize(msg = "Custom option error message")
9
+ super
10
+ end
11
+ end
12
+
13
+ class CLI < Thor
14
+ class << self
15
+ def exit_on_failure?
16
+ true
17
+ end
18
+ end
19
+
20
+ desc "start HOST", "connect to HOST and start watching"
21
+ option :port, type: :numeric, default: 143, aliases: :p
22
+ option :starttls, type: :boolean, default: true
23
+ option :use_ssl, type: :boolean, default: false
24
+ option :auth_type, type: :string, default: "LOGIN"
25
+ option :user, type: :string, aliases: :u
26
+ option :password_base64, type: :string
27
+ option :config, type: :string, aliases: :c, required: true
28
+ option :target_mailbox, type: :string, aliases: :b, required: true
29
+ option :filter_unseen, type: :boolean, default: true
30
+ option :create_directory, type: :boolean, default: true
31
+ option :dry_run, type: :boolean, default: false
32
+ def start(host)
33
+ port = options[:port]
34
+ starttls = options[:starttls]
35
+ use_ssl = options[:use_ssl]
36
+ auth_type = options[:auth_type] if use_ssl
37
+ user = options[:user]
38
+ password = Base64.decode64(options[:password_base64])
39
+ config_name = options[:config]
40
+ target_mailbox = options[:target_mailbox]
41
+ filter_unseen = options[:filter_unseen]
42
+ create_directory = options[:create_directory]
43
+ dry_run = options[:dry_run]
44
+
45
+ monitored_events = ["RECENT"]
46
+ search_keys = ["RECENT", *(["UNSEEN"] if filter_unseen)]
47
+
48
+ with_all_preparation_ready(config_name, host, port, starttls, use_ssl,
49
+ auth_info: { auth_type: auth_type, user: user, password: password },
50
+ create_directory: create_directory,
51
+ dry_run: dry_run) do |imap_handler, rules|
52
+ warn "* start monitoring recent mails in \"#{target_mailbox}\""
53
+
54
+ imap_handler.monitor_events(target_mailbox, monitored_events) do
55
+ imap_handler.find_and_handle_mails(target_mailbox, search_keys) do |message_id|
56
+ warn "mail (id = #{message_id} on \"#{target_mailbox}\") is recent"
57
+
58
+ filter_mail(imap_handler, rules, target_mailbox, message_id, dry_run: dry_run)
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ desc "filter HOST", "filter mails on HOST"
65
+ option :port, type: :numeric, default: 143, aliases: :p
66
+ option :starttls, type: :boolean, default: true
67
+ option :use_ssl, type: :boolean, default: false
68
+ option :auth_type, type: :string, default: "LOGIN"
69
+ option :user, type: :string, aliases: :u
70
+ option :password_base64, type: :string
71
+ option :config, type: :string, aliases: :c, required: true
72
+ option :all_mailbox, type: :boolean, default: false, aliases: :a
73
+ option :exclude_mailboxes, type: :array, default: []
74
+ option :target_mailbox, type: :string, aliases: :b
75
+ option :filter_only_unseen, type: :boolean, default: false
76
+ option :create_directory, type: :boolean, default: true
77
+ option :dry_run, type: :boolean, default: false
78
+ def filter(host)
79
+ port = options[:port]
80
+ starttls = options[:starttls]
81
+ use_ssl = options[:use_ssl]
82
+ auth_type = options[:auth_type] if use_ssl
83
+ user = options[:user]
84
+ password = Base64.decode64(options[:password_base64])
85
+ config_name = options[:config]
86
+ all_mailbox = options[:all_mailbox]
87
+ exclude_mailboxes = options[:exclude_mailboxes]
88
+ target_mailbox = options[:target_mailbox] unless all_mailbox
89
+
90
+ raise CustomOptionError, "--all-mailbox (-a) or --target-mailbox (-b) is required" if !all_mailbox && target_mailbox.nil?
91
+
92
+ filter_only_unseen = options[:filter_only_unseen]
93
+ create_directory = options[:create_directory]
94
+ dry_run = options[:dry_run]
95
+
96
+ search_keys = if filter_only_unseen
97
+ ["UNSEEN"]
98
+ else
99
+ ["ALL"]
100
+ end
101
+
102
+ with_all_preparation_ready(config_name, host, port, starttls, use_ssl,
103
+ auth_info: { auth_type: auth_type, user: user, password: password },
104
+ excluded_mailboxes: exclude_mailboxes,
105
+ create_directory: create_directory,
106
+ dry_run: dry_run) do |imap_handler, rules, options|
107
+ if all_mailbox
108
+ excluded_mailboxes = options[:excluded_mailboxes]
109
+
110
+ imap_handler.all_mailbox_list.reject { |mailbox| excluded_mailboxes.include?(mailbox) }.each do |mailbox|
111
+ filter_mails(imap_handler, rules, mailbox, search_keys, dry_run: dry_run)
112
+ end
113
+ else
114
+ filter_mails(imap_handler, rules, target_mailbox, search_keys, dry_run: dry_run)
115
+ end
116
+ end
117
+ end
118
+
119
+ desc "list HOST", "list mailboxes on HOST"
120
+ option :port, type: :numeric, default: 143, aliases: :p
121
+ option :starttls, type: :boolean, default: true
122
+ option :use_ssl, type: :boolean, default: false
123
+ option :auth_type, type: :string, default: "LOGIN"
124
+ option :user, type: :string, aliases: :u
125
+ option :password_base64, type: :string
126
+ def list(host)
127
+ port = options[:port]
128
+ starttls = options[:starttls]
129
+ use_ssl = options[:use_ssl]
130
+ auth_type = options[:auth_type] if use_ssl
131
+ user = options[:user]
132
+ password = Base64.decode64(options[:password_base64])
133
+
134
+ imap_handler = IMAPHandler.new(host, port,
135
+ starttls: starttls, usessl: use_ssl, certs: nil, verify: true,
136
+ auth_info: { auth_type: auth_type, user: user, password: password })
137
+
138
+ puts imap_handler.all_mailbox_list
139
+
140
+ imap_handler.close
141
+ end
142
+
143
+ desc "check-config", "check config specified by --config / -c"
144
+ option :config, type: :string, aliases: :c, required: true
145
+ def check_config
146
+ config_name = options[:config]
147
+
148
+ load_and_handle_config(config_name)
149
+
150
+ warn "OK"
151
+ end
152
+
153
+ private
154
+
155
+ def with_all_preparation_ready(config_name,
156
+ host, port,
157
+ starttls, use_ssl, certs: nil, verify: true,
158
+ auth_info: nil,
159
+ excluded_mailboxes: [],
160
+ create_directory: true,
161
+ dry_run: false, &block)
162
+ _, rules = load_and_handle_config(config_name)
163
+
164
+ warn "* connecting the server #{host}:#{port}..."
165
+
166
+ imap_handler = IMAPHandler.new(host, port,
167
+ starttls: starttls, usessl: use_ssl, certs: certs, verify: verify,
168
+ auth_info: auth_info)
169
+
170
+ # FIXME: this doesn't work expectedly
171
+ # trap("INT") do
172
+ # imap_handler.close
173
+ # exit
174
+ # end
175
+
176
+ touch_all_mailboxes_in_rules(imap_handler, rules, dry_run: dry_run) if create_directory
177
+
178
+ options = { excluded_mailboxes: excluded_mailboxes }
179
+
180
+ block[imap_handler, rules, options]
181
+
182
+ imap_handler.close
183
+ end
184
+
185
+ def load_and_handle_config(config_name)
186
+ metadata, raw_rules = ConfigParser.parse(config_name)
187
+
188
+ rules = RulesParser.parse(raw_rules)
189
+
190
+ [metadata, rules]
191
+ rescue ConfigParser::ParseError => e
192
+ raise Thor::Error, "Error: failed to parse config: #{e.message}"
193
+ rescue RulesParser::ParseError => e
194
+ raise Thor::Error, "Error: failed to parse rules: #{e.message}"
195
+ end
196
+
197
+ def touch_all_mailboxes_in_rules(imap_handler, rules, dry_run: false)
198
+ rules.each do |rule_set|
199
+ dst_mailbox = rule_set.destination
200
+
201
+ if dry_run
202
+ warn "creation or existence check of mailbox \"#{dst_mailbox}\" is skipped because this is dry run"
203
+ else
204
+ result = imap_handler.touch_mailbox(dst_mailbox)
205
+ warn "mailbox \"#{dst_mailbox}\" is created" if result
206
+ end
207
+ end
208
+ end
209
+
210
+ def filter_mails(imap_handler, rules, mailbox, search_keys = ["ALL"], retry_count = 0, dry_run: false)
211
+ imap_handler.find_and_handle_mails(mailbox, search_keys) do |message_id|
212
+ filter_mail(imap_handler, rules, mailbox, message_id, dry_run: dry_run)
213
+ end
214
+ rescue IMAPHandler::MailFetchError => e
215
+ warn "failed to fetch mail (id = #{e.message_id} on mailbox #{e.mailbox}): #{e.bad_response_error_message}"
216
+
217
+ # if retry_count is over the threshold, terminate processing
218
+ unless retry_count < 3
219
+ warn "retry count is over the threshold, stop processing"
220
+
221
+ return
222
+ end
223
+
224
+ warn "wait a moment..."
225
+
226
+ # wait a moment...
227
+ sleep 10
228
+
229
+ warn "retry filter all mails on #{e.mailbox}"
230
+
231
+ # retry filter all mails itself
232
+ filter_mails(imap_handler, rules, mailbox, search_keys, retry_count + 1, dry_run: dry_run)
233
+ end
234
+
235
+ def filter_mail(imap_handler, rules, mailbox, message_id, dry_run: false)
236
+ mail = imap_handler.fetch_header(mailbox, message_id)
237
+
238
+ warn "# filtering mail on \"#{mailbox}\" of subject \"#{mail.subject}\"..."
239
+
240
+ rules.each do |rule_set|
241
+ dst_mailbox = rule_set.destination
242
+ rule = rule_set.rule
243
+
244
+ next unless rule.match?(mail)
245
+
246
+ warn "the mail matches for the rule of the destination \"#{dst_mailbox}\""
247
+ warn "moving the mail..."
248
+
249
+ if dry_run
250
+ warn "moving skipped because this is dry run"
251
+ else
252
+ result = imap_handler.move(mailbox, message_id, dst_mailbox)
253
+ if result
254
+ warn "moving done"
255
+ else
256
+ warn "moving skipped because the destination is the same with the current mailbox \"#{mailbox}\""
257
+ end
258
+ end
259
+ end
260
+
261
+ imap_handler.close_operation_for_mailbox(mailbox)
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImapMogura
4
+ module ConfigParser
5
+ class Error < StandardError; end
6
+ class ParseError < Error; end
7
+ end
8
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ require_relative "config_parser/errors"
6
+
7
+ module ImapMogura
8
+ module ConfigParser
9
+ class << self
10
+ def parse(config_name)
11
+ config = YAML.safe_load_file(config_name)
12
+
13
+ raise ParseError, "config must be in hash format" unless config.is_a?(Hash)
14
+ raise ParseError, "\"rules:\" must be defined" unless config.key?("rules")
15
+
16
+ metadata = config["metadata"]
17
+ rules = config["rules"]
18
+
19
+ [metadata, rules]
20
+ rescue YAML::SyntaxError => e
21
+ raise ParseError, "failed to parse yaml: #{e.message}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+ require "net/imap"
5
+
6
+ module ImapMogura
7
+ class IMAPHandler
8
+ class Error < StandardError; end
9
+
10
+ class MailFetchError < Error
11
+ def initialize(mailbox, message_id, bad_response_error_message)
12
+ @mailbox = mailbox
13
+ @message_id = message_id
14
+ @bad_response_error_message = bad_response_error_message
15
+
16
+ super("failed to fetch mail: id = #{message_id} on \"#{mailbox}\", message = \"#{bad_response_error_message}\"")
17
+ end
18
+
19
+ attr_reader :mailbox, :message_id, :bad_response_error_message
20
+ end
21
+
22
+ def initialize(host, port = 143, starttls: true, usessl: false, certs: nil, verify: true,
23
+ auth_info: nil)
24
+ @imap = Net::IMAP.new(host, port, usessl, certs, verify)
25
+
26
+ if usessl || starttls
27
+ @imap.starttls(certs, verify) if !usessl && starttls
28
+
29
+ # in case with TLS, just authenticate with LOGIN command
30
+ @imap.login(auth_info[:user], auth_info[:password])
31
+ else
32
+ # in plain text session, use AUTHENTICATE command
33
+ @imap.authenticate(auth_info[:auth_type], auth_info[:user], auth_info[:password])
34
+ end
35
+
36
+ initialize_selected_mailbox
37
+ end
38
+
39
+ def close
40
+ close_mailbox
41
+ @imap.disconnect
42
+ end
43
+
44
+ def monitor_events(mailbox, events, &block)
45
+ loop do
46
+ resp = wait_event_with_idle(mailbox, events)
47
+
48
+ break unless block
49
+
50
+ block[resp]
51
+ end
52
+ end
53
+
54
+ def wait_event_with_idle(mailbox, expected_response_names)
55
+ select_mailbox(mailbox)
56
+
57
+ @imap.idle do |resp|
58
+ if expected_response_names.include? resp.name
59
+ @imap.idle_done
60
+
61
+ return resp
62
+ end
63
+ end
64
+ end
65
+
66
+ def all_mailbox_list
67
+ @imap.list("", "*").map(&:name)
68
+ end
69
+
70
+ def handle_all_mails(mailbox, &block)
71
+ find_and_handle_mails(mailbox, ["ALL"], &block)
72
+ end
73
+
74
+ def find_and_handle_mails(mailbox, search_keys, &block)
75
+ select_mailbox(mailbox)
76
+
77
+ @imap.search(search_keys).each do |message_id|
78
+ break unless block
79
+
80
+ block[message_id]
81
+ end
82
+ end
83
+
84
+ def fetch_envelope(mailbox, message_id)
85
+ with_mailbox_selected(mailbox) do
86
+ @imap.fetch(message_id, "ENVELOPE")[0].attr["ENVELOPE"]
87
+ rescue Net::IMAP::BadResponseError => e
88
+ raise MailFetchError.new(mailbox, message_id, e.message)
89
+ end
90
+ end
91
+
92
+ def fetch_header(mailbox, message_id)
93
+ with_mailbox_selected(mailbox) do
94
+ fetch_data = @imap.fetch(message_id, "BODY.PEEK[HEADER]")[0].attr["BODY[HEADER]"]
95
+
96
+ Mail.read_from_string(fetch_data)
97
+ rescue Net::IMAP::BadResponseError => e
98
+ raise MailFetchError.new(mailbox, message_id, e.message)
99
+ end
100
+ end
101
+
102
+ def touch_mailbox(mailbox)
103
+ @imap.create(mailbox) if @imap.list("", mailbox).empty?
104
+ end
105
+
106
+ def move(src_mailbox, src_message_id, dst_mailbox)
107
+ return if src_mailbox == dst_mailbox # skip moving if src_mailbox is the same with dst_mailbox
108
+
109
+ with_mailbox_selected(src_mailbox, readonly: false) do
110
+ @imap.copy([src_message_id], dst_mailbox)
111
+ @imap.store(src_message_id, "+FLAGS", [:Deleted])
112
+ end
113
+
114
+ dst_mailbox
115
+ end
116
+
117
+ def close_operation_for_mailbox(_)
118
+ close_mailbox
119
+ end
120
+
121
+ private
122
+
123
+ def initialize_selected_mailbox
124
+ @selected_mailbox = nil
125
+ end
126
+
127
+ def with_mailbox_selected(mailbox, readonly: true, &block)
128
+ select_mailbox(mailbox, readonly: readonly)
129
+
130
+ block[]
131
+ end
132
+
133
+ def select_mailbox(mailbox, readonly: true)
134
+ return if @selected_mailbox == [mailbox, readonly]
135
+
136
+ close_mailbox
137
+
138
+ if readonly
139
+ @imap.examine(mailbox)
140
+ else
141
+ @imap.select(mailbox)
142
+ end
143
+
144
+ @selected_mailbox = [mailbox, readonly]
145
+ end
146
+
147
+ def close_mailbox
148
+ return unless @selected_mailbox
149
+
150
+ @imap.close
151
+ @selected_mailbox = nil
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImapMogura
4
+ module RulesParser
5
+ class Error < StandardError; end
6
+ class ParseError < Error; end
7
+ end
8
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImapMogura
4
+ class RuleElement
5
+ def match?(mail)
6
+ raise NotImplementedError
7
+ end
8
+ end
9
+
10
+ class LogicalOperator < RuleElement
11
+ def initialize(operands)
12
+ @operands = operands
13
+
14
+ super()
15
+ end
16
+
17
+ def to_s
18
+ "#<#{self.class} operands=#{@operands}>"
19
+ end
20
+
21
+ attr_reader :operands
22
+ end
23
+
24
+ class AndOperator < LogicalOperator
25
+ def match?(mail)
26
+ @operands.all? { |elm| elm.match?(mail) }
27
+ end
28
+ end
29
+
30
+ class OrOperator < LogicalOperator
31
+ def match?(mail)
32
+ @operands.any? { |elm| elm.match?(mail) }
33
+ end
34
+ end
35
+
36
+ class FieldMatcher < RuleElement
37
+ def initialize(regexp)
38
+ @regexp = /#{regexp}/ # FIXME: need to care about malicious regular expression injection
39
+
40
+ super()
41
+ end
42
+
43
+ attr_reader :regexp
44
+ end
45
+
46
+ class SpecialFieldMatcher < FieldMatcher; end
47
+
48
+ class FromMatcher < SpecialFieldMatcher
49
+ def match?(mail)
50
+ case mail.from
51
+ when Enumerable
52
+ mail.from.any? { |address| address&.match?(@regexp) }
53
+ else
54
+ mail.from&.match?(@regexp)
55
+ end
56
+ end
57
+ end
58
+
59
+ class SenderMatcher < SpecialFieldMatcher
60
+ def match?(mail)
61
+ mail.sender&.match?(@regexp)
62
+ end
63
+ end
64
+
65
+ class ToMatcher < SpecialFieldMatcher
66
+ def match?(mail)
67
+ case mail.to
68
+ when Enumerable
69
+ mail.to.any? { |address| address&.match?(@regexp) }
70
+ else
71
+ mail.to&.match?(@regexp)
72
+ end
73
+ end
74
+ end
75
+
76
+ class CcMatcher < SpecialFieldMatcher
77
+ def match?(mail)
78
+ case mail.cc
79
+ when Enumerable
80
+ mail.cc.any? { |address| address&.match?(@regexp) }
81
+ else
82
+ mail.cc&.match?(@regexp)
83
+ end
84
+ end
85
+ end
86
+
87
+ class SubjectMatcher < SpecialFieldMatcher
88
+ def match?(mail)
89
+ mail.subject&.match?(@regexp)
90
+ end
91
+ end
92
+
93
+ class DateMatcher < SpecialFieldMatcher
94
+ def match?(mail)
95
+ # TODO: impl
96
+ raise NotImplementedError
97
+ end
98
+ end
99
+
100
+ class GeneralFieldMatcher < FieldMatcher
101
+ def initialize(field_name, regexp)
102
+ @field_name = field_name
103
+
104
+ super(regexp)
105
+ end
106
+
107
+ def match?(mail)
108
+ mail.headers[@field_name]&.value&.match?(@regexp)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImapMogura
4
+ RuleSet = Struct.new(:destination, :raw_rule, :parsed_rule) do
5
+ HASH_KEYS = %w[destination rule].freeze # rubocop:disable Lint/ConstantDefinitionInBlock
6
+
7
+ def initialize(hash)
8
+ validate_hash(hash)
9
+
10
+ super(*HASH_KEYS.map { |k| hash[k] }, nil)
11
+ end
12
+
13
+ alias_method :rule, :parsed_rule
14
+
15
+ private
16
+
17
+ def validate_hash(hash)
18
+ HASH_KEYS.each do |k|
19
+ hash.fetch(k)
20
+ rescue KeyError => e
21
+ raise ArgumentError, "given hash doesn't have required key: \"#{e.key}\""
22
+ end
23
+
24
+ return if hash.keys.sort == HASH_KEYS.sort
25
+
26
+ raise ArgumentError,
27
+ "given hash has unknown keys: #{(hash.keys - HASH_KEYS).map { |k| "\"#{k}\"" }.join(", ")}"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "json"
5
+
6
+ require_relative "rules_parser/errors"
7
+ require_relative "rules_parser/rule_set"
8
+ require_relative "rules_parser/rule_elements"
9
+
10
+ module ImapMogura
11
+ module RulesParser
12
+ class << self
13
+ def parse(rules)
14
+ raise ParseError, "rules is required to be just only one array" unless rules.is_a?(Array)
15
+
16
+ rules.map do |item|
17
+ rule_set = RuleSet.new(item)
18
+
19
+ rule_set.parsed_rule = parse_rule(rule_set.raw_rule)
20
+
21
+ rule_set
22
+ rescue ArgumentError
23
+ raise ParseError,
24
+ "keywords \"destination\" and \"rule\" are required but some keywords are not specified or unknown keywords are specified. specified keywords: #{item.keys.map do |k|
25
+ "\"#{k}\""
26
+ end.join(", ")}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def parse_rule(rule)
33
+ unless rule.is_a?(Hash) && rule.keys.count == 1
34
+ raise ParseError,
35
+ "rule should be a hash having only one key, illegal rule: #{JSON.dump(rule)}"
36
+ end
37
+
38
+ k = rule.keys.first
39
+
40
+ case k
41
+ when /^[Aa]nd$/
42
+ rule_list = rule[k]
43
+
44
+ AndOperator.new(parse_rule_list(rule_list))
45
+ when /^[Oo]r$/
46
+ rule_list = rule[k]
47
+
48
+ OrOperator.new(parse_rule_list(rule_list))
49
+ when /^(?<special_field_name>[Ff]rom|[Ss]ender|[Tt]o|[Cc]c|[Ss]ubject|[Dd]ate)$/
50
+ case $LAST_MATCH_INFO[:special_field_name]
51
+ when /^[Ff]rom$/
52
+ FromMatcher
53
+ when /^[Ss]ender$/
54
+ SenderMatcher
55
+ when /^[Tt]o$/
56
+ ToMatcher
57
+ when /^[Cc]c$/
58
+ CcMatcher
59
+ when /^[Ss]ubject$/
60
+ SubjectMatcher
61
+ when /^[Dd]ate$/
62
+ DateMatcher
63
+ end.new(rule[k])
64
+ else
65
+ GeneralFieldMatcher.new(k, rule[k])
66
+ end
67
+ end
68
+
69
+ def parse_rule_list(rule_list)
70
+ rule_list.map { |rule| parse_rule(rule) }
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ImapMogura
4
+ VERSION = "0.1.1.pre.dev"
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "imap_mogura/config_parser"
4
+ require_relative "imap_mogura/imap_handler"
5
+ require_relative "imap_mogura/rules_parser"
6
+ require_relative "imap_mogura/cli"
7
+ require_relative "imap_mogura/version"
@@ -0,0 +1,4 @@
1
+ module ImapMogura
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: imap_mogura
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1.pre.dev
5
+ platform: ruby
6
+ authors:
7
+ - ysk
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-11-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mail
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.8'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.8'
41
+ - !ruby/object:Gem::Dependency
42
+ name: net-imap
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.5'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: thor
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.3'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.3'
69
+ description: A mail filtering tool for IMAP.
70
+ email:
71
+ - ysk.univ.1007@gmail.com
72
+ executables:
73
+ - mogura
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".rspec"
78
+ - ".rubocop.yml"
79
+ - LICENSE.txt
80
+ - README.md
81
+ - Rakefile
82
+ - exe/mogura
83
+ - lib/imap_mogura.rb
84
+ - lib/imap_mogura/cli.rb
85
+ - lib/imap_mogura/config_parser.rb
86
+ - lib/imap_mogura/config_parser/errors.rb
87
+ - lib/imap_mogura/imap_handler.rb
88
+ - lib/imap_mogura/rules_parser.rb
89
+ - lib/imap_mogura/rules_parser/errors.rb
90
+ - lib/imap_mogura/rules_parser/rule_elements.rb
91
+ - lib/imap_mogura/rules_parser/rule_set.rb
92
+ - lib/imap_mogura/version.rb
93
+ - sig/imap_mogura.rbs
94
+ homepage: https://github.com/yskuniv/imap_mogura
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ homepage_uri: https://github.com/yskuniv/imap_mogura
99
+ source_code_uri: https://github.com/yskuniv/mogura
100
+ post_install_message:
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: 3.0.0
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 3.5.22
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: A mail filtering tool for IMAP.
119
+ test_files: []