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.
@@ -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: