mournmail 0.1.1 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/README.md +46 -27
- data/lib/mournmail.rb +2 -0
- data/lib/mournmail/commands.rb +7 -4
- data/lib/mournmail/config.rb +2 -4
- data/lib/mournmail/draft_mode.rb +56 -13
- data/lib/mournmail/mail_encoded_word_patch.rb +73 -0
- data/lib/mournmail/message_mode.rb +3 -2
- data/lib/mournmail/message_rendering.rb +22 -15
- data/lib/mournmail/search_result_mode.rb +144 -0
- data/lib/mournmail/summary.rb +63 -8
- data/lib/mournmail/summary_mode.rb +406 -38
- data/lib/mournmail/utils.rb +279 -38
- data/lib/mournmail/version.rb +1 -1
- data/mournmail.gemspec +1 -1
- metadata +6 -5
data/lib/mournmail/utils.rb
CHANGED
@@ -1,11 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "mail"
|
4
|
-
require "mail-iso-2022-jp"
|
5
4
|
require "net/imap"
|
6
5
|
require "time"
|
6
|
+
require "tempfile"
|
7
7
|
require "fileutils"
|
8
8
|
require "timeout"
|
9
|
+
require "digest"
|
10
|
+
require "groonga"
|
9
11
|
|
10
12
|
module Mournmail
|
11
13
|
begin
|
@@ -15,23 +17,37 @@ module Mournmail
|
|
15
17
|
HAVE_MAIL_GPG = false
|
16
18
|
end
|
17
19
|
|
18
|
-
def self.define_variable(name,
|
20
|
+
def self.define_variable(name, initial_value: nil, attr: nil)
|
19
21
|
var_name = "@" + name.to_s
|
20
22
|
if !instance_variable_defined?(var_name)
|
21
|
-
instance_variable_set(var_name,
|
23
|
+
instance_variable_set(var_name, initial_value)
|
24
|
+
end
|
25
|
+
case attr
|
26
|
+
when :accessor
|
27
|
+
singleton_class.send(:attr_accessor, name)
|
28
|
+
when :reader
|
29
|
+
singleton_class.send(:attr_reader, name)
|
30
|
+
when :writer
|
31
|
+
singleton_class.send(:attr_writer, name)
|
22
32
|
end
|
23
|
-
singleton_class.send(:attr_accessor, name)
|
24
33
|
end
|
25
34
|
|
26
|
-
define_variable :current_mailbox
|
27
|
-
define_variable :current_summary
|
28
|
-
define_variable :current_uid
|
29
|
-
define_variable :current_mail
|
30
|
-
define_variable :background_thread
|
35
|
+
define_variable :current_mailbox, attr: :accessor
|
36
|
+
define_variable :current_summary, attr: :accessor
|
37
|
+
define_variable :current_uid, attr: :accessor
|
38
|
+
define_variable :current_mail, attr: :accessor
|
39
|
+
define_variable :background_thread, attr: :accessor
|
40
|
+
define_variable :keep_alive_thread, attr: :accessor
|
41
|
+
define_variable :imap
|
42
|
+
define_variable :imap_mutex, initial_value: Mutex.new
|
43
|
+
define_variable :mailboxes, initial_value: []
|
44
|
+
define_variable :current_account
|
45
|
+
define_variable :account_config
|
46
|
+
define_variable :groonga_db
|
31
47
|
|
32
|
-
def self.background
|
48
|
+
def self.background(skip_if_busy: false)
|
33
49
|
if background_thread&.alive?
|
34
|
-
|
50
|
+
return if skip_if_busy
|
35
51
|
end
|
36
52
|
self.background_thread = Utils.background {
|
37
53
|
begin
|
@@ -42,6 +58,33 @@ module Mournmail
|
|
42
58
|
}
|
43
59
|
end
|
44
60
|
|
61
|
+
def self.start_keep_alive_thread
|
62
|
+
if keep_alive_thread
|
63
|
+
raise EditorError, "Keep alive thread already running"
|
64
|
+
end
|
65
|
+
self.keep_alive_thread = Thread.start {
|
66
|
+
loop do
|
67
|
+
sleep(CONFIG[:mournmail_keep_alive_interval])
|
68
|
+
background(skip_if_busy: true) do
|
69
|
+
begin
|
70
|
+
imap_connect do |imap|
|
71
|
+
imap.noop
|
72
|
+
end
|
73
|
+
rescue => e
|
74
|
+
message("Error in IMAP NOOP: #{e.class}: #{e.message}")
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
def self.stop_keep_alive_thread
|
82
|
+
if keep_alive_thread
|
83
|
+
keep_alive_thread&.kill
|
84
|
+
self.keep_alive_thread = nil
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
45
88
|
def self.message_window
|
46
89
|
if Window.list.size == 1
|
47
90
|
split_window
|
@@ -74,19 +117,46 @@ module Mournmail
|
|
74
117
|
escape_binary(s)
|
75
118
|
end
|
76
119
|
|
77
|
-
|
78
|
-
|
120
|
+
def self.current_account
|
121
|
+
init_current_account
|
122
|
+
@current_account
|
123
|
+
end
|
124
|
+
|
125
|
+
def self.account_config
|
126
|
+
init_current_account
|
127
|
+
@account_config
|
128
|
+
end
|
129
|
+
|
130
|
+
def self.init_current_account
|
131
|
+
if @current_account.nil?
|
132
|
+
@current_account, @account_config = CONFIG[:mournmail_accounts].first
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def self.current_account=(name)
|
137
|
+
unless CONFIG[:mournmail_accounts].key?(name)
|
138
|
+
raise ArgumentError, "No such account: #{name}"
|
139
|
+
end
|
140
|
+
@current_account = name
|
141
|
+
@account_config = CONFIG[:mournmail_accounts][name]
|
142
|
+
end
|
79
143
|
|
80
144
|
def self.imap_connect
|
81
145
|
@imap_mutex.synchronize do
|
146
|
+
if keep_alive_thread.nil?
|
147
|
+
start_keep_alive_thread
|
148
|
+
end
|
82
149
|
if @imap.nil? || @imap.disconnected?
|
150
|
+
conf = account_config
|
83
151
|
Timeout.timeout(CONFIG[:mournmail_imap_connect_timeout]) do
|
84
|
-
@imap = Net::IMAP.new(
|
85
|
-
|
86
|
-
@imap.authenticate(
|
87
|
-
|
88
|
-
|
89
|
-
|
152
|
+
@imap = Net::IMAP.new(conf[:imap_host],
|
153
|
+
conf[:imap_options])
|
154
|
+
@imap.authenticate(conf[:imap_options][:auth_type] || "PLAIN",
|
155
|
+
conf[:imap_options][:user_name],
|
156
|
+
conf[:imap_options][:password])
|
157
|
+
@mailboxes = @imap.list("", "*").map { |mbox|
|
158
|
+
Net::IMAP.decode_utf7(mbox.name)
|
159
|
+
}
|
90
160
|
if Mournmail.current_mailbox
|
91
161
|
@imap.select(Mournmail.current_mailbox)
|
92
162
|
end
|
@@ -101,6 +171,7 @@ module Mournmail
|
|
101
171
|
|
102
172
|
def self.imap_disconnect
|
103
173
|
@imap_mutex.synchronize do
|
174
|
+
stop_keep_alive_thread
|
104
175
|
if @imap
|
105
176
|
@imap.disconnect rescue nil
|
106
177
|
@imap = nil
|
@@ -116,6 +187,17 @@ module Mournmail
|
|
116
187
|
else
|
117
188
|
summary = Mournmail::Summary.load_or_new(mailbox)
|
118
189
|
end
|
190
|
+
uidvalidity = imap.responses["UIDVALIDITY"].last
|
191
|
+
if summary.uidvalidity.nil?
|
192
|
+
summary.uidvalidity = uidvalidity
|
193
|
+
elsif uidvalidity && uidvalidity != summary.uidvalidity
|
194
|
+
clear = next_tick {
|
195
|
+
yes_or_no?("UIDVALIDITY has been changed; Clear cache?")
|
196
|
+
}
|
197
|
+
if clear
|
198
|
+
summary = Mournmail::Summary.new(mailbox)
|
199
|
+
end
|
200
|
+
end
|
119
201
|
first_uid = (summary.last_uid || 0) + 1
|
120
202
|
data = imap.uid_fetch(first_uid..-1, ["UID", "ENVELOPE", "FLAGS"])
|
121
203
|
summary.synchronize do
|
@@ -134,33 +216,192 @@ module Mournmail
|
|
134
216
|
end
|
135
217
|
|
136
218
|
def self.mailbox_cache_path(mailbox)
|
137
|
-
|
138
|
-
|
139
|
-
|
219
|
+
File.expand_path("cache/#{current_account}/mailboxes/#{mailbox}",
|
220
|
+
CONFIG[:mournmail_directory])
|
221
|
+
end
|
222
|
+
|
223
|
+
def self.mail_cache_path(cache_id)
|
224
|
+
dir = cache_id[0, 2]
|
225
|
+
File.expand_path("cache/#{current_account}/mails/#{dir}/#{cache_id}",
|
226
|
+
CONFIG[:mournmail_directory])
|
140
227
|
end
|
141
228
|
|
142
|
-
def self.
|
143
|
-
path =
|
229
|
+
def self.read_mail_cache(cache_id)
|
230
|
+
path = Mournmail.mail_cache_path(cache_id)
|
231
|
+
File.read(path)
|
232
|
+
end
|
233
|
+
|
234
|
+
def self.write_mail_cache(s)
|
235
|
+
header = s.slice(/.*\r\n\r\n/m)
|
236
|
+
cache_id = Digest::SHA256.hexdigest(header)
|
237
|
+
path = mail_cache_path(cache_id)
|
238
|
+
dir = File.dirname(path)
|
239
|
+
base = File.basename(path)
|
144
240
|
begin
|
145
|
-
|
146
|
-
|
147
|
-
|
241
|
+
f = Tempfile.create(["#{base}-", ".tmp"], dir)
|
242
|
+
begin
|
243
|
+
f.write(s)
|
244
|
+
ensure
|
245
|
+
f.close
|
148
246
|
end
|
149
247
|
rescue Errno::ENOENT
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
248
|
+
FileUtils.mkdir_p(File.dirname(path))
|
249
|
+
retry
|
250
|
+
end
|
251
|
+
File.rename(f.path, path)
|
252
|
+
cache_id
|
253
|
+
end
|
254
|
+
|
255
|
+
def self.index_mail(cache_id, mail)
|
256
|
+
messages_db = Groonga["Messages"]
|
257
|
+
unless messages_db.has_key?(cache_id)
|
258
|
+
thread_id = find_thread_id(mail, messages_db)
|
259
|
+
list_id = (mail["List-Id"] || mail["X-ML-Name"])
|
260
|
+
messages_db.add(cache_id,
|
261
|
+
message_id: header_text(mail.message_id),
|
262
|
+
thread_id: header_text(thread_id),
|
263
|
+
date: mail.date&.to_time,
|
264
|
+
subject: header_text(mail.subject),
|
265
|
+
from: header_text(mail["From"]),
|
266
|
+
to: header_text(mail["To"]),
|
267
|
+
cc: header_text(mail["Cc"]),
|
268
|
+
list_id: header_text(list_id),
|
269
|
+
body: body_text(mail))
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
class << self
|
274
|
+
private
|
275
|
+
|
276
|
+
def find_thread_id(mail, messages_db)
|
277
|
+
references = Array(mail.references) | Array(mail.in_reply_to)
|
278
|
+
if references.empty?
|
279
|
+
mail.message_id
|
280
|
+
else
|
281
|
+
parent = messages_db.select { |m|
|
282
|
+
references.inject(nil) { |cond, ref|
|
283
|
+
if cond.nil?
|
284
|
+
m.message_id == ref
|
285
|
+
else
|
286
|
+
cond | (m.message_id == ref)
|
287
|
+
end
|
288
|
+
}
|
289
|
+
}.first
|
290
|
+
if parent
|
291
|
+
parent.thread_id
|
292
|
+
else
|
293
|
+
mail.message_id
|
155
294
|
end
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
def header_text(s)
|
299
|
+
force_utf8(s.to_s)
|
300
|
+
end
|
301
|
+
|
302
|
+
def body_text(mail)
|
303
|
+
if mail.multipart?
|
304
|
+
mail.parts.map { |part|
|
305
|
+
part_text(part)
|
306
|
+
}.join("\n")
|
307
|
+
else
|
308
|
+
s = mail.body.decoded
|
309
|
+
to_utf8(s, mail.charset).gsub(/\r\n/, "\n")
|
310
|
+
end
|
311
|
+
rescue
|
312
|
+
""
|
313
|
+
end
|
314
|
+
|
315
|
+
def part_text(part)
|
316
|
+
if part.multipart?
|
317
|
+
part.parts.map { |part|
|
318
|
+
part_text(part)
|
319
|
+
}.join("\n")
|
320
|
+
elsif part.main_type == "message" && part.sub_type == "rfc822"
|
321
|
+
mail = Mail.new(part.body.raw_source)
|
322
|
+
body_text(mail)
|
323
|
+
elsif part.attachment?
|
324
|
+
force_utf8(part.filename.to_s)
|
325
|
+
else
|
326
|
+
if part.main_type == "text" && part.sub_type == "plain"
|
327
|
+
force_utf8(part.decoded).sub(/(?<!\n)\z/, "\n").gsub(/\r\n/, "\n")
|
328
|
+
else
|
329
|
+
""
|
161
330
|
end
|
162
|
-
[s, true]
|
163
331
|
end
|
332
|
+
rescue
|
333
|
+
""
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
def self.read_mailbox_name(prompt, **opts)
|
338
|
+
f = ->(s) {
|
339
|
+
complete_for_minibuffer(s, @mailboxes)
|
340
|
+
}
|
341
|
+
mailbox = read_from_minibuffer(prompt, completion_proc: f, **opts)
|
342
|
+
Net::IMAP.encode_utf7(mailbox)
|
343
|
+
end
|
344
|
+
|
345
|
+
def self.force_utf8(s)
|
346
|
+
s.force_encoding(Encoding::UTF_8).scrub("?")
|
347
|
+
end
|
348
|
+
|
349
|
+
def self.to_utf8(s, charset)
|
350
|
+
if /\Autf-8\z/i =~ charset
|
351
|
+
force_utf8(s)
|
352
|
+
else
|
353
|
+
begin
|
354
|
+
s.encode(Encoding::UTF_8, charset, replace: "?")
|
355
|
+
rescue Encoding::ConverterNotFoundError
|
356
|
+
force_utf8(s)
|
357
|
+
end
|
358
|
+
end.gsub(/\r\n/, "\n")
|
359
|
+
end
|
360
|
+
|
361
|
+
def self.open_groonga_db
|
362
|
+
db_path = File.expand_path("groonga/#{current_account}/messages.db",
|
363
|
+
CONFIG[:mournmail_directory])
|
364
|
+
if File.exist?(db_path)
|
365
|
+
@groonga_db = Groonga::Database.open(db_path)
|
366
|
+
else
|
367
|
+
@groonga_db = create_groonga_db(db_path)
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
def self.create_groonga_db(db_path)
|
372
|
+
FileUtils.mkdir_p(File.dirname(db_path), mode: 0700)
|
373
|
+
db = Groonga::Database.create(path: db_path)
|
374
|
+
|
375
|
+
Groonga::Schema.create_table("Messages", :type => :hash) do |table|
|
376
|
+
table.short_text("message_id")
|
377
|
+
table.short_text("thread_id")
|
378
|
+
table.time("date")
|
379
|
+
table.short_text("subject")
|
380
|
+
table.short_text("from")
|
381
|
+
table.short_text("to")
|
382
|
+
table.short_text("cc")
|
383
|
+
table.short_text("list_id")
|
384
|
+
table.text("body")
|
385
|
+
end
|
386
|
+
|
387
|
+
Groonga::Schema.create_table("Terms",
|
388
|
+
type: :patricia_trie,
|
389
|
+
normalizer: :NormalizerAuto,
|
390
|
+
default_tokenizer: "TokenBigram") do |table|
|
391
|
+
table.index("Messages.subject")
|
392
|
+
table.index("Messages.from")
|
393
|
+
table.index("Messages.to")
|
394
|
+
table.index("Messages.cc")
|
395
|
+
table.index("Messages.list_id")
|
396
|
+
table.index("Messages.body")
|
397
|
+
end
|
398
|
+
|
399
|
+
db
|
400
|
+
end
|
401
|
+
|
402
|
+
def self.close_groonga_db
|
403
|
+
if @groonga_db
|
404
|
+
@groonga_db.close
|
164
405
|
end
|
165
406
|
end
|
166
407
|
end
|
data/lib/mournmail/version.rb
CHANGED
data/mournmail.gemspec
CHANGED
@@ -23,7 +23,7 @@ Gem::Specification.new do |spec|
|
|
23
23
|
|
24
24
|
spec.add_runtime_dependency "textbringer"
|
25
25
|
spec.add_runtime_dependency "mail"
|
26
|
-
spec.add_runtime_dependency "
|
26
|
+
spec.add_runtime_dependency "rroonga"
|
27
27
|
|
28
28
|
spec.add_development_dependency "bundler", "~> 1.14"
|
29
29
|
spec.add_development_dependency "rake", "~> 10.0"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: mournmail
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Shugo Maeda
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2018-05-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: textbringer
|
@@ -39,7 +39,7 @@ dependencies:
|
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
42
|
+
name: rroonga
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
45
|
- - ">="
|
@@ -99,8 +99,10 @@ files:
|
|
99
99
|
- lib/mournmail/config.rb
|
100
100
|
- lib/mournmail/draft_mode.rb
|
101
101
|
- lib/mournmail/faces.rb
|
102
|
+
- lib/mournmail/mail_encoded_word_patch.rb
|
102
103
|
- lib/mournmail/message_mode.rb
|
103
104
|
- lib/mournmail/message_rendering.rb
|
105
|
+
- lib/mournmail/search_result_mode.rb
|
104
106
|
- lib/mournmail/summary.rb
|
105
107
|
- lib/mournmail/summary_mode.rb
|
106
108
|
- lib/mournmail/utils.rb
|
@@ -127,9 +129,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
127
129
|
version: '0'
|
128
130
|
requirements: []
|
129
131
|
rubyforge_project:
|
130
|
-
rubygems_version: 2.6
|
132
|
+
rubygems_version: 2.7.6
|
131
133
|
signing_key:
|
132
134
|
specification_version: 4
|
133
135
|
summary: A message user agent for Textbringer.
|
134
136
|
test_files: []
|
135
|
-
has_rdoc:
|