mournmail 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "fileutils"
5
+ require "monitor"
6
+
7
+ module Mournmail
8
+ class Summary
9
+ attr_reader :items, :last_uid
10
+
11
+ include MonitorMixin
12
+
13
+ def self.cache_path(mailbox)
14
+ File.expand_path("cache/#{mailbox}/.summary",
15
+ CONFIG[:mournmail_directory])
16
+ end
17
+
18
+ def self.load(mailbox)
19
+ File.open(cache_path(mailbox)) { |f|
20
+ f.flock(File::LOCK_SH)
21
+ Marshal.load(f)
22
+ }
23
+ end
24
+
25
+ def self.load_or_new(mailbox)
26
+ load(mailbox)
27
+ rescue Errno::ENOENT
28
+ new(mailbox)
29
+ end
30
+
31
+ def initialize(mailbox)
32
+ super()
33
+ @mailbox = mailbox
34
+ @items = []
35
+ @message_id_table = {}
36
+ @uid_table = {}
37
+ @last_uid = nil
38
+ end
39
+
40
+ DUMPABLE_VARIABLES = [
41
+ :@mailbox,
42
+ :@items,
43
+ :@message_id_table,
44
+ :@uid_table,
45
+ :@last_uid
46
+ ]
47
+
48
+ def marshal_dump
49
+ DUMPABLE_VARIABLES.each_with_object({}) { |var, h|
50
+ h[var] = instance_variable_get(var)
51
+ }
52
+ end
53
+
54
+ def marshal_load(data)
55
+ mon_initialize
56
+ data.each do |var, val|
57
+ instance_variable_set(var, val)
58
+ end
59
+ end
60
+
61
+ def add_item(item, message_id, in_reply_to)
62
+ synchronize do
63
+ parent = @message_id_table[in_reply_to]
64
+ if parent
65
+ parent.add_reply(item)
66
+ else
67
+ @items.push(item)
68
+ end
69
+ if message_id
70
+ @message_id_table[message_id] = item
71
+ end
72
+ @uid_table[item.uid] = item
73
+ @last_uid = item.uid
74
+ end
75
+ end
76
+
77
+ def delete_item_if(&block)
78
+ synchronize do
79
+ @items = @items.flat_map { |item|
80
+ item.delete_reply_if(&block)
81
+ if yield(item)
82
+ item.replies
83
+ else
84
+ [item]
85
+ end
86
+ }
87
+ end
88
+ end
89
+
90
+ def [](uid)
91
+ synchronize do
92
+ @uid_table[uid]
93
+ end
94
+ end
95
+
96
+ def save
97
+ synchronize do
98
+ path = Summary.cache_path(@mailbox)
99
+ FileUtils.mkdir_p(File.dirname(path))
100
+ File.open(Summary.cache_path(@mailbox), "w", 0600) do |f|
101
+ f.flock(File::LOCK_EX)
102
+ Marshal.dump(self, f)
103
+ end
104
+ end
105
+ end
106
+
107
+ def to_s
108
+ synchronize do
109
+ items.each_with_object(String.new) do |item, s|
110
+ s << item.to_s
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ class SummaryItem
117
+ attr_reader :uid, :date, :from, :subject, :flags
118
+ attr_reader :replies
119
+
120
+ def initialize(uid, date, from, subject, flags)
121
+ @uid = uid
122
+ @date = date
123
+ @from = from
124
+ @subject = subject
125
+ @flags = flags
126
+ @line = nil
127
+ @replies = []
128
+ end
129
+
130
+ def add_reply(reply)
131
+ @replies << reply
132
+ end
133
+
134
+ def delete_reply_if(&block)
135
+ @replies = @replies.flat_map { |reply|
136
+ reply.delete_reply_if(&block)
137
+ if yield(reply)
138
+ reply.replies
139
+ else
140
+ [reply]
141
+ end
142
+ }
143
+ end
144
+
145
+ def to_s(limit = 78, from_limit = 16, level = 0)
146
+ @line ||= format_line(limit, from_limit, level)
147
+ return @line if @replies.empty?
148
+ s = @line.dup
149
+ child_level = level + 1
150
+ @replies.each do |reply|
151
+ s << reply.to_s(limit, from_limit, child_level)
152
+ end
153
+ s
154
+ end
155
+
156
+ def set_flag(flag, update_server: true)
157
+ if !@flags.include?(flag)
158
+ update_flag("+", flag, update_server: update_server)
159
+ end
160
+ end
161
+
162
+ def unset_flag(flag, update_server: true)
163
+ if @flags.include?(flag)
164
+ update_flag("-", flag, update_server: update_server)
165
+ end
166
+ end
167
+
168
+ def toggle_flag(flag, update_server: true)
169
+ sign = @flags.include?(flag) ? "-" : "+"
170
+ update_flag(sign, flag, update_server: update_server)
171
+ end
172
+
173
+ def flags_char
174
+ format_flags(@flags)
175
+ end
176
+
177
+ private
178
+
179
+ def format_line(limit = 78, from_limit = 16, level = 0)
180
+ space = " " * (level < 8 ? level : 8)
181
+ s = String.new
182
+ s << format("%s %s%s %s[ %s ] ",
183
+ @uid, format_flags(@flags), format_date(@date), space,
184
+ ljust(format_from(@from), from_limit))
185
+ s << ljust(decode_eword(@subject.to_s), limit - Buffer.display_width(s))
186
+ s << "\n"
187
+ s
188
+ end
189
+
190
+ def ljust(s, n)
191
+ width = 0
192
+ str = String.new
193
+ s.each_char do |c|
194
+ w = Buffer.display_width(c)
195
+ width += w
196
+ if width > n
197
+ width -= w
198
+ break
199
+ end
200
+ str.concat(c)
201
+ break if width == n
202
+ end
203
+ str + " " * (n - width)
204
+ end
205
+
206
+ def format_flags(flags)
207
+ if flags.include?(:Deleted)
208
+ "d"
209
+ elsif flags.include?(:Flagged)
210
+ "$"
211
+ elsif flags.include?(:Answered)
212
+ "a"
213
+ elsif !flags.include?(:Seen)
214
+ "u"
215
+ else
216
+ " "
217
+ end
218
+ end
219
+
220
+ def format_date(date)
221
+ (Time.parse(date) rescue Time.at(0)).localtime.strftime("%m/%d %H:%M")
222
+ end
223
+
224
+ def format_from(from)
225
+ addr = from&.[](0)
226
+ if addr.nil? || addr.mailbox.nil?
227
+ return "Unknown sender"
228
+ end
229
+ mailbox = Mournmail.escape_binary(addr.mailbox)
230
+ host = Mournmail.escape_binary(addr.host.to_s)
231
+ if addr.name
232
+ "#{decode_eword(addr.name)} <#{mailbox}@#{host}>"
233
+ else
234
+ "#{mailbox}@#{host}"
235
+ end
236
+ end
237
+
238
+ def decode_eword(s)
239
+ Mournmail.decode_eword(s)
240
+ end
241
+
242
+ def update_flag(sign, flag, update_server: true)
243
+ if update_server
244
+ Mournmail.imap_connect do |imap|
245
+ data = imap.uid_store(@uid, "#{sign}FLAGS", [flag]).first
246
+ @flags = data.attr["FLAGS"]
247
+ end
248
+ else
249
+ case
250
+ when "+"
251
+ @flags.push(flag)
252
+ when "-"
253
+ @flags.delete(flag)
254
+ end
255
+ end
256
+ if @line
257
+ s = format("%s %s", @uid, format_flags(@flags))
258
+ @line.sub!(/^\d+ ./, s)
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ using Mournmail::MessageRendering
6
+
7
+ module Mournmail
8
+ class SummaryMode < Textbringer::Mode
9
+ SUMMARY_MODE_MAP = Keymap.new
10
+ SUMMARY_MODE_MAP.define_key("s", :mournmail_summary_sync)
11
+ SUMMARY_MODE_MAP.define_key(" ", :summary_read_command)
12
+ SUMMARY_MODE_MAP.define_key(:backspace, :summary_scroll_down_command)
13
+ SUMMARY_MODE_MAP.define_key("\C-h", :summary_scroll_down_command)
14
+ SUMMARY_MODE_MAP.define_key("\C-?", :summary_scroll_down_command)
15
+ SUMMARY_MODE_MAP.define_key("n", :summary_next_command)
16
+ SUMMARY_MODE_MAP.define_key("w", :summary_write_command)
17
+ SUMMARY_MODE_MAP.define_key("a", :summary_reply_command)
18
+ SUMMARY_MODE_MAP.define_key("A", :summary_reply_command)
19
+ SUMMARY_MODE_MAP.define_key("f", :summary_forward_command)
20
+ SUMMARY_MODE_MAP.define_key("u", :summary_toggle_seen_command)
21
+ SUMMARY_MODE_MAP.define_key("$", :summary_toggle_flagged_command)
22
+ SUMMARY_MODE_MAP.define_key("d", :summary_toggle_deleted_command)
23
+ SUMMARY_MODE_MAP.define_key("x", :summary_expunge_command)
24
+ SUMMARY_MODE_MAP.define_key("v", :summary_view_source_command)
25
+ SUMMARY_MODE_MAP.define_key("q", :mournmail_quit)
26
+ SUMMARY_MODE_MAP.define_key("k", :previous_line)
27
+ SUMMARY_MODE_MAP.define_key("j", :next_line)
28
+ SUMMARY_MODE_MAP.define_key("m", :mournmail_visit_mailbox)
29
+
30
+ define_syntax :seen, /^\d+ .*/
31
+ define_syntax :unseen, /^\d+ u.*/
32
+ define_syntax :flagged, /^\d+ \$.*/
33
+ define_syntax :deleted, /^\d+ d.*/
34
+ define_syntax :answered, /^\d+ a.*/
35
+
36
+ def initialize(buffer)
37
+ super(buffer)
38
+ buffer.keymap = SUMMARY_MODE_MAP
39
+ end
40
+
41
+ define_local_command(:summary_read, doc: "Read a mail.") do
42
+ uid = scroll_up_or_next_uid
43
+ return if uid.nil?
44
+ Mournmail.background do
45
+ mailbox = Mournmail.current_mailbox
46
+ mail = Mail.new(Mournmail.read_mail(mailbox, uid))
47
+ message = mail.render
48
+ next_tick do
49
+ message_buffer = Buffer.find_or_new("*message*",
50
+ undo_limit: 0, read_only: true)
51
+ message_buffer.apply_mode(Mournmail::MessageMode)
52
+ message_buffer.read_only_edit do
53
+ message_buffer.clear
54
+ message_buffer.insert(message)
55
+ message_buffer.beginning_of_buffer
56
+ end
57
+ window = Mournmail.message_window
58
+ window.buffer = message_buffer
59
+ mark_as_seen(uid)
60
+ Mournmail.current_uid = uid
61
+ Mournmail.current_mail = mail
62
+ end
63
+ end
64
+ end
65
+
66
+ define_local_command(:summary_scroll_down,
67
+ doc: "Scroll down the current message.") do
68
+ uid = selected_uid
69
+ if uid == Mournmail.current_uid
70
+ window = Mournmail.message_window
71
+ if window.buffer.name == "*message*"
72
+ old_window = Window.current
73
+ begin
74
+ Window.current = window
75
+ scroll_down
76
+ return
77
+ ensure
78
+ Window.current = old_window
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ define_local_command(:summary_next,
85
+ doc: "Display the next mail.") do
86
+ next_message
87
+ summary_read
88
+ end
89
+
90
+ define_local_command(:summary_write,
91
+ doc: "Write a new mail.") do
92
+ Window.current = Mournmail.message_window
93
+ Commands.mail
94
+ end
95
+
96
+ define_local_command(:summary_reply,
97
+ doc: "Reply to the current message.") do
98
+ |reply_all = current_prefix_arg|
99
+ uid = selected_uid
100
+ Mournmail.background do
101
+ mailbox = Mournmail.current_mailbox
102
+ mail = Mail.new(Mournmail.read_mail(mailbox, uid))
103
+ body = mail.render_body
104
+ next_tick do
105
+ Window.current = Mournmail.message_window
106
+ Commands.mail(run_hooks: false)
107
+ if reply_all
108
+ insert(mail.from&.join(", "))
109
+ cc_addrs = [mail.reply_to, mail.to, mail.cc].flat_map { |addrs|
110
+ addrs || []
111
+ }.uniq.reject { |addr|
112
+ mail.from&.include?(addr)
113
+ }
114
+ insert("\nCc: " + cc_addrs.join(", "))
115
+ else
116
+ insert(mail.reply_to&.join(", ") || mail.from&.join(", "))
117
+ end
118
+ re_search_forward(/^Subject: /)
119
+ subject = mail["subject"].to_s
120
+ if /\Are:/i !~ subject
121
+ insert("Re: ")
122
+ end
123
+ insert(subject)
124
+ if mail['message-id']
125
+ insert("\nIn-Reply-To: #{mail['message-id']}")
126
+ end
127
+ end_of_buffer
128
+ push_mark
129
+ insert(<<~EOF + body.gsub(/^/, "> "))
130
+
131
+
132
+ On #{mail['date']}
133
+ #{mail['from']} wrote:
134
+ EOF
135
+ exchange_point_and_mark
136
+ run_hooks(:mournmail_draft_setup_hook)
137
+ end
138
+ end
139
+ end
140
+
141
+ define_local_command(:summary_forward,
142
+ doc: "Forward the current message.") do
143
+ uid = selected_uid
144
+ summary = Mournmail.current_summary
145
+ item = summary[uid]
146
+ Window.current = Mournmail.message_window
147
+ Commands.mail
148
+ re_search_forward(/^Subject: /)
149
+ insert("Forward: " + Mournmail.decode_eword(item.subject))
150
+ insert("\nAttached-Message: #{Mournmail.current_mailbox}/#{uid}")
151
+ re_search_backward(/^To: /)
152
+ end_of_line
153
+ end
154
+
155
+ define_local_command(:summary_toggle_seen,
156
+ doc: "Toggle Seen.") do
157
+ toggle_flag(selected_uid, :Seen)
158
+ end
159
+
160
+ define_local_command(:summary_toggle_flagged,
161
+ doc: "Toggle Flagged.") do
162
+ toggle_flag(selected_uid, :Flagged)
163
+ end
164
+
165
+ define_local_command(:summary_toggle_deleted,
166
+ doc: <<~EOD) do
167
+ Toggle Deleted. Type `x` to expunge deleted messages.
168
+ EOD
169
+ toggle_flag(selected_uid, :Deleted)
170
+ end
171
+
172
+ define_local_command(:summary_expunge,
173
+ doc: <<~EOD) do
174
+ Expunge deleted messages.
175
+ EOD
176
+ buffer = Buffer.current
177
+ Mournmail.background do
178
+ Mournmail.imap_connect do |imap|
179
+ imap.expunge
180
+ end
181
+ summary = Mournmail.current_summary
182
+ summary.delete_item_if do |item|
183
+ item.flags.include?(:Deleted)
184
+ end
185
+ summary_text = summary.to_s
186
+ summary.save
187
+ next_tick do
188
+ buffer.read_only_edit do
189
+ buffer.clear
190
+ buffer.insert(summary_text)
191
+ end
192
+ message("Expunged messages")
193
+ end
194
+ end
195
+ end
196
+
197
+ define_local_command(:summary_view_source,
198
+ doc: "View source of a mail.") do
199
+ uid = selected_uid
200
+ Mournmail.background do
201
+ mailbox = Mournmail.current_mailbox
202
+ source = Mournmail.read_mail(mailbox, uid)
203
+ next_tick do
204
+ source_buffer = Buffer.find_or_new("*message-source*",
205
+ file_encoding: "ascii-8bit",
206
+ undo_limit: 0, read_only: true)
207
+ source_buffer.read_only_edit do
208
+ source_buffer.clear
209
+ source_buffer.insert(source.gsub(/\r\n/, "\n"))
210
+ source_buffer.file_format = :dos
211
+ source_buffer.beginning_of_buffer
212
+ end
213
+ window = Mournmail.message_window
214
+ window.buffer = source_buffer
215
+ end
216
+ end
217
+ end
218
+
219
+ private
220
+
221
+ def selected_uid
222
+ uid = @buffer.save_excursion {
223
+ @buffer.beginning_of_line
224
+ if !@buffer.looking_at?(/\d+/)
225
+ Mournmail.current_mail = nil
226
+ Mournmail.current_uid = nil
227
+ raise EditorError, "No message found"
228
+ end
229
+ match_string(0).to_i
230
+ }
231
+ end
232
+
233
+ def scroll_up_or_next_uid
234
+ begin
235
+ uid = selected_uid
236
+ if uid == Mournmail.current_uid
237
+ window = Mournmail.message_window
238
+ if window.buffer.name == "*message*"
239
+ old_window = Window.current
240
+ begin
241
+ Window.current = window
242
+ scroll_up
243
+ return nil
244
+ ensure
245
+ Window.current = old_window
246
+ end
247
+ end
248
+ end
249
+ uid
250
+ rescue RangeError # may be raised by scroll_up
251
+ next_message
252
+ retry
253
+ end
254
+ end
255
+
256
+ def mark_as_seen(uid)
257
+ summary_item = Mournmail.current_summary[uid]
258
+ if summary_item && !summary_item.flags.include?(:Seen)
259
+ summary_item.set_flag(:Seen, update_server: false)
260
+ Mournmail.current_summary.save
261
+ update_flags(summary_item)
262
+ end
263
+ end
264
+
265
+ def toggle_flag(uid, flag)
266
+ summary_item = Mournmail.current_summary[uid]
267
+ if summary_item
268
+ Mournmail.background do
269
+ summary_item.toggle_flag(flag)
270
+ Mournmail.current_summary.save
271
+ next_tick do
272
+ update_flags(summary_item)
273
+ end
274
+ end
275
+ end
276
+ end
277
+
278
+ def update_flags(summary_item)
279
+ @buffer.read_only_edit do
280
+ @buffer.save_excursion do
281
+ @buffer.beginning_of_buffer
282
+ uid = summary_item.uid
283
+ flags_char = summary_item.flags_char
284
+ if @buffer.re_search_forward(/^#{uid} ./)
285
+ @buffer.replace_match("#{uid} #{flags_char}")
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ def next_message
292
+ @buffer.end_of_line
293
+ if @buffer.end_of_buffer?
294
+ raise EditorError, "No more mail"
295
+ end
296
+ begin
297
+ @buffer.re_search_forward(/^\d+ u/)
298
+ rescue SearchError
299
+ @buffer.forward_line
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+ require "mail-iso-2022-jp"
5
+ require "net/imap"
6
+ require "time"
7
+ require "fileutils"
8
+ require "timeout"
9
+
10
+ module Mournmail
11
+ begin
12
+ require "mail-gpg"
13
+ HAVE_MAIL_GPG = true
14
+ rescue LoadError
15
+ HAVE_MAIL_GPG = false
16
+ end
17
+
18
+ def self.define_variable(name, value = nil)
19
+ var_name = "@" + name.to_s
20
+ if !instance_variable_defined?(var_name)
21
+ instance_variable_set(var_name, value)
22
+ end
23
+ singleton_class.send(:attr_accessor, name)
24
+ end
25
+
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
31
+
32
+ def self.background
33
+ if background_thread&.alive?
34
+ raise EditorError, "Background thread already running"
35
+ end
36
+ self.background_thread = Utils.background {
37
+ begin
38
+ yield
39
+ ensure
40
+ self.background_thread = nil
41
+ end
42
+ }
43
+ end
44
+
45
+ def self.message_window
46
+ if Window.list.size == 1
47
+ split_window
48
+ shrink_window(Window.current.lines - 8)
49
+ end
50
+ windows = Window.list
51
+ i = (windows.index(Window.current) + 1) % windows.size
52
+ windows[i]
53
+ end
54
+
55
+ def self.back_to_summary
56
+ summary_window = Window.list.find { |window|
57
+ window.buffer.name == "*summary*"
58
+ }
59
+ if summary_window
60
+ Window.current = summary_window
61
+ end
62
+ end
63
+
64
+ def self.escape_binary(s)
65
+ s.b.gsub(/[\x80-\xff]/n) { |c|
66
+ "<%02X>" % c.ord
67
+ }
68
+ end
69
+
70
+ def self.decode_eword(s)
71
+ Mail::Encodings.decode_encode(s, :decode).
72
+ encode(Encoding::UTF_8, replace: "?").gsub(/[\t\n]/, " ")
73
+ rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError
74
+ escape_binary(s)
75
+ end
76
+
77
+ @imap = nil
78
+ @imap_mutex = Mutex.new
79
+
80
+ def self.imap_connect
81
+ @imap_mutex.synchronize do
82
+ if @imap.nil? || @imap.disconnected?
83
+ 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])
90
+ if Mournmail.current_mailbox
91
+ @imap.select(Mournmail.current_mailbox)
92
+ end
93
+ end
94
+ end
95
+ yield(@imap)
96
+ end
97
+ rescue IOError, Errno::ECONNRESET
98
+ imap_disconnect
99
+ raise
100
+ end
101
+
102
+ def self.imap_disconnect
103
+ @imap_mutex.synchronize do
104
+ if @imap
105
+ @imap.disconnect rescue nil
106
+ @imap = nil
107
+ end
108
+ end
109
+ end
110
+
111
+ def self.fetch_summary(mailbox, all: false)
112
+ imap_connect do |imap|
113
+ imap.select(mailbox)
114
+ if all
115
+ summary = Mournmail::Summary.new(mailbox)
116
+ else
117
+ summary = Mournmail::Summary.load_or_new(mailbox)
118
+ end
119
+ first_uid = (summary.last_uid || 0) + 1
120
+ data = imap.uid_fetch(first_uid..-1, ["UID", "ENVELOPE", "FLAGS"])
121
+ 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)
130
+ end
131
+ end
132
+ summary
133
+ end
134
+ end
135
+
136
+ def self.read_mail(mailbox, uid)
137
+ path = File.expand_path("cache/#{mailbox}/#{uid}",
138
+ CONFIG[:mournmail_directory])
139
+ begin
140
+ File.open(path) do |f|
141
+ f.flock(File::LOCK_SH)
142
+ f.read
143
+ end
144
+ rescue Errno::ENOENT
145
+ imap_connect do |imap|
146
+ imap.select(mailbox)
147
+ data = imap.uid_fetch(uid, "BODY[]")
148
+ if data.empty?
149
+ raise EditorError, "No such mail: #{uid}"
150
+ end
151
+ s = data[0].attr["BODY[]"]
152
+ FileUtils.mkdir_p(File.dirname(path))
153
+ File.open(path, "w", 0600) do |f|
154
+ f.flock(File::LOCK_EX)
155
+ f.write(s)
156
+ end
157
+ s
158
+ end
159
+ end
160
+ end
161
+ end