mournmail 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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, value = nil)
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, value)
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
- raise EditorError, "Background thread already running"
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
- @imap = nil
78
- @imap_mutex = Mutex.new
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(CONFIG[:mournmail_imap_host],
85
- CONFIG[:mournmail_imap_options])
86
- @imap.authenticate(CONFIG[:mournmail_imap_options][:auth_type] ||
87
- "PLAIN",
88
- CONFIG[:mournmail_imap_options][:user_name],
89
- CONFIG[:mournmail_imap_options][:password])
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
- dir = CONFIG[:mournmail_directory]
138
- host = CONFIG[:mournmail_imap_host]
139
- File.expand_path("cache/#{host}/#{mailbox}", dir)
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.read_mail(mailbox, uid)
143
- path = File.join(mailbox_cache_path(mailbox), uid.to_s)
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
- File.open(path) do |f|
146
- f.flock(File::LOCK_SH)
147
- [f.read, false]
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
- imap_connect do |imap|
151
- imap.select(mailbox)
152
- data = imap.uid_fetch(uid, "BODY[]")
153
- if data.empty?
154
- raise EditorError, "No such mail: #{uid}"
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
- s = data[0].attr["BODY[]"]
157
- FileUtils.mkdir_p(File.dirname(path))
158
- File.open(path, "w", 0600) do |f|
159
- f.flock(File::LOCK_EX)
160
- f.write(s)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mournmail
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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 "mail-iso-2022-jp"
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.1.1
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: 2017-09-18 00:00:00.000000000 Z
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: mail-iso-2022-jp
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.12
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: