]> git.cworth.org Git - sup/blob - lib/sup/modes/edit-message-mode.rb
check for missing attachments and top-posting
[sup] / lib / sup / modes / edit-message-mode.rb
1 require 'tempfile'
2 require 'socket' # just for gethostname!
3 require 'pathname'
4 require 'rmail'
5
6 module Redwood
7
8 class SendmailCommandFailed < StandardError; end
9
10 class EditMessageMode < LineCursorMode
11   FORCE_HEADERS = %w(From To Cc Bcc Subject)
12   MULTI_HEADERS = %w(To Cc Bcc)
13   NON_EDITABLE_HEADERS = %w(Message-Id Date)
14
15   attr_reader :status
16   attr_accessor :body, :header
17   bool_reader :edited
18
19   register_keymap do |k|
20     k.add :send_message, "Send message", 'y'
21     k.add :edit_field, "Edit field", 'e'
22     k.add :edit_message, "Edit message", :enter
23     k.add :save_as_draft, "Save as draft", 'P'
24     k.add :attach_file, "Attach a file", 'a'
25     k.add :delete_attachment, "Delete an attachment", 'd'
26   end
27
28   def initialize opts={}
29     @header = opts.delete(:header) || {} 
30     @header_lines = []
31
32     @body = opts.delete(:body) || []
33     @body += sig_lines if $config[:edit_signature]
34
35     @attachments = []
36     @message_id = "<#{Time.now.to_i}-sup-#{rand 10000}@#{Socket.gethostname}>"
37     @edited = false
38     @skip_top_rows = opts[:skip_top_rows] || 0
39
40     super opts
41     regen_text
42   end
43
44   def lines; @text.length end
45   def [] i; @text[i] end
46
47   ## a hook
48   def handle_new_text header, body; end
49
50   def edit_field
51     if (curpos - @skip_top_rows) >= @header_lines.length
52       edit_message
53     else
54       case(field = @header_lines[curpos - @skip_top_rows])
55       when "Subject"
56         text = BufferManager.ask :subject, "Subject: ", @header[field]
57         @header[field] = parse_header field, text if text
58       else
59         default =
60           case field
61           when *MULTI_HEADERS
62             @header[field].join(", ")
63           else
64             @header[field]
65           end
66
67         contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
68         if contacts
69           text = contacts.map { |s| s.longname }.join(", ")
70           @header[field] = parse_header field, text
71         end
72       end
73       update
74     end
75   end
76
77   def edit_message
78     @file = Tempfile.new "sup.#{self.class.name.gsub(/.*::/, '').camel_to_hyphy}"
79     @file.puts format_headers(@header - NON_EDITABLE_HEADERS).first
80     @file.puts
81     @file.puts @body
82     @file.close
83
84     editor = $config[:editor] || ENV['EDITOR'] || "/usr/bin/vi"
85
86     mtime = File.mtime @file.path
87     BufferManager.shell_out "#{editor} #{@file.path}"
88     @edited = true if File.mtime(@file.path) > mtime
89
90     BufferManager.kill_buffer self.buffer unless @edited
91
92     header, @body = parse_file @file.path
93     @header = header - NON_EDITABLE_HEADERS
94     handle_new_text @header, @body
95     update
96   end
97
98   def killable?
99     !edited? || BufferManager.ask_yes_or_no("Discard message?")
100   end
101
102   def attach_file
103     fn = BufferManager.ask_for_filenames :attachment, "File name (enter for browser): "
104     fn.each { |f| @attachments << Pathname.new(f) }
105     update
106   end
107
108   def delete_attachment
109     i = (curpos - @skip_top_rows) - @attachment_lines_offset
110     if i >= 0 && i < @attachments.size && BufferManager.ask_yes_or_no("Delete attachment #{@attachments[i]}?")
111       @attachments.delete_at i
112       update
113     end
114   end
115
116 protected
117
118   def update
119     regen_text
120     buffer.mark_dirty if buffer
121   end
122
123   def regen_text
124     header, @header_lines = format_headers(@header - NON_EDITABLE_HEADERS) + [""]
125     @text = header + [""] + @body
126     @text += sig_lines unless $config[:edit_signature]
127
128     unless @attachments.empty?
129       @text += [""]
130       @attachment_lines_offset = @text.length
131       @text += @attachments.map { |f| [[:attachment_color, "+ Attachment: #{f} (#{f.human_size})"]] }
132     end
133   end
134
135   def parse_file fn
136     File.open(fn) do |f|
137       header = MBox::read_header f
138       body = f.readlines
139
140       header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
141       header.each { |k, v| header[k] = parse_header k, v }
142
143       [header, body]
144     end
145   end
146
147   def parse_header k, v
148     if MULTI_HEADERS.include?(k)
149       v.split_on_commas.map do |name|
150         (p = ContactManager.contact_for(name)) && p.full_address || name
151       end
152     else
153       v
154     end
155   end
156
157   def format_headers header
158     header_lines = []
159     headers = (FORCE_HEADERS + (header.keys - FORCE_HEADERS)).map do |h|
160       lines = make_lines "#{h}:", header[h]
161       lines.length.times { header_lines << h }
162       lines
163     end.flatten.compact
164     [headers, header_lines]
165   end
166
167   def make_lines header, things
168     case things
169     when nil, []
170       [header + " "]
171     when String
172       [header + " " + things]
173     else
174       if things.empty?
175         [header]
176       else
177         things.map_with_index do |name, i|
178           raise "an array: #{name.inspect} (things #{things.inspect})" if Array === name
179           if i == 0
180             header + " " + name
181           else
182             (" " * (header.length + 1)) + name
183           end + (i == things.length - 1 ? "" : ",")
184         end
185       end
186     end
187   end
188
189   def send_message
190     return unless edited? || BufferManager.ask_yes_or_no("Message unedited. Really send?")
191     return unless $config[:confirm_no_attachments] && mentions_attachments? && @attachments.size == 0 && BufferManager.ask_yes_or_no("You haven't added any attachments. Really send?")#" stupid ruby-mode
192     return unless $config[:confirm_top_posting] && top_posting? && BufferManager.ask_yes_or_no("You're top posting. That makes you a bad person. Really send?") #" stupid ruby-mode
193
194     date = Time.now
195     from_email = 
196       if @header["From"] =~ /<?(\S+@(\S+?))>?$/
197         $1
198       else
199         AccountManager.default_account.email
200       end
201
202     acct = AccountManager.account_for(from_email) || AccountManager.default_account
203     BufferManager.flash "Sending..."
204
205     begin
206       IO.popen(acct.sendmail, "w") { |p| write_full_message_to p, date }
207       raise SendmailCommandFailed, "Couldn't execute #{acct.sendmail}" unless $? == 0
208       SentManager.write_sent_message(date, from_email) { |f| write_full_message_to f, date }
209       BufferManager.kill_buffer buffer
210       BufferManager.flash "Message sent!"
211     rescue SystemCallError, SendmailCommandFailed => e
212       Redwood::log "Problem sending mail: #{e.message}"
213       BufferManager.flash "Problem sending mail: #{e.message}"
214     end
215   end
216
217   def save_as_draft
218     DraftManager.write_draft { |f| write_message f, false }
219     BufferManager.kill_buffer buffer
220     BufferManager.flash "Saved for later editing."
221   end
222
223   def write_full_message_to f, date=Time.now
224     m = RMail::Message.new
225     @header.each do |k, v|
226       next if v.nil? || v.empty?
227       m.header[k] = 
228         case v
229         when String
230           v
231         when Array
232           v.join ", "
233         end
234     end
235
236     m.header["Date"] = date.rfc2822
237     m.header["Message-Id"] = @message_id
238     m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
239
240     if @attachments.empty?
241       m.header["Content-Disposition"] = "inline"
242       m.header["Content-Type"] = "text/plain; charset=#{$encoding}"
243       m.body = @body.join
244       m.body += sig_lines.join("\n") unless $config[:edit_signature]
245     else
246       body_m = RMail::Message.new
247       body_m.body = @body.join
248       body_m.body += sig_lines.join("\n") unless $config[:edit_signature]
249       
250       m.add_part body_m
251       @attachments.each { |fn| m.add_attachment fn.to_s }
252     end
253     f.puts m.to_s
254   end
255
256   ## this is going to change soon: draft messages (currently written
257   ## with full=false) will be output as yaml.
258   def write_message f, full=true, date=Time.now
259     raise ArgumentError, "no pre-defined date: header allowed" if @header["Date"]
260     f.puts format_headers(@header).first
261     f.puts <<EOS
262 Date: #{date.rfc2822}
263 Message-Id: #{@message_id}
264 EOS
265     if full
266       f.puts <<EOS
267 Mime-Version: 1.0
268 Content-Type: text/plain; charset=us-ascii
269 Content-Disposition: inline
270 User-Agent: Redwood/#{Redwood::VERSION}
271 EOS
272     end
273
274     f.puts
275     f.puts @body.map { |l| l =~ /^From / ? ">#{l}" : l }
276     f.puts sig_lines if full unless $config[:edit_signature]
277   end  
278
279 private
280
281   def mentions_attachments?
282     @body.any? { |l| l =~ /^[^>]/ && l =~ /\battach(ment|ed|ing|)\b/i }
283   end
284
285   def top_posting?
286     @body.join =~ /(\S+)\s*Excerpts from /
287   end
288
289   def sig_lines
290     p = PersonManager.person_for @header["From"]
291     sigfn = (AccountManager.account_for(p.email) || 
292              AccountManager.default_account).signature
293
294     if sigfn && File.exists?(sigfn)
295       ["", "-- "] + File.readlines(sigfn).map { |l| l.chomp }
296     else
297       []
298     end
299   end
300 end
301
302 end