vmail 0.0.1

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.
data/lib/vmail.rb ADDED
@@ -0,0 +1,45 @@
1
+ require 'vmail/imap_client'
2
+
3
+ module Vmail
4
+ extend self
5
+
6
+ def start
7
+ config = YAML::load(File.read(File.expand_path("~/gmail.yml")))
8
+ config.merge! 'logfile' => "vmail.log"
9
+
10
+ puts "starting vmail imap client with config #{config}"
11
+
12
+ drb_uri = Vmail::ImapClient.daemon config
13
+
14
+ server = DRbObject.new_with_uri drb_uri
15
+ server.window_width = `stty size`.strip.split(' ')[1]
16
+ server.select_mailbox ARGV.shift || 'INBOX'
17
+
18
+ query = ARGV.empty? ? [100, 'ALL'] : nil
19
+
20
+ buffer_file = "vmail-buffer.txt"
21
+ File.open(buffer_file, "w") do |file|
22
+ file.puts server.search(*query)
23
+ end
24
+
25
+ # invoke vim
26
+ # TODO
27
+ # - mvim; move viewer.vim to new file
28
+
29
+ vimscript = "viewer.vim"
30
+ system("DRB_URI='#{drb_uri}' vim -S #{vimscript} #{buffer_file}")
31
+
32
+ File.delete(buffer_file)
33
+
34
+ puts "closing imap connection"
35
+ begin
36
+ Timeout::timeout(5) do
37
+ $gmail.close
38
+ end
39
+ rescue Timeout::Error
40
+ puts "close connection attempt timed out"
41
+ end
42
+ puts "bye"
43
+ exit
44
+ end
45
+ end
@@ -0,0 +1,495 @@
1
+ require 'drb'
2
+ require 'vmail/message_formatter'
3
+ require 'vmail/string_ext'
4
+ require 'yaml'
5
+ require 'mail'
6
+ require 'net/imap'
7
+ require 'time'
8
+ require 'logger'
9
+
10
+ module Vmail
11
+ class ImapClient
12
+
13
+ MailboxAliases = { 'sent' => '[Gmail]/Sent Mail',
14
+ 'all' => '[Gmail]/All Mail',
15
+ 'starred' => '[Gmail]/Starred',
16
+ 'important' => '[Gmail]/Important',
17
+ 'drafts' => '[Gmail]/Drafts',
18
+ 'spam' => '[Gmail]/Spam',
19
+ 'trash' => '[Gmail]/Trash'
20
+ }
21
+
22
+ def initialize(config)
23
+ @username, @password = config['username'], config['password']
24
+ @name = config['name']
25
+ @signature = config['signature']
26
+ @mailbox = nil
27
+ @logger = Logger.new(config['logfile'] || STDERR)
28
+ @logger.level = Logger::DEBUG
29
+ @current_message = nil
30
+ end
31
+
32
+ def open
33
+ @imap = Net::IMAP.new('imap.gmail.com', 993, true, nil, false)
34
+ @imap.login(@username, @password)
35
+ end
36
+
37
+ def close
38
+ log "closing connection"
39
+ @imap.close rescue Net::IMAP::BadResponseError
40
+ @imap.disconnect
41
+ end
42
+
43
+ def select_mailbox(mailbox)
44
+ if MailboxAliases[mailbox]
45
+ mailbox = MailboxAliases[mailbox]
46
+ end
47
+ if mailbox == @mailbox
48
+ return
49
+ end
50
+ log "selecting mailbox #{mailbox.inspect}"
51
+ reconnect_if_necessary do
52
+ @imap.select(mailbox)
53
+ end
54
+ @mailbox = mailbox
55
+ @all_uids = []
56
+ @bad_uids = []
57
+ return "OK"
58
+ end
59
+
60
+ def revive_connection
61
+ log "reviving connection"
62
+ open
63
+ log "reselecting mailbox #@mailbox"
64
+ @imap.select(@mailbox)
65
+ end
66
+
67
+ def list_mailboxes
68
+ @mailboxes ||= (@imap.list("[Gmail]/", "%") + @imap.list("", "%")).
69
+ select {|struct| struct.attr.none? {|a| a == :Noselect} }.
70
+ map {|struct| struct.name}.
71
+ map {|name| MailboxAliases.invert[name] || name}
72
+ @mailboxes.delete("INBOX")
73
+ @mailboxes.unshift("INBOX")
74
+ @mailboxes.join("\n")
75
+ end
76
+
77
+ def fetch_headers(uid_set)
78
+ if uid_set.is_a?(String)
79
+ uid_set = uid_set.split(",").map(&:to_i)
80
+ elsif uid_set.is_a?(Integer)
81
+ uid_set = [uid_set]
82
+ end
83
+ max_uid = uid_set.max
84
+ log "fetch headers for #{uid_set.inspect}"
85
+ if uid_set.empty?
86
+ log "empty set"
87
+ return ""
88
+ end
89
+ results = reconnect_if_necessary do
90
+ @imap.uid_fetch(uid_set, ["FLAGS", "ENVELOPE", "RFC822.SIZE" ])
91
+ end
92
+ log "extracting headers"
93
+ lines = results.sort_by {|x| Time.parse(x.attr['ENVELOPE'].date)}.map {|x| format_header(x, max_uid)}
94
+ log "returning result"
95
+ return lines.join("\n")
96
+ end
97
+
98
+ def format_header(fetch_data, max_uid=nil)
99
+ uid = fetch_data.attr["UID"]
100
+ envelope = fetch_data.attr["ENVELOPE"]
101
+ size = fetch_data.attr["RFC822.SIZE"]
102
+ flags = fetch_data.attr["FLAGS"]
103
+ address_struct = if @mailbox == '[Gmail]/Sent Mail'
104
+ structs = envelope.to || envelope.cc
105
+ structs.nil? ? nil : structs.first
106
+ else
107
+ envelope.from.first
108
+ end
109
+ address = if address_struct.nil?
110
+ "unknown"
111
+ elsif address_struct.name
112
+ "#{Mail::Encodings.unquote_and_convert_to(address_struct.name, 'utf-8')} <#{[address_struct.mailbox, address_struct.host].join('@')}>"
113
+ else
114
+ [address_struct.mailbox, address_struct.host].join('@')
115
+ end
116
+ if @mailbox == '[Gmail]/Sent Mail' && envelope.to && envelope.cc
117
+ total_recips = (envelope.to + envelope.cc).size
118
+ address += " + #{total_recips - 1}"
119
+ end
120
+ date = Time.parse(envelope.date).localtime
121
+ date_formatted = if date.year != Time.now.year
122
+ date.strftime "%b %d %Y" rescue envelope.date.to_s
123
+ else
124
+ date.strftime "%b %d %I:%M%P" rescue envelope.date.to_s
125
+ end
126
+ subject = envelope.subject || ''
127
+ subject = Mail::Encodings.unquote_and_convert_to(subject, 'utf-8')
128
+ flags = format_flags(flags)
129
+ first_col_width = max_uid.to_s.length
130
+ mid_width = @width - (first_col_width + 14 + 2) - (10 + 2) - 5
131
+ address_col_width = (mid_width * 0.3).ceil
132
+ subject_col_width = (mid_width * 0.7).floor
133
+ [uid.to_s.col(first_col_width),
134
+ (date_formatted || '').col(14),
135
+ address.col(address_col_width),
136
+ subject.encode('utf-8').col(subject_col_width),
137
+ number_to_human_size(size).rcol(6),
138
+ flags.rcol(7)].join(' ')
139
+ end
140
+
141
+ UNITS = [:b, :kb, :mb, :gb].freeze
142
+
143
+ # borrowed from ActionView/Helpers
144
+ def number_to_human_size(number)
145
+ if number.to_i < 1024
146
+ "#{number} b"
147
+ else
148
+ max_exp = UNITS.size - 1
149
+ exponent = (Math.log(number) / Math.log(1024)).to_i # Convert to base 1024
150
+ exponent = max_exp if exponent > max_exp # we need this to avoid overflow for the highest unit
151
+ number /= 1024 ** exponent
152
+ unit = UNITS[exponent]
153
+ "#{number} #{unit}"
154
+ end
155
+ end
156
+
157
+
158
+ FLAGMAP = {:Flagged => '[*]'}
159
+ # flags is an array like [:Flagged, :Seen]
160
+ def format_flags(flags)
161
+ flags = flags.map {|flag| FLAGMAP[flag] || flag}
162
+ if flags.delete(:Seen).nil?
163
+ flags << '[+]' # unread
164
+ end
165
+ flags.join('')
166
+ end
167
+
168
+ def search(limit, *query)
169
+ log "uid_search limit: #{limit} query: #{@query.inspect}"
170
+ limit = 25 if limit.to_s !~ /^\d+$/
171
+ query = ['ALL'] if query.empty?
172
+ @query = query.join(' ')
173
+ log "uid_search #@query #{limit}"
174
+ @all_uids = reconnect_if_necessary do
175
+ @imap.uid_search(@query)
176
+ end
177
+ uids = @all_uids[-([limit.to_i, @all_uids.size].min)..-1] || []
178
+ res = fetch_headers(uids)
179
+ add_more_message_line(res, uids)
180
+ end
181
+
182
+ def update
183
+ reconnect_if_necessary(4) do
184
+ # this is just to prime the IMAP connection
185
+ # It's necessary for some reason.
186
+ log "priming connection for update"
187
+ res = @imap.uid_fetch(@all_uids[-1], ["ENVELOPE"])
188
+ if res.nil?
189
+ raise IOError, "IMAP connection seems broken"
190
+ end
191
+ end
192
+ uids = reconnect_if_necessary {
193
+ log "uid_search #@query"
194
+ @imap.uid_search(@query)
195
+ }
196
+ new_uids = uids - @all_uids
197
+ log "UPDATE: NEW UIDS: #{new_uids.inspect}"
198
+ if !new_uids.empty?
199
+ res = fetch_headers(new_uids)
200
+ @all_uids = uids
201
+ res
202
+ end
203
+ end
204
+
205
+ # gets 100 messages prior to uid
206
+ def more_messages(uid, limit=100)
207
+ uid = uid.to_i
208
+ x = [(@all_uids.index(uid) - limit), 0].max
209
+ y = [@all_uids.index(uid) - 1, 0].max
210
+ uids = @all_uids[x..y]
211
+ res = fetch_headers(uids)
212
+ add_more_message_line(res, uids)
213
+ end
214
+
215
+ def add_more_message_line(res, uids)
216
+ return res if uids.empty?
217
+ start_index = @all_uids.index(uids[0])
218
+ if start_index > 0
219
+ remaining = start_index
220
+ res = "> Load #{[100, remaining].min} more messages. #{remaining} remaining.\n" + res
221
+ end
222
+ res
223
+ end
224
+
225
+ def lookup(uid, raw=false, forwarded=false)
226
+ if raw
227
+ return @current_message.to_s
228
+ end
229
+ log "fetching #{uid.inspect}"
230
+ fetch_data = reconnect_if_necessary do
231
+ @imap.uid_fetch(uid.to_i, ["FLAGS", "RFC822", "RFC822.SIZE"])[0]
232
+ end
233
+ res = fetch_data.attr["RFC822"]
234
+ mail = Mail.new(res)
235
+ @current_message = mail # used later to show raw message or extract attachments if any
236
+ formatter = MessageFormatter.new(mail)
237
+ part = formatter.find_text_part
238
+ out = formatter.process_body
239
+ size = fetch_data.attr["RFC822.SIZE"]
240
+ message = <<-EOF
241
+ #{@mailbox} #{uid} #{number_to_human_size size} #{forwarded ? nil : format_parts_info(formatter.list_parts)}
242
+ ----------------------------------------
243
+ #{format_headers(formatter.extract_headers)}
244
+
245
+ #{out}
246
+ EOF
247
+ end
248
+
249
+ def format_parts_info(parts)
250
+ lines = parts.select {|part| part !~ %r{text/plain}}
251
+ if lines.size > 0
252
+ "\n#{lines.join("\n")}"
253
+ end
254
+ end
255
+
256
+ # uid_set is a string comming from the vim client
257
+ # action is -FLAGS or +FLAGS
258
+ def flag(uid_set, action, flg)
259
+ if uid_set.is_a?(String)
260
+ uid_set = uid_set.split(",").map(&:to_i)
261
+ end
262
+ # #<struct Net::IMAP::FetchData seqno=17423, attr={"FLAGS"=>[:Seen, "Flagged"], "UID"=>83113}>
263
+ log "flag #{uid_set} #{flg} #{action}"
264
+ if flg == 'Deleted'
265
+ # for delete, do in a separate thread because deletions are slow
266
+ Thread.new do
267
+ @imap.uid_copy(uid_set, "[Gmail]/Trash")
268
+ res = @imap.uid_store(uid_set, action, [flg.to_sym])
269
+ end
270
+ uid_set.each { |uid| @all_uids.delete(uid) }
271
+ elsif flg == '[Gmail]/Spam'
272
+ @imap.uid_copy(uid_set, "[Gmail]/Spam")
273
+ res = @imap.uid_store(uid_set, action, [:Deleted])
274
+ "#{uid} deleted"
275
+ else
276
+ log "Flagging"
277
+ res = @imap.uid_store(uid_set, action, [flg.to_sym])
278
+ # log res.inspect
279
+ fetch_headers(uid_set)
280
+ end
281
+ end
282
+
283
+ # uid_set is a string comming from the vim client
284
+ def move_to(uid_set, mailbox)
285
+ if MailboxAliases[mailbox]
286
+ mailbox = MailboxAliases[mailbox]
287
+ end
288
+ log "move_to #{uid_set.inspect} #{mailbox}"
289
+ if uid_set.is_a?(String)
290
+ uid_set = uid_set.split(",").map(&:to_i)
291
+ end
292
+ log @imap.uid_copy(uid_set, mailbox)
293
+ log @imap.uid_store(uid_set, '+FLAGS', [:Deleted])
294
+ end
295
+
296
+ # TODO mark spam
297
+
298
+ def new_message_template
299
+ headers = {'from' => "#{@name} <#{@username}>",
300
+ 'to' => nil,
301
+ 'subject' => nil
302
+ }
303
+ format_headers(headers) + "\n\n"
304
+ end
305
+
306
+ def format_headers(hash)
307
+ lines = []
308
+ hash.each_pair do |key, value|
309
+ if value.is_a?(Array)
310
+ value = value.join(", ")
311
+ end
312
+ lines << "#{key.gsub("_", '-')}: #{value}"
313
+ end
314
+ lines.join("\n")
315
+ end
316
+
317
+ def reply_template(uid, replyall=false)
318
+ log "sending reply template for #{uid}"
319
+ fetch_data = @imap.uid_fetch(uid.to_i, ["FLAGS", "ENVELOPE", "RFC822"])[0]
320
+ envelope = fetch_data.attr['ENVELOPE']
321
+ recipient = [envelope.reply_to, envelope.from].flatten.map {|x| address_to_string(x)}[0]
322
+ cc = [envelope.to, envelope.cc]
323
+ cc = cc.flatten.compact.
324
+ select {|x| @username !~ /#{x.mailbox}@#{x.host}/}.
325
+ map {|x| address_to_string(x)}.join(", ")
326
+ mail = Mail.new fetch_data.attr['RFC822']
327
+ formatter = MessageFormatter.new(mail)
328
+ headers = formatter.extract_headers
329
+ subject = headers['subject']
330
+ if subject !~ /Re: /
331
+ subject = "Re: #{subject}"
332
+ end
333
+ cc = replyall ? cc : nil
334
+ date = headers['date'].is_a?(String) ? Time.parse(headers['date']) : headers['date']
335
+ quote_header = "On #{date.strftime('%a, %b %d, %Y at %I:%M %p')}, #{recipient} wrote:\n\n"
336
+ body = quote_header + formatter.process_body.gsub(/^(?=>)/, ">").gsub(/^(?!>)/, "> ")
337
+ reply_headers = { 'from' => "#@name <#@username>", 'to' => recipient, 'cc' => cc, 'subject' => headers['subject']}
338
+ format_headers(reply_headers) + "\n\n\n" + body + signature
339
+ end
340
+
341
+ def address_to_string(x)
342
+ x.name ? "#{x.name} <#{x.mailbox}@#{x.host}>" : "#{x.mailbox}@#{x.host}"
343
+ end
344
+
345
+ def signature
346
+ return '' unless @signature
347
+ "\n\n#@signature"
348
+ end
349
+
350
+ # TODO, forward with attachments
351
+ def forward_template(uid)
352
+ original_body = lookup(uid, false, true)
353
+ new_message_template +
354
+ "\n---------- Forwarded message ----------\n" +
355
+ original_body + signature
356
+ end
357
+
358
+ def deliver(text)
359
+ # parse the text. The headers are yaml. The rest is text body.
360
+ require 'net/smtp'
361
+ mail = new_mail_from_input(text)
362
+ mail.delivery_method(*smtp_settings)
363
+ mail.deliver!
364
+ "message '#{mail.subject}' sent"
365
+ end
366
+
367
+ def save_draft(text)
368
+ mail = new_mail_from_input(text)
369
+ log mail.to_s
370
+ reconnect_if_necessary do
371
+ log "saving draft"
372
+ log @imap.append("[Gmail]/Drafts", text.gsub(/\n/, "\r\n"), [:Seen], Time.now)
373
+ end
374
+ end
375
+
376
+ def new_mail_from_input(text)
377
+ require 'mail'
378
+ mail = Mail.new
379
+ raw_headers, body = *text.split(/\n\s*\n/, 2)
380
+ # handle attachments
381
+ if (attachments = body.split(/\n\s*\n/, 2)[0]) =~ /^attach:/
382
+ # TODO
383
+ log "attachments: #{YAML::load(attachments).inspect}"
384
+ end
385
+ headers = {}
386
+ raw_headers.split("\n").each do |line|
387
+ key, value = *line.split(/:\s*/, 2)
388
+ headers[key] = value
389
+ end
390
+ log "headers: #{headers.inspect}"
391
+ log "delivering: #{headers.inspect}"
392
+ mail.from = headers['from'] || @username
393
+ mail.to = headers['to'] #.split(/,\s+/)
394
+ mail.cc = headers['cc'] #&& headers['cc'].split(/,\s+/)
395
+ mail.bcc = headers['bcc'] #&& headers['cc'].split(/,\s+/)
396
+ mail.subject = headers['subject']
397
+ mail.from ||= @username
398
+ mail.body = body
399
+ mail
400
+ end
401
+
402
+ def save_attachments(dir)
403
+ log "save_attachments #{dir}"
404
+ if !@current_message
405
+ log "missing a current message"
406
+ end
407
+ return unless dir && @current_message
408
+ attachments = @current_message.attachments
409
+ `mkdir -p #{dir}`
410
+ attachments.each do |x|
411
+ path = File.join(dir, x.filename)
412
+ log "saving #{path}"
413
+ File.open(path, 'wb') {|f| f.puts x.decoded}
414
+ end
415
+ end
416
+
417
+ def open_html_part(uid)
418
+ log "open_html_part #{uid}"
419
+ fetch_data = @imap.uid_fetch(uid.to_i, ["RFC822"])[0]
420
+ mail = Mail.new(fetch_data.attr['RFC822'])
421
+ multipart = mail.parts.detect {|part| part.multipart?}
422
+ html_part = (multipart || mail).parts.detect {|part| part.header["Content-Type"].to_s =~ /text\/html/}
423
+ outfile = 'htmlpart.html'
424
+ File.open(outfile, 'w') {|f| f.puts(html_part.decoded)}
425
+ # client should handle opening the html file
426
+ return outfile
427
+ end
428
+
429
+ def window_width=(width)
430
+ log "setting window width to #{width}"
431
+ @width = width.to_i
432
+ end
433
+
434
+ def smtp_settings
435
+ [:smtp, {:address => "smtp.gmail.com",
436
+ :port => 587,
437
+ :domain => 'gmail.com',
438
+ :user_name => @username,
439
+ :password => @password,
440
+ :authentication => 'plain',
441
+ :enable_starttls_auto => true}]
442
+ end
443
+
444
+ def log(string)
445
+ @logger.debug string
446
+ end
447
+
448
+ def handle_error(error)
449
+ log error
450
+ end
451
+
452
+ def reconnect_if_necessary(timeout = 60, &block)
453
+ # if this times out, we know the connection is stale while the user is
454
+ # trying to update
455
+ Timeout::timeout(timeout) do
456
+ block.call
457
+ end
458
+ rescue IOError, Errno::EADDRNOTAVAIL, Timeout::Error
459
+ log "error: #{$!}"
460
+ log "attempting to reconnect"
461
+ log(revive_connection)
462
+ # try just once
463
+ block.call
464
+ end
465
+
466
+ def self.start(config)
467
+ imap_client = Vmail::ImapClient.new config
468
+ imap_client.open
469
+ imap_client
470
+ end
471
+
472
+ def self.daemon(config)
473
+ $gmail = self.start(config)
474
+ puts DRb.start_service(nil, $gmail)
475
+ uri = DRb.uri
476
+ puts "starting gmail service at #{uri}"
477
+ uri
478
+ end
479
+ end
480
+ end
481
+
482
+ trap("INT") {
483
+ require 'timeout'
484
+ puts "closing imap connection"
485
+ begin
486
+ Timeout::timeout(5) do
487
+ $gmail.close
488
+ end
489
+ rescue Timeout::Error
490
+ puts "close connection attempt timed out"
491
+ end
492
+ exit
493
+ }
494
+
495
+