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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +78 -0
- data/Rakefile +12 -0
- data/exe/mogura +7 -0
- data/lib/imap_mogura/cli.rb +264 -0
- data/lib/imap_mogura/config_parser/errors.rb +8 -0
- data/lib/imap_mogura/config_parser.rb +25 -0
- data/lib/imap_mogura/imap_handler.rb +154 -0
- data/lib/imap_mogura/rules_parser/errors.rb +8 -0
- data/lib/imap_mogura/rules_parser/rule_elements.rb +111 -0
- data/lib/imap_mogura/rules_parser/rule_set.rb +30 -0
- data/lib/imap_mogura/rules_parser.rb +74 -0
- data/lib/imap_mogura/version.rb +5 -0
- data/lib/imap_mogura.rb +7 -0
- data/sig/imap_mogura.rbs +4 -0
- metadata +119 -0
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
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
data/exe/mogura
ADDED
@@ -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,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,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
|
data/lib/imap_mogura.rb
ADDED
data/sig/imap_mogura.rbs
ADDED
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: []
|