imap_mogura 0.1.1.pre.dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []