mournmail 0.1.1 → 1.0.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,42 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "mail"
4
- require "mail-iso-2022-jp"
5
2
  require "net/imap"
6
3
  require "time"
4
+ require "tempfile"
7
5
  require "fileutils"
8
6
  require "timeout"
7
+ require "digest"
8
+ require "nkf"
9
+ require "groonga"
10
+ require 'google/api_client/client_secrets'
11
+ require 'google/api_client/auth/storage'
12
+ require 'google/api_client/auth/storages/file_store'
13
+ require 'launchy'
14
+
15
+ class Net::SMTP
16
+ def auth_xoauth2(user, secret)
17
+ check_auth_args user, secret
18
+ res = critical {
19
+ s = Mournmail.xoauth2_string(user, secret)
20
+ get_response('AUTH XOAUTH2 ' + base64_encode(s))
21
+ }
22
+ check_auth_response res
23
+ res
24
+ end
25
+ end
26
+
27
+ class Net::IMAP::Xoauth2Authenticator
28
+ def process(data)
29
+ Mournmail.xoauth2_string(@user, @access_token)
30
+ end
31
+
32
+ private
33
+
34
+ def initialize(user, access_token)
35
+ @user = user
36
+ @access_token = access_token
37
+ end
38
+ end
39
+ Net::IMAP.add_authenticator("XOAUTH2", Net::IMAP::Xoauth2Authenticator)
9
40
 
10
41
  module Mournmail
11
42
  begin
@@ -15,31 +46,84 @@ module Mournmail
15
46
  HAVE_MAIL_GPG = false
16
47
  end
17
48
 
18
- def self.define_variable(name, value = nil)
49
+ def self.define_variable(name, initial_value: nil, attr: nil)
19
50
  var_name = "@" + name.to_s
20
51
  if !instance_variable_defined?(var_name)
21
- instance_variable_set(var_name, value)
52
+ instance_variable_set(var_name, initial_value)
53
+ end
54
+ case attr
55
+ when :accessor
56
+ singleton_class.send(:attr_accessor, name)
57
+ when :reader
58
+ singleton_class.send(:attr_reader, name)
59
+ when :writer
60
+ singleton_class.send(:attr_writer, name)
22
61
  end
23
- singleton_class.send(:attr_accessor, name)
24
62
  end
25
63
 
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
64
+ define_variable :current_mailbox, attr: :accessor
65
+ define_variable :current_summary, attr: :accessor
66
+ define_variable :current_uid, attr: :accessor
67
+ define_variable :current_mail, attr: :accessor
68
+ define_variable :background_thread, attr: :accessor
69
+ define_variable :background_thread_mutex, initial_value: Mutex.new
70
+ define_variable :keep_alive_thread, attr: :accessor
71
+ define_variable :keep_alive_thread_mutex, initial_value: Mutex.new
72
+ define_variable :imap
73
+ define_variable :imap_mutex, initial_value: Mutex.new
74
+ define_variable :mailboxes, initial_value: []
75
+ define_variable :current_account
76
+ define_variable :account_config
77
+ define_variable :groonga_db
31
78
 
32
- def self.background
33
- if background_thread&.alive?
34
- raise EditorError, "Background thread already running"
79
+ def self.background(skip_if_busy: false)
80
+ @background_thread_mutex.synchronize do
81
+ if background_thread&.alive?
82
+ if skip_if_busy
83
+ return
84
+ else
85
+ raise EditorError, "Another background thread is running"
86
+ end
87
+ end
88
+ self.background_thread = Utils.background {
89
+ begin
90
+ yield
91
+ ensure
92
+ self.background_thread = nil
93
+ end
94
+ }
35
95
  end
