mournmail 0.1.1 → 0.2.0
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 +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:
|