36
- self.background_thread = Utils.background {
37
- begin
38
- yield
39
- ensure
40
- self.background_thread = nil
96
+ end
97
+
98
+ def self.start_keep_alive_thread
99
+ @keep_alive_thread_mutex.synchronize do
100
+ if keep_alive_thread
101
+ raise EditorError, "Keep alive thread already running"
41
102
  end
42
- }
103
+ self.keep_alive_thread = Thread.start {
104
+ loop do
105
+ sleep(CONFIG[:mournmail_keep_alive_interval])
106
+ background(skip_if_busy: true) do
107
+ begin
108
+ imap_connect do |imap|
109
+ imap.noop
110
+ end
111
+ rescue => e
112
+ message("Error in IMAP NOOP: #{e.class}: #{e.message}")
113
+ end
114
+ end
115
+ end
116
+ }
117
+ end
118
+ end
119
+
120
+ def self.stop_keep_alive_thread
121
+ @keep_alive_thread_mutex.synchronize do
122
+ if keep_alive_thread
123
+ keep_alive_thread&.kill
124
+ self.keep_alive_thread = nil
125
+ end
126
+ end
43
127
  end
44
128
 
45
129
  def self.message_window
@@ -74,19 +158,51 @@ module Mournmail
74
158
  escape_binary(s)
75
159
  end
76
160
 
77
- @imap = nil
78
- @imap_mutex = Mutex.new
161
+ def self.current_account
162
+ init_current_account
163
+ @current_account
164
+ end
165
+
166
+ def self.account_config
167
+ init_current_account
168
+ @account_config
169
+ end
170
+
171
+ def self.init_current_account
172
+ if @current_account.nil?
173
+ @current_account, @account_config = CONFIG[:mournmail_accounts].first
174
+ end
175
+ end
176
+
177
+ def self.current_account=(name)
178
+ unless CONFIG[:mournmail_accounts].key?(name)
179
+ raise ArgumentError, "No such account: #{name}"
180
+ end
181
+ @current_account = name
182
+ @account_config = CONFIG[:mournmail_accounts][name]
183
+ end
79
184
 
80
185
  def self.imap_connect
81
186
  @imap_mutex.synchronize do
187
+ if keep_alive_thread.nil?
188
+ start_keep_alive_thread
189
+ end
82
190
  if @imap.nil? || @imap.disconnected?
191
+ conf = account_config
192
+ auth_type = conf[:imap_options][:auth_type] || "PLAIN"
193
+ password = conf[:imap_options][:password]
194
+ if auth_type == "gmail"
195
+ auth_type = "XOAUTH2"
196
+ password = google_access_token
197
+ end
83
198
  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])
199
+ @imap = Net::IMAP.new(conf[:imap_host],
200
+ conf[:imap_options])
201
+ @imap.authenticate(auth_type, conf[:imap_options][:user_name],
202
+ password)
203
+ @mailboxes = @imap.list("", "*").map { |mbox|
204
+ Net::IMAP.decode_utf7(mbox.name)
205
+ }
90
206
  if Mournmail.current_mailbox
91
207
  @imap.select(Mournmail.current_mailbox)
92
208
  end
@@ -101,6 +217,7 @@ module Mournmail
101
217
 
102
218
  def self.imap_disconnect
103
219
  @imap_mutex.synchronize do
220
+ stop_keep_alive_thread
104
221
  if @imap
105
222
  @imap.disconnect rescue nil
106
223
  @imap = nil
@@ -108,58 +225,354 @@ module Mournmail
108
225
  end
109
226
  end
110
227
 
228
+ def self.google_access_token(account = current_account)
229
+ auth_path = File.expand_path("cache/#{account}/google_auth.json",
230
+ CONFIG[:mournmail_directory])
231
+ FileUtils.mkdir_p(File.dirname(auth_path))
232
+ store = Google::APIClient::FileStore.new(auth_path)
233
+ storage = Google::APIClient::Storage.new(store)
234
+ storage.authorize
235
+ if storage.authorization.nil?
236
+ conf = CONFIG[:mournmail_accounts][account]
237
+ path = File.expand_path(conf[:client_secret_path])
238
+ client_secrets = Google::APIClient::ClientSecrets.load(path)
239
+ auth_client = client_secrets.to_authorization
240
+ auth_client.update!(
241
+ :scope => 'https://mail.google.com/',
242
+ :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
243
+ )
244
+ auth_uri = auth_client.authorization_uri.to_s
245
+ auth_client.code = foreground! {
246
+ begin
247
+ Launchy.open(auth_uri)
248
+ rescue Launchy::CommandNotFoundError
249
+ buffer = show_google_auth_uri(auth_uri)
250
+ end
251
+ begin
252
+ Window.echo_area.clear_message
253
+ Window.redisplay
254
+ read_from_minibuffer("Code: ").chomp
255
+ ensure
256
+ if buffer
257
+ kill_buffer(buffer, force: true)
258
+ end
259
+ end
260
+ }
261
+ auth_client.fetch_access_token!
262
+ old_umask = File.umask(077)
263
+ begin
264
+ storage.write_credentials(auth_client)
265
+ ensure
266
+ File.umask(old_umask)
267
+ end
268
+ else
269
+ auth_client = storage.authorization
270
+ end
271
+ auth_client.access_token
272
+ end
273
+
274
+ def self.show_google_auth_uri(auth_uri)
275
+ buffer = Buffer.find_or_new("*message*",
276
+ undo_limit: 0, read_only: true)
277
+ buffer.apply_mode(Mournmail::MessageMode)
278
+ buffer.read_only_edit do
279
+ buffer.clear
280
+ buffer.insert(<<~EOF)
281
+ Open the following URI in your browser and type obtained code:
282
+
283
+ #{auth_uri}
284
+ EOF
285
+ end
286
+ window = Mournmail.message_window
287
+ window.buffer = buffer
288
+ buffer
289
+ end
290
+
291
+ def self.xoauth2_string(user, access_token)
292
+ "user=#{user}\1auth=Bearer #{access_token}\1\1"
293
+ end
294
+
111
295
  def self.fetch_summary(mailbox, all: false)
296
+ if all
297
+ summary = Mournmail::Summary.new(mailbox)
298
+ else
299
+ summary = Mournmail::Summary.load_or_new(mailbox)
300
+ end
112
301
  imap_connect do |imap|
113
302
  imap.select(mailbox)
114
- if all
115
- summary = Mournmail::Summary.new(mailbox)
116
- else
117
- summary = Mournmail::Summary.load_or_new(mailbox)
303
+ uidvalidity = imap.responses["UIDVALIDITY"].last
304
+ if uidvalidity && summary.uidvalidity &&
305
+ uidvalidity != summary.uidvalidity
306
+ clear = foreground! {
307
+ yes_or_no?("UIDVALIDITY has been changed; Clear cache?")
308
+ }
309
+ if clear
310
+ summary = Mournmail::Summary.new(mailbox)
311
+ end
118
312
  end
119
- first_uid = (summary.last_uid || 0) + 1
120
- data = imap.uid_fetch(first_uid..-1, ["UID", "ENVELOPE", "FLAGS"])
313
+ summary.uidvalidity = uidvalidity
314
+ uids = imap.uid_search("ALL")
315
+ new_uids = uids - summary.uids
316
+ return summary if new_uids.empty?
121
317
  summary.synchronize do
122
- data&.each do |i|
123
- uid = i.attr["UID"]
124
- next if summary[uid]
125
- env = i.attr["ENVELOPE"]
126
- flags = i.attr["FLAGS"]
127
- item = Mournmail::SummaryItem.new(uid, env.date, env.from,
128
- env.subject, flags)
129
- summary.add_item(item, env.message_id, env.in_reply_to)
318
+ new_uids.each_slice(1000) do |uid_chunk|
319
+ data = imap.uid_fetch(uid_chunk, ["UID", "ENVELOPE", "FLAGS"])
320
+ data&.each do |i|
321
+ uid = i.attr["UID"]
322
+ next if summary[uid]
323
+ env = i.attr["ENVELOPE"]
324
+ flags = i.attr["FLAGS"]
325
+ item = Mournmail::SummaryItem.new(uid, env.date, env.from,
326
+ env.subject, flags)
327
+ summary.add_item(item, env.message_id, env.in_reply_to)
328
+ end
130
329
  end
131
330
  end
132
331
  summary
133
332
  end
333
+ rescue SocketError, Timeout::Error => e
334
+ foreground do
335
+ message(e.message)
336
+ end
337
+ summary
338
+ end
339
+
340
+ def self.show_summary(summary)
341
+ buffer = Buffer.find_or_new("*summary*", undo_limit: 0,
342
+ read_only: true)
343
+ buffer.apply_mode(Mournmail::SummaryMode)
344
+ buffer.read_only_edit do
345
+ buffer.clear
346
+ buffer.insert(summary.to_s)
347
+ end
348
+ switch_to_buffer(buffer)
349
+ Mournmail.current_mailbox = summary.mailbox
350
+ Mournmail.current_summary = summary
351
+ Mournmail.current_mail = nil
352
+ Mournmail.current_uid = nil
353
+ begin
354
+ buffer.beginning_of_buffer
355
+ buffer.re_search_forward(/^ *\d+ u/)
356
+ rescue SearchError
357
+ buffer.end_of_buffer
358
+ buffer.re_search_backward(/^ *\d+ /, raise_error: false)
359
+ end
360
+ summary_read_command
134
361
  end
135
362
 
136
363
  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)
364
+ File.expand_path("cache/#{current_account}/mailboxes/#{mailbox}",
365
+ CONFIG[:mournmail_directory])
366
+ end
367
+
368
+ def self.mail_cache_path(cache_id)
369
+ dir = cache_id[0, 2]
370
+ File.expand_path("cache/#{current_account}/mails/#{dir}/#{cache_id}",
371
+ CONFIG[:mournmail_directory])
140
372
  end
141
373
 
142
- def self.read_mail(mailbox, uid)
143
- path = File.join(mailbox_cache_path(mailbox), uid.to_s)
374
+ def self.read_mail_cache(cache_id)
375
+ path = Mournmail.mail_cache_path(cache_id)
376
+ File.read(path)
377
+ end
378
+
379
+ def self.write_mail_cache(s)
380
+ header = s.slice(/.*\r\n\r\n/m)
381
+ cache_id = Digest::SHA256.hexdigest(header)
382
+ path = mail_cache_path(cache_id)
383
+ dir = File.dirname(path)
384
+ base = File.basename(path)
144
385
  begin
145
- File.open(path) do |f|
146
- f.flock(File::LOCK_SH)
147
- [f.read, false]
386
+ f = Tempfile.create(["#{base}-", ".tmp"], dir,
387
+ external_encoding: "ASCII-8BIT", binmode: true)
388
+ begin
389
+ f.write(s)
390
+ ensure
391
+ f.close
148
392
  end
149
393
  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}"
394
+ FileUtils.mkdir_p(File.dirname(path))
395
+ retry
396
+ end
397
+ File.rename(f.path, path)
398
+ cache_id
399
+ end
400
+
401
+ def self.index_mail(cache_id, mail)
402
+ messages_db = Groonga["Messages"]
403
+ unless messages_db.has_key?(cache_id)
404
+ thread_id = find_thread_id(mail, messages_db)
405
+ list_id = (mail["List-Id"] || mail["X-ML-Name"])
406
+ messages_db.add(cache_id,
407
+ message_id: header_text(mail.message_id),
408
+ thread_id: header_text(thread_id),
409
+ date: mail.date&.to_time,
410
+ subject: header_text(mail.subject),
411
+ from: header_text(mail["From"]),
412
+ to: header_text(mail["To"]),
413
+ cc: header_text(mail["Cc"]),
414
+ list_id: header_text(list_id),
415
+ body: body_text(mail))
416
+ end
417
+ end
418
+
419
+ class << self
420
+ private
421
+
422
+ def find_thread_id(mail, messages_db)
423
+ references = Array(mail.references) | Array(mail.in_reply_to)
424
+ if references.empty?
425
+ mail.message_id
426
+ elsif /\Aredmine\.issue-/.match?(references.first)
427
+ references.first
428
+ else
429
+ parent = messages_db.select { |m|
430
+ references.inject(nil) { |cond, ref|
431
+ if cond.nil?
432
+ m.message_id == ref
433
+ else
434
+ cond | (m.message_id == ref)
435
+ end
436
+ }
437
+ }.first
438
+ if parent
439
+ parent.thread_id
440
+ else
441
+ mail.message_id
155
442
  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)
443
+ end
444
+ end
445
+
446
+ def header_text(s)
447
+ force_utf8(s.to_s)
448
+ end
449
+
450
+ def body_text(mail)
451
+ if mail.multipart?
452
+ mail.parts.map { |part|
453
+ part_text(part)
454
+ }.join("\n")
455
+ else
456
+ s = mail.body.decoded
457
+ to_utf8(s, mail.charset).gsub(/\r\n/, "\n")
458
+ end
459
+ rescue
460
+ ""
461
+ end
462
+
463
+ def part_text(part)
464
+ if part.multipart?
465
+ part.parts.map { |part|
466
+ part_text(part)
467
+ }.join("\n")
468
+ elsif part.main_type == "message" && part.sub_type == "rfc822"
469
+ mail = Mail.new(part.body.raw_source)
470
+ body_text(mail)
471
+ elsif part.attachment?
472
+ force_utf8(part.filename.to_s)
473
+ else
474
+ if part.main_type == "text" && part.sub_type == "plain"
475
+ force_utf8(part.decoded).sub(/(?<!\n)\z/, "\n").gsub(/\r\n/, "\n")
476
+ else
477
+ ""
161
478
  end
162
- [s, true]
479
+ end
480
+ rescue
481
+ ""
482
+ end
483
+ end
484
+
485
+ def self.read_mailbox_name(prompt, **opts)
486
+ f = ->(s) {
487
+ complete_for_minibuffer(s, @mailboxes)
488
+ }
489
+ mailbox = read_from_minibuffer(prompt, completion_proc: f, **opts)
490
+ Net::IMAP.encode_utf7(mailbox)
491
+ end
492
+
493
+ def self.force_utf8(s)
494
+ s.dup.force_encoding(Encoding::UTF_8).scrub("?")
495
+ end
496
+
497
+ def self.to_utf8(s, charset)
498
+ if /\Autf-8\z/i.match?(charset)
499
+ force_utf8(s)
500
+ else
501
+ begin
502
+ s.encode(Encoding::UTF_8, charset, replace: "?")
503
+ rescue
504
+ force_utf8(NKF.nkf("-w", s))
505
+ end
506
+ end.gsub(/\r\n/, "\n")
507
+ end
508
+
509
+ def self.open_groonga_db
510
+ db_path = File.expand_path("groonga/#{current_account}/messages.db",
511
+ CONFIG[:mournmail_directory])
512
+ if File.exist?(db_path)
513
+ @groonga_db = Groonga::Database.open(db_path)
514
+ else
515
+ @groonga_db = create_groonga_db(db_path)
516
+ end
517
+ end
518
+
519
+ def self.create_groonga_db(db_path)
520
+ FileUtils.mkdir_p(File.dirname(db_path), mode: 0700)
521
+ db = Groonga::Database.create(path: db_path)
522
+
523
+ Groonga::Schema.create_table("Messages", :type => :hash) do |table|
524
+ table.short_text("message_id")
525
+ table.short_text("thread_id")
526
+ table.time("date")
527
+ table.short_text("subject")
528
+ table.short_text("from")
529
+ table.short_text("to")
530
+ table.short_text("cc")
531
+ table.short_text("list_id")
532
+ table.text("body")
533
+ end
534
+
535
+ Groonga::Schema.create_table("Terms",
536
+ type: :patricia_trie,
537
+ normalizer: :NormalizerAuto,
538
+ default_tokenizer: "TokenBigram") do |table|
539
+ table.index("Messages.subject")
540
+ table.index("Messages.from")
541
+ table.index("Messages.to")
542
+ table.index("Messages.cc")
543
+ table.index("Messages.list_id")
544
+ table.index("Messages.body")
545
+ end
546
+
547
+ db
548
+ end
549
+
550
+ def self.close_groonga_db
551
+ if @groonga_db
552
+ @groonga_db.close
553
+ end
554
+ end
555
+
556
+ def self.parse_mail(s)
557
+ Mail.new(s.scrub("??"))
558
+ end
559
+
560
+ def self.read_account_name(prompt, **opts)
561
+ f = ->(s) {
562
+ complete_for_minibuffer(s, CONFIG[:mournmail_accounts].keys)
563
+ }
564
+ read_from_minibuffer(prompt, completion_proc: f, **opts)
565
+ end
566
+
567
+ def self.insert_signature
568
+ account = Buffer.current[:mournmail_delivery_account] ||
569
+ Mournmail.current_account
570
+ signature = CONFIG[:mournmail_accounts][account][:signature]
571
+ if signature
572
+ Buffer.current.save_excursion do
573
+ end_of_buffer
574
+ insert("\n")
575
+ insert(signature)
163
576
  end
164
577
  end
165
578
  end