lib/sup/tagger.rb
lib/sup/textfield.rb
lib/sup/thread.rb
+lib/sup/undo.rb
lib/sup/update.rb
lib/sup/util.rb
- Unix-centrism in MIME attachment handling and in sendmail
invocation.
-- Several obvious missing features, like undo, filters / saved
+- Several obvious missing features, like filters / saved
searches, message annotations, etc.
== SYNOPSYS:
k.add :quit_now, "Quit Sup immediately", 'Q'
k.add :help, "Show help", '?'
k.add :roll_buffers, "Switch to next buffer", 'b'
-# k.add :roll_buffers_backwards, "Switch to previous buffer", 'B'
+ k.add :roll_buffers_backwards, "Switch to previous buffer", 'B'
k.add :kill_buffer, "Kill the current buffer", 'x'
- k.add :list_buffers, "List all buffers", 'B'
+ k.add :list_buffers, "List all buffers", ';'
k.add :list_contacts, "List contacts", 'C'
k.add :redraw, "Redraw screen", :ctrl_l
k.add :search, "Search all messages", '\\', 'F'
k.add :recall_draft, "Edit most recent draft message", 'R'
end
+## the following magic enables wide characters when used with a ruby
+## ncurses.so that's been compiled against libncursesw. (note the w.) why
+## this works, i have no idea. much like pretty much every aspect of
+## dealing with curses. cargo cult programming at its best.
+##
+## BSD users: if libc.so.6 is not found, try installing compat6x.
+require 'dl/import'
+module LibC
+ extend DL::Importable
+ setlocale_lib = case Config::CONFIG['arch']
+ when /darwin/; "libc.dylib"
+ when /cygwin/; "cygwin1.dll"
+ else; "libc.so.6"
+ end
+
+ Redwood::log "dynamically loading setlocale() from #{setlocale_lib}"
+ begin
+ dlload setlocale_lib
+ extern "void setlocale(int, const char *)"
+ Redwood::log "setting locale..."
+ LibC.setlocale(6, "") # LC_ALL == 6
+ rescue RuntimeError => e
+ Redwood::log "cannot dlload setlocale(); ncurses wide character support probably broken."
+ Redwood::log "dlload error was #{e.class}: #{e.message}"
+ if Config::CONFIG['arch'] =~ /bsd/
+ Redwood::log "BSD variant detected. You may have to install a compat6x package to acquire libc."
+ end
+ end
+end
+
def start_cursing
Ncurses.initscr
Ncurses.noecho
when :kill_buffer
bm.kill_buffer_safely bm.focus_buf
when :list_buffers
- bm.spawn_unless_exists("Buffer List") { BufferListMode.new }
+ bm.spawn_unless_exists("buffer list", :system => true) { BufferListMode.new }
when :list_contacts
b, new = bm.spawn_unless_exists("Contact List") { ContactListMode.new }
b.mode.load_in_background if new
when :search_unread
SearchResultsMode.spawn_from_query "is:unread"
when :list_labels
- labels = LabelManager.listable_labels.map { |l| LabelManager.string_for l }
+ labels = LabelManager.all_labels.map { |l| LabelManager.string_for l }
user_label = bm.ask_with_completions :label, "Show threads with label (enter for listing): ", labels
unless user_label.nil?
if user_label.empty?
## nothin! use default source labels
end
+ if Time.now - last_info_time > PROGRESS_UPDATE_INTERVAL
+ last_info_time = Time.now
+ elapsed = last_info_time - start_time
+ pctdone = source.respond_to?(:pct_done) ? source.pct_done : 100.0 * (source.cur_offset.to_f - source.start_offset).to_f / (source.end_offset - source.start_offset).to_f
+ remaining = (100.0 - pctdone) * (elapsed.to_f / pctdone)
+ $stderr.printf "## read %dm (about %.0f%%) @ %.1fm/s. %s elapsed, about %s remaining\n", num_scanned, pctdone, num_scanned / elapsed, elapsed.to_time_s, remaining.to_time_s
+ end
+
if index_state.nil?
puts "Adding message #{source}##{offset} from #{m.from} with state {#{m.labels * ', '}}" if opts[:verbose]
num_added += 1
--- /dev/null
+#compdef sup sup-add sup-config sup-dump sup-sync sup-sync-back sup-tweak-labels sup-recover-sources
+# vim: set et sw=2 sts=2 ts=2 ft=zsh :
+
+# TODO: sources completion: maildir://some/dir, mbox://some/file, ...
+# for sup-add, sup-sync, sup-sync-back, sup-tweak-labels
+
+(( ${+functions[_sup_cmd]} )) ||
+_sup_cmd()
+{
+ _arguments -s : \
+ "(--list-hooks -l)"{--list-hooks,-l}"[list all hooks and descriptions, and quit]" \
+ "(--no-threads -n)"{--no-threads,-n}"[turn off threading]" \
+ "(--no-initial-poll -o)"{--no-initial-poll,-o}"[Don't poll for new messages when starting]" \
+ "(--search -s)"{--search,-s}"[search for this query upon startup]:Query: " \
+ "(--compose -c)"{--compose,-c}"[compose message to this recipient upon startup]:Email: " \
+ "--version[show version information]" \
+ "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_add_cmd]} )) ||
+_sup_add_cmd()
+{
+ _arguments -s : \
+ "(--archive -a)"{--archive,-a}"[automatically archive all new messages from this source]" \
+ "(--unusual -u)"{--unusual,-u}"[do not automatically poll for new messages from this source]" \
+ "(--labels -l)"{--labels,-l}"[set of labels to apply to all messages from this source]:Labels: " \
+ "(--force-new -f)"{--force-new,-f}"[create a new account for this source, even if one already exists]" \
+ "--version[show version information]" \
+ "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_config_cmd]} )) ||
+_sup_config_cmd()
+{
+ _arguments -s : \
+ "--version[show version information]" \
+ "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_dump_cmd]} )) ||
+_sup_dump_cmd()
+{
+ _arguments -s : \
+ "--version[show version information]" \
+ "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_recover_sources_cmd]} )) ||
+_sup_recover_sources_cmd()
+{
+ _arguments -s : \
+ "--archive[automatically archive all new messages from this source]" \
+ "--scan-num[number of messages to scan per source]:" \
+ "--unusual[do not automatically poll for new messages from this source]" \
+ "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_sync_cmd]} )) ||
+_sup_sync_cmd()
+{
+ # XXX Add only when --restore is given: (--restored -r)
+ # Add only when --changed or--all are given: (--start-at -s)
+ _arguments -s : \
+ "--new[operate on new messages only]" \
+ "(--changed -c)"{--changed,-c}"[scan over the entire source for messages that have been deleted, altered, or moved from another source]" \
+ "(--restored -r)"{--restored,-r}"[operate only on those messages included in a dump file as specified by --restore which have changed state]" \
+ "(--all -a)"{--all,-a}"[operate on all messages in the source, regardless of newness or changedness]" \
+ "(--start-at -s)"{--start-at,-s}"[start at a particular offset]:Offset: " \
+ "--asis[if the message is already in the index, preserve its state, otherwise, use default source state]" \
+ "--restore[restore message state from a dump file created with sup-dump]:File:_file" \
+ "--discard[discard any message state in the index and use the default source state]" \
+ "(--archive -x)"{--archive,-x}"[mark messages as archived when using the default source state]" \
+ "(--read -e)"{--read,-e}"[mark messages as read when using the default source state]" \
+ "--extra-labels[apply these labels when using the default source state]:Labels: " \
+ "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \
+ "(--optimize -o)"{--optimize,-o}"[as the final operation, optimize the index]" \
+ "--all-sources[scan over all sources]" \
+ "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \
+ "--version[show version information]" \
+ "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_sync_back_cmd]} )) ||
+_sup_sync_back_cmd()
+{
+ _arguments -s : \
+ "(--drop-deleted -d)"{--drop-deleted,-d}"[drop deleted messages]" \
+ "--move-deleted[move deleted messages to a local mbox file]:File:_file" \
+ "(--drop-spam -s)"{--drop-spam,-s}"[drop spam messages]" \
+ "--move-spam[move spam messages to a local mbox file]:File:_file" \
+ "--with-dotlockfile[specific dotlockfile location (mbox files only)]:File:_file" \
+ "--dont-use-dotlockfile[don't use dotlockfile to lock mbox files]" \
+ "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \
+ "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \
+ "--version[show version information]" \
+ "(--help -h)"{--help,-h}"[show help]"
+}
+
+(( ${+functions[_sup_tweak_labels_cmd]} )) ||
+_sup_tweak_labels_cmd()
+{
+ _arguments -s : \
+ "(--add -a)"{--add,-a}"[which labels to add to every message from the specified sources]:Labels: " \
+ "(--remove -r)"{--remove,-r}"[which labels to remove from every message from the specified sources]:Labels: " \
+ "--all-sources[scan over all sources]" \
+ "(--verbose -v)"{--verbose,-v}"[print message ids as they're processed]" \
+ "(--dry-run -n)"{--dry-run,-n}"[don't actually modify the index]" \
+ "--version[show version information]" \
+ "(--help -h)"{--help,-h}"[show help]"
+}
+
+_call_function ret _${words[1]//-/_}_cmd
+return ret
+
aligning the display as necessary.
Now press 'x' to kill the thread view buffer. You should see the inbox
-again. If you don't, you can cycle through the buffers by pressing
-'b', or you can press 'B' to see a list of all buffers and simply
-select the inbox.
+again. If you don't, you can cycle through the buffers by pressing 'b'
+and 'B' (forwards and backwards, respectively), or you can press ';' to
+see a list of all buffers and simply select the inbox.
There are many operations you can perform on threads beyond viewing
them. To archive a thread, press 'a'. The thread will disappear from
input, press Ctrl-G.
Many of these operations can be applied to a group of threads. Press
-'t' to tag a thread. Tag a couple, then press ';' to apply the next
-command to the set of threads. ';t', of course, will untag all tagged
+'t' to tag a thread. Tag a couple, then press '+' to apply the next
+command to the set of threads. '+t', of course, will untag all tagged
messages.
Ok, let's try using labels and search. Press 'L' to do a quick label
inbox, and you can browse the mailing list traffic at any point by
searching for that label.
-
Appendix C: Reading blogs with Sup
----------------------------------
require 'gettext'
require 'curses'
-## the following magic enables wide characters when used with a ruby
-## ncurses.so that's been compiled against libncursesw. (note the w.) why
-## this works, i have no idea. much like pretty much every aspect of
-## dealing with curses. cargo cult programming at its best.
-
-require 'dl/import'
-module LibC
- extend DL::Importable
- dlload Config::CONFIG['arch'] =~ /darwin/ ? "libc.dylib" : "libc.so.6"
- extern "void setlocale(int, const char *)"
-end
-LibC.setlocale(6, "") # LC_ALL == 6
-
class Object
## this is for debugging purposes because i keep calling #id on the
## wrong object and i want it to throw an exception
Redwood::PollManager.new
Redwood::SuicideManager.new Redwood::SUICIDE_FN
Redwood::CryptoManager.new
+ Redwood::UndoManager.new
end
def finish
require "sup/draft"
require "sup/poll"
require "sup/crypto"
+require "sup/undo"
require "sup/horizontal-selector"
require "sup/modes/line-cursor-mode"
require "sup/modes/help-mode"
class InputSequenceAborted < StandardError; end
class Buffer
- attr_reader :mode, :x, :y, :width, :height, :title
- bool_reader :dirty
+ attr_reader :mode, :x, :y, :width, :height, :title, :atime
+ bool_reader :dirty, :system
bool_accessor :force_to_top
def initialize window, mode, width, height, opts={}
@title = opts[:title] || ""
@force_to_top = opts[:force_to_top] || false
@x, @y, @width, @height = 0, 0, width, height
+ @atime = Time.at 0
+ @system = opts[:system] || false
end
def content_height; @height - 1; end
@mode.draw
draw_status status
commit
+ @atime = Time.now
end
## s nil means a blank line!
## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0),
## (opts[:left] || 0))
w = Ncurses.stdscr
- b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false)
+ b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system]
mode.buffer = b
@name_map[realtitle] = b
default = default_labels.join(" ")
default += " " unless default.empty?
- applyable_labels = (LabelManager.applyable_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
+ # here I would prefer to give more control and allow all_labels instead of
+ # user_defined_labels only
+ applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase }
answer = ask_many_with_completions domain, question, applyable_labels, default
:completion_character => { :fg => "white", :bg => "default", :attrs => ["bold"] },
:horizontal_selector_selected => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
:horizontal_selector_unselected => { :fg => "cyan", :bg => "default" },
- :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] }
+ :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] },
+ :system_buf => { :fg => "blue", :bg => "default" },
+ :regular_buf => { :fg => "white", :bg => "default" },
+ :modified_buffer => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
}
def initialize
def fn_for_offset o; File.join(@dir, o.to_s); end
def load_header offset
- File.open fn_for_offset(offset) do |f|
- return MBox::read_header(f)
- end
+ File.open(fn_for_offset(offset)) { |f| parse_raw_email_header f }
end
def load_message offset
def == o; o.is_a?(IMAP) && o.uri == self.uri && o.username == self.username; end
def load_header id
- MBox::read_header StringIO.new(raw_header(id))
+ parse_raw_email_header StringIO.new(raw_header(id))
end
def load_message id
end
end
- ## Syncs the message to the index: deleting if it's already there,
- ## and adding either way. Index state will be determined by m.labels.
+ ## Syncs the message to the index, replacing any previous version. adding
+ ## either way. Index state will be determined by the message's #labels
+ ## accessor.
##
- ## docid and entry can be specified if they're already known.
- def sync_message m, docid=nil, entry=nil, opts={}
- docid, entry = load_entry_for_id m.id unless docid && entry
+ ## if need_load is false, docid and entry are assumed to be set to the
+ ## result of load_entry_for_id (which can be nil).
+ def sync_message m, need_load=true, docid=nil, entry=nil, opts={}
+ docid, entry = load_entry_for_id m.id if need_load
raise "no source info for message #{m.id}" unless m.source && m.source_info
@index_mutex.synchronize do
raise "trying to delete non-corresponding entry #{docid} with index message-id #{@index[docid][:message_id].inspect} and parameter message id #{m.id.inspect}" if docid && @index[docid][:message_id] != m.id
end
- source_id =
- if m.source.is_a? Integer
- m.source
- else
- m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
- end
+ source_id = if m.source.is_a? Integer
+ m.source
+ else
+ m.source.id or raise "unregistered source #{m.source} (id #{m.source.id.inspect})"
+ end
- snippet =
- if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages]
- ""
- else
- m.snippet
- end
+ snippet = if m.snippet_contains_encrypted_content? && $config[:discard_snippets_from_encrypted_messages]
+ ""
+ else
+ m.snippet
+ end
## write the new document to the index. if the entry already exists in the
## index, reuse it (which avoids having to reload the entry from the source,
:refs => (entry[:refs] || (m.refs + m.replytos).uniq.join(" ")),
}
- @index_mutex.synchronize do
+ @index_mutex.synchronize do
@index.delete docid if docid
@index.add_document d
end
- docid, entry = load_entry_for_id m.id
- ## this hasn't been triggered in a long time. TODO: decide whether it's still a problem.
- raise "just added message #{m.id.inspect} but couldn't find it in a search" unless docid
- true
+ ## this hasn't been triggered in a long time.
+ ## docid, entry = load_entry_for_id m.id
+ ## raise "just added message #{m.id.inspect} but couldn't find it in a search" unless docid
end
def save_index fn=File.join(@dir, "ferret")
"references" => doc[:refs].split(/\s+/).map { |x| "<#{x}>" }.join(" "),
}
- Message.new :source => source, :source_info => doc[:source_info].to_i,
+ m = Message.new :source => source, :source_info => doc[:source_info].to_i,
:labels => doc[:label].split(" ").map { |s| s.intern },
- :snippet => doc[:snippet], :header => fake_header
+ :snippet => doc[:snippet]
+ m.parse_header fake_header
+ m
end
end
## add/remove these via normal label mechanisms.
RESERVED_LABELS = [ :starred, :spam, :draft, :unread, :killed, :sent, :deleted, :inbox, :attachment ]
- ## labels which it nonetheless makes sense to search for by
- LISTABLE_RESERVED_LABELS = [ :starred, :spam, :draft, :sent, :killed, :deleted, :inbox, :attachment ]
-
## labels that will typically be hidden from the user
HIDDEN_RESERVED_LABELS = [ :starred, :unread, :attachment ]
self.class.i_am_the_instance self
end
- ## all listable (just user-defined at the moment) labels, ordered
+ ## all labels user-defined and system, ordered
## nicely and converted to pretty strings. use #label_for to recover
## the original label.
- def listable_labels
+ def all_labels
## uniq's only necessary here because of certain upgrade issues
- (LISTABLE_RESERVED_LABELS + @labels.keys).uniq
+ (RESERVED_LABELS + @labels.keys).uniq
end
- ## all apply-able (user-defined and system listable) labels, ordered
+ ## all user-defined labels, ordered
## nicely and converted to pretty strings. use #label_for to recover
## the original label.
- def applyable_labels
+ def user_defined_labels
@labels.keys
end
def make_buf
return if @mode.buffer || !BufferManager.instantiated? || !@respawn || @spawning
@spawning = true
- @mode.buffer = BufferManager.instance.spawn "<log>", @mode, :hidden => true
+ @mode.buffer = BufferManager.instance.spawn "log", @mode, :hidden => true, :system => true
@spawning = false
end
def load_header id
scan_mailbox
- with_file_for(id) { |f| MBox::read_header f }
+ with_file_for(id) { |f| parse_raw_email_header f }
end
def load_message id
module Redwood
-## some utility functions. actually these are not mbox-specific at all
-## and should be moved somewhere else.
-##
-## TODO: move functionality to somewhere better, like message.rb
module MBox
- BREAK_RE = /^From \S+/
- HEADER_RE = /\s*(.*?)\s*/
-
- def read_header f
- header = {}
- last = nil
-
- ## i do it in this weird way because i am trying to speed things up
- ## when scanning over large mbox files.
- while(line = f.gets)
- case line
- ## these three can occur multiple times, and we want the first one
- when /^(Delivered-To):#{HEADER_RE}$/i,
- /^(X-Original-To):#{HEADER_RE}$/i,
- /^(Envelope-To):#{HEADER_RE}$/i: header[last = $1] ||= $2
-
- when /^(From):#{HEADER_RE}$/i,
- /^(To):#{HEADER_RE}$/i,
- /^(Cc):#{HEADER_RE}$/i,
- /^(Bcc):#{HEADER_RE}$/i,
- /^(Subject):#{HEADER_RE}$/i,
- /^(Date):#{HEADER_RE}$/i,
- /^(References):#{HEADER_RE}$/i,
- /^(In-Reply-To):#{HEADER_RE}$/i,
- /^(Reply-To):#{HEADER_RE}$/i,
- /^(List-Post):#{HEADER_RE}$/i,
- /^(List-Subscribe):#{HEADER_RE}$/i,
- /^(List-Unsubscribe):#{HEADER_RE}$/i,
- /^(Status):#{HEADER_RE}$/i,
- /^(X-\S+):#{HEADER_RE}$/: header[last = $1] = $2
- when /^(Message-Id):#{HEADER_RE}$/i: header[mid_field = last = $1] = $2
-
- when /^\r*$/: break
- when /^\S+:/: last = nil # some other header we don't care about
- else
- header[last] += " " + line.chomp.gsub(/^\s+/, "") if last
- end
- end
-
- if mid_field && header[mid_field] && header[mid_field] =~ /<(.*?)>/
- header[mid_field] = $1
- end
-
- header.each do |k, v|
- next unless Rfc2047.is_encoded? v
- header[k] =
- begin
- Rfc2047.decode_to $encoding, v
- rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
- Redwood::log "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}"
- v
- end
- end
- header
- end
-
- ## never actually called
- def read_body f
- body = []
- f.each_line do |l|
- break if l =~ BREAK_RE
- body << l.chomp
- end
- body
- end
-
- module_function :read_header, :read_body
+ BREAK_RE = /^From \S+@\S+ /
end
end
unless l =~ BREAK_RE
raise OutOfSyncSourceError, "mismatch in mbox file offset #{offset.inspect}: #{l.inspect}."
end
- header = MBox::read_header @f
+ header = parse_raw_email_header @f
end
header
end
@mutex.synchronize do
@f.seek offset
begin
- RMail::Mailbox::MBoxReader.new(@f).each_message do |input|
- m = RMail::Parser.read(input)
- if m.body && m.body.is_a?(String)
- m.body.gsub!(/^>From /, "From ")
- end
- return m
- end
+ ## don't use RMail::Mailbox::MBoxReader because it doesn't properly ignore
+ ## "From" at the start of a message body line.
+ string = ""
+ l = @f.gets
+ string << l until @f.eof? || (l = @f.gets) =~ BREAK_RE
+ RMail::Parser.read string
rescue RMail::Parser::Error => e
raise FatalSourceError, "error parsing mbox file: #{e.message}"
end
@mutex.synchronize do
@f.seek offset
until @f.eof? || (l = @f.gets) =~ /^\r*$/
- ret += l
+ ret << l
end
end
ret
def raw_message offset
ret = ""
- each_raw_message_line(offset) { |l| ret += l }
+ each_raw_message_line(offset) { |l| ret << l }
ret
end
## why.
@refs = []
- parse_header(opts[:header] || @source.load_header(@source_info))
+ #parse_header(opts[:header] || @source.load_header(@source_info))
end
def parse_header header
- header.keys.each { |k| header[k.downcase] = header[k] } # canonicalize
-
- fakeid = nil
- fakename = nil
-
- @id =
- if header["message-id"]
- sanitize_message_id header["message-id"]
- else
- fakeid = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
- end
+ @id = if header["message-id"]
+ mid = header["message-id"] =~ /<(.+?)>/ ? $1 : header["message-id"]
+ sanitize_message_id mid
+ else
+ id = "sup-faked-" + Digest::MD5.hexdigest(raw_header)
+ from = header["from"]
+ #Redwood::log "faking non-existent message-id for message from #{from}: #{id}"
+ id
+ end
- @from =
- if header["from"]
- Person.from_address header["from"]
- else
- fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
- Person.from_address fakename
- end
-
- Redwood::log "faking message-id for message from #@from: #{id}" if fakeid
- Redwood::log "faking from for message #@id: #{fakename}" if fakename
-
- date = header["date"]
- @date =
- case date
- when Time
- date
- when String
- begin
- Time.parse date
- rescue ArgumentError => e
- Redwood::log "faking date header for #{@id} due to error parsing date #{header['date'].inspect}: #{e.message}"
- Time.now
- end
- else
- Redwood::log "faking date header for #{@id}"
+ @from = Person.from_address(if header["from"]
+ header["from"]
+ else
+ name = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
+ #Redwood::log "faking non-existent sender for message #@id: #{name}"
+ name
+ end)
+
+ @date = case(date = header["date"])
+ when Time
+ date
+ when String
+ begin
+ Time.parse date
+ rescue ArgumentError => e
+ #Redwood::log "faking mangled date header for #{@id} (orig #{header['date'].inspect} gave error: #{e.message})"
Time.now
end
+ else
+ #Redwood::log "faking non-existent date header for #{@id}"
+ Time.now
+ end
@subj = header.member?("subject") ? header["subject"].gsub(/\s+/, " ").gsub(/\s+$/, "") : DEFAULT_SUBJECT
@to = Person.from_address_list header["to"]
@list_subscribe = header["list-subscribe"]
@list_unsubscribe = header["list-unsubscribe"]
end
- private :parse_header
def add_ref ref
@refs << ref
## this is called when the message body needs to actually be loaded.
def load_from_source!
@chunks ||=
- if @source.has_errors?
+ if @source.respond_to?(:has_errors?) && @source.has_errors?
[Chunk::Text.new(error_message(@source.error.message).split("\n"))]
else
begin
[notice, sig, children].flatten.compact
end
+ ## takes a RMail::Message, breaks it into Chunk:: classes.
def message_to_chunks m, encrypted=false, sibling_types=[]
if m.multipart?
chunks =
end
def killable?; true; end
+ def unsaved?; false end
def draw; end
def focus; end
def blur; end
def focus
reload # buffers may have been killed or created since last view
+ set_cursor_pos 0
end
protected
end
def regen_text
- @bufs = BufferManager.buffers.sort_by { |name, buf| name }
+ @bufs = BufferManager.buffers.reject { |name, buf| buf.mode == self }.sort_by { |name, buf| buf.atime }.reverse
width = @bufs.max_of { |name, buf| buf.mode.name.length }
@text = @bufs.map do |name, buf|
- sprintf "%#{width}s %s", buf.mode.name, name
+ base_color = buf.system? ? :system_buf_color : :regular_buf_color
+ [[base_color, sprintf("%#{width}s ", buf.mode.name)],
+ [:modified_buffer_color, (buf.mode.unsaved? ? '*' : ' ')],
+ [base_color, " " + name]]
end
end
k.add :reload, "Drop contact list and reload", 'D'
k.add :alias, "Edit alias/or name for contact", 'a', 'i'
k.add :toggle_tagged, "Tag/untag current line", 't'
- k.add :apply_to_tagged, "Apply next command to all tagged items", ';'
+ k.add :apply_to_tagged, "Apply next command to all tagged items", '+'
k.add :search, "Search for messages from particular people", 'S'
end
FORCE_HEADERS = %w(From To Cc Bcc Subject)
MULTI_HEADERS = %w(To Cc Bcc)
- NON_EDITABLE_HEADERS = %w(Message-Id Date)
+ NON_EDITABLE_HEADERS = %w(Message-id Date)
HookManager.register "signature", <<EOS
Generates a message signature.
!edited? || BufferManager.ask_yes_or_no("Discard message?")
end
+ def unsaved?; edited? end
+
def attach_file
fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
return unless fn
def parse_file fn
File.open(fn) do |f|
- header = MBox::read_header f
+ header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
body = f.readlines.map { |l| l.chomp }
header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
if contacts
- text = contacts.map { |s| s.longname }.join(", ")
+ text = contacts.map { |s| s.full_address }.join(", ")
@header[field] = parse_header field, text
update
end
def archive
return unless cursor_thread
+ thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+ undo = lambda {
+ thread.apply_label :inbox
+ add_or_unhide thread.first
+ }
+ UndoManager.register("archiving thread #{thread.first.id}", undo)
+
cursor_thread.remove_label :inbox
hide_thread cursor_thread
regen_text
end
def multi_archive threads
+ undo = threads.map {|t|
+ lambda{
+ t.apply_label :inbox
+ add_or_unhide t.first
+ }}
+ UndoManager.register("archiving #{threads.size} #{threads.size.pluralize 'thread'}",
+ undo << lambda {regen_text} )
+
threads.each do |t|
t.remove_label :inbox
hide_thread t
def read_and_archive
return unless cursor_thread
+ thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+ undo = lambda {
+ thread.apply_label :inbox
+ thread.apply_label :unread
+ add_or_unhide thread.first
+ }
+ UndoManager.register("reading and archiving thread ", undo)
+
cursor_thread.remove_label :unread
cursor_thread.remove_label :inbox
hide_thread cursor_thread
end
def multi_read_and_archive threads
+ undo = threads.map {|t|
+ lambda {
+ t.apply_label :inbox
+ t.apply_label :unread
+ add_or_unhide t.first
+ }
+ }
+ UndoManager.register("reading and archiving #{threads.size} #{threads.size.pluralize 'thread'}",
+ undo << lambda {regen_text})
+
threads.each do |t|
t.remove_label :unread
t.remove_label :inbox
def regen_text
@text = []
- labels = LabelManager.listable_labels
+ labels = LabelManager.all_labels
counts = labels.map do |label|
string = LabelManager.string_for label
total = Index.num_results_for :label => label
- unread = Index.num_results_for :labels => [label, :unread]
+ unread = (label == :unread)? total : Index.num_results_for(:labels => [label, :unread])
[label, string, total, unread]
end.sort_by { |l, s, t, u| s.downcase }
super :header => header, :body => body, :have_signature => true
end
+ def unsaved?; !@safe end
+
def killable?
return true if @safe
k.add :toggle_tagged, "Tag/untag selected thread", 't'
k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
k.add :tag_matching, "Tag matching threads", 'g'
- k.add :apply_to_tagged, "Apply next command to all tagged threads", ';'
+ k.add :apply_to_tagged, "Apply next command to all tagged threads", '+'
k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
+ k.add :undo, "Undo the previous action", 'u'
end
def initialize hidden_labels=[], load_thread_opts={}
end
end
+ def unsaved?; dirty? end
def lines; @text.length; end
def [] i; @text[i]; end
def contains_thread? t; @threads.include?(t) end
def reload
drop_all_threads
+ UndoManager.clear
BufferManager.draw_screen
load_threads :num => buffer.content_height
end
add_or_unhide m
end
+ def undo
+ UndoManager.undo
+ end
+
def update
@mutex.synchronize do
## let's see you do THIS in python
end
def actually_toggle_starred t
+ thread = t # cargo cult programming
+ pos = curpos
if t.has_label? :starred # if ANY message has a star
+ undo = lambda {
+ thread.first.add_label :starred
+ update_text_for_line pos
+ UpdateManager.relay self, :starred, thread.first
+ }
t.remove_label :starred # remove from all
UpdateManager.relay self, :unstarred, t.first
else
+ undo = lambda {
+ thread.remove_label :starred
+ update_text_for_line pos
+ UpdateManager.relay self, :unstarred, thread.first
+ }
t.first.add_label :starred # add only to first
UpdateManager.relay self, :starred, t.first
end
+
+ return undo
end
def toggle_starred
t = cursor_thread or return
- actually_toggle_starred t
+ undo = actually_toggle_starred t
+ UndoManager.register("starring/unstarring thread #{t.first.id}",undo)
update_text_for_line curpos
cursor_down
end
def multi_toggle_starred threads
- threads.each { |t| actually_toggle_starred t }
+ undo = threads.map { |t| actually_toggle_starred t }
+ UndoManager.register("starring/unstarring #{threads.size} #{threads.size.pluralize 'thread'}",
+ undo)
regen_text
end
def actually_toggle_archived t
+ thread = t
+ pos = curpos
if t.has_label? :inbox
t.remove_label :inbox
+ undo = lambda {
+ thread.apply_label :inbox
+ update_text_for_line pos
+ UpdateManager.relay self,:unarchived, thread.first
+ }
UpdateManager.relay self, :archived, t.first
else
t.apply_label :inbox
+ undo = lambda {
+ thread.remove_label :inbox
+ update_text_for_line pos
+ UpdateManager.relay self, :unarchived, thread.first
+ }
UpdateManager.relay self, :unarchived, t.first
end
+
+ return undo
end
def actually_toggle_spammed t
+ thread = t
if t.has_label? :spam
+ undo = lambda {
+ thread.apply_label :spam
+ self.hide_thread thread
+ UpdateManager.relay self,:spammed, thread.first
+ }
t.remove_label :spam
+ add_or_unhide t.first
UpdateManager.relay self, :unspammed, t.first
else
+ undo = lambda {
+ thread.remove_label :spam
+ add_or_unhide thread.first
+ UpdateManager.relay self,:unspammed, thread.first
+ }
t.apply_label :spam
+ hide_thread t
UpdateManager.relay self, :spammed, t.first
end
+
+ return undo
end
def actually_toggle_deleted t
if t.has_label? :deleted
+ undo = lambda {
+ t.apply_label :deleted
+ hide_thread t
+ UpdateManager.relay self, :deleted, t.first
+ }
t.remove_label :deleted
+ add_or_unhide t.first
UpdateManager.relay self, :undeleted, t.first
else
+ undo = lambda {
+ t.remove_label :deleted
+ add_or_unhide t.first
+ UpdateManager.relay self, :undeleted, t.first
+ }
t.apply_label :deleted
+ hide_thread t
UpdateManager.relay self, :deleted, t.first
end
+
+ return undo
end
def toggle_archived
t = cursor_thread or return
- actually_toggle_archived t
+ undo = [actually_toggle_archived(t), lambda {self.update_text_for_line curpos}]
+ UndoManager.register("deleting/undeleting thread #{t.first.id}",undo)
update_text_for_line curpos
end
def multi_toggle_archived threads
- threads.each { |t| actually_toggle_archived t }
+ undo = threads.map { |t| actually_toggle_archived t}
+ UndoManager.register("deleting/undeleting #{threads.size} #{threads.size.pluralize 'thread'}",
+ undo << lambda {self.regen_text})
regen_text
end
## see deleted or spam emails, and when you undelete or unspam them
## you also want them to disappear immediately.
def multi_toggle_spam threads
- threads.each do |t|
- actually_toggle_spammed t
- hide_thread t
- end
+ undo = threads.map{ |t| actually_toggle_spammed t}
+ UndoManager.register("marking/unmarking #{threads.size} #{threads.size.pluralize 'thread'} as spam",
+ undo << lambda {self.regen_text})
regen_text
end
## see comment for multi_toggle_spam
def multi_toggle_deleted threads
- threads.each do |t|
- actually_toggle_deleted t
- hide_thread t
- end
+ undo = threads.map{ |t| actually_toggle_deleted t}
+ UndoManager.register("deleting/undeleting #{threads.size} #{threads.size.pluralize 'thread'}",
+ undo << lambda {regen_text})
regen_text
end
multi_kill [t]
end
+ ## m-m-m-m-MULTI-KILL
def multi_kill threads
- threads.each do |t|
+ undo = threads.map do |t|
t.apply_label :killed
hide_thread t
+ thread = t
+ lambda { thread.remove_label :killed
+ add_or_unhide thread.first
+ }
end
+ UndoManager.register("killing #{threads.size} #{threads.size.pluralize 'thread'}",
+ undo << lambda {regen_text})
regen_text
BufferManager.flash "#{threads.size.pluralize 'Thread'} killed."
end
def edit_labels
thread = cursor_thread or return
speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
+
+ old_labels = thread.labels
+ pos = curpos
+
keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl, @hidden_labels
thread.labels = keepl + user_labels
user_labels.each { |l| LabelManager << l }
update_text_for_line curpos
+
+ undo = lambda{
+ thread.labels = old_labels
+ update_text_for_line pos
+ UpdateManager.relay self, :labeled, thread.first
+ }
+
+ UndoManager.register("labeling thread #{thread.first.id}", undo)
+
UpdateManager.relay self, :labeled, thread.first
end
user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
if hl.empty?
- threads.each do |t|
+ undo = threads.map do |t|
+ old_labels = t.labels
user_labels.each do |(l, to_remove)|
if to_remove
t.remove_label l
t.apply_label l
end
end
+ ## UpdateManager or some other regresh mechanism?
+ UpdateManager.relay self, :labeled, t.first
+ lambda do
+ t.labels = old_labels
+ UpdateManager.relay self, :labeled, t.first
+ end
end
user_labels.each { |(l,_)| LabelManager << l }
+ UndoManager.register("labeling #{threads.size} #{threads.size.pluralize 'thread'}",
+ undo << lambda { regen_text})
else
BufferManager.flash "'#{hl}' is a reserved label!"
end
(t.labels - @hidden_labels).map { |label| [:label_color, "+#{label} "] } +
[[:snippet_color, snippet]
]
-
end
def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
end
def buffer
- b, new = BufferManager.spawn_unless_exists("<poll for new messages>", :hidden => true) { PollMode.new }
+ b, new = BufferManager.spawn_unless_exists("poll for new messages", :hidden => true, :system => true) { PollMode.new }
b
end
Index.usual_sources.each do |source|
# yield "source #{source} is done? #{source.done?} (cur_offset #{source.cur_offset} >= #{source.end_offset})"
begin
- yield "Loading from #{source}... " unless source.done? || source.has_errors?
+ yield "Loading from #{source}... " unless source.done? || (source.respond_to?(:has_errors?) && source.has_errors?)
rescue SourceError => e
Redwood::log "problem getting messages from #{source}: #{e.message}"
Redwood::report_broken_sources :force_to_top => true
yield "Found message at #{offset} with labels {#{m.labels * ', '}}"
unless entry
num += 1
- from_and_subj << [m.from.longname, m.subj]
+ from_and_subj << [m.from && m.from.longname, m.subj]
if m.has_label?(:inbox) && ([:spam, :deleted, :killed] & m.labels).empty?
- from_and_subj_inbox << [m.from.longname, m.subj]
+ from_and_subj_inbox << [m.from && m.from.longname, m.subj]
numi += 1
end
end
begin
m = Message.new :source => source, :source_info => offset, :labels => labels
+ m.load_from_source!
+
if m.source_marked_read?
m.remove_label :unread
labels.delete :unread
docid, entry = Index.load_entry_for_id m.id
HookManager.run "before-add-message", :message => m
m = yield(m, offset, entry) or next if block_given?
- Index.sync_message m, docid, entry, opts
+ times = Index.sync_message m, false, docid, entry, opts
UpdateManager.relay self, :added, m unless entry
rescue MessageFormatError => e
Redwood::log "ignoring erroneous message at #{source}##{offset}: #{e.message}"
end
end
+ ## read a raw email header from a filehandle (or anything that responds to
+ ## #gets), and turn it into a hash of key-value pairs.
+ ##
+ ## WARNING! THIS IS A SPEED-CRITICAL SECTION. Everything you do here will have
+ ## a significant effect on Sup's processing speed of email from ALL sources.
+ ## Little things like string interpolation, regexp interpolation, += vs <<,
+ ## all have DRAMATIC effects. BE CAREFUL WHAT YOU DO!
+ def self.parse_raw_email_header f
+ header = {}
+ last = nil
+
+ while(line = f.gets)
+ case line
+ ## these three can occur multiple times, and we want the first one
+ when /^(Delivered-To|X-Original-To|Envelope-To):\s*(.*?)\s*$/i; header[last = $1.downcase] ||= $2
+ ## mark this guy specially. not sure why i care.
+ when /^([^:\s]+):\s*(.*?)\s*$/i; header[last = $1.downcase] = $2
+ when /^\r*$/; break
+ else
+ if last
+ header[last] << " " unless header[last].empty?
+ header[last] << line.strip
+ end
+ end
+ end
+
+ %w(subject from to cc bcc).each do |k|
+ v = header[k] or next
+ next unless Rfc2047.is_encoded? v
+ header[k] = begin
+ Rfc2047.decode_to $encoding, v
+ rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence => e
+ #Redwood::log "warning: error decoding RFC 2047 header (#{e.class.name}): #{e.message}"
+ v
+ end
+ end
+ header
+ end
+
protected
+
+ ## convenience function
+ def parse_raw_email_header f; self.class.parse_raw_email_header f end
def Source.expand_filesystem_uri uri
uri.gsub "~", File.expand_path("~")
--- /dev/null
+module Redwood
+
+## Implements a single undo list for the Sup instance
+##
+## The basic idea is to keep a list of lambdas to undo
+## things. When an action is called (such as 'archive'),
+## a lambda is registered with UndoManager that will
+## undo the archival action
+
+class UndoManager
+ include Singleton
+
+ def initialize
+ @@actionlist = []
+ self.class.i_am_the_instance self
+ end
+
+ def register desc, actions
+ actions = [actions] unless actions.is_a?Array
+ raise StandardError, "when would I need to undo 'nothing?'" unless actions.length > 0
+ Redwood::log "registering #{actions.length} actions: #{desc}"
+ @@actionlist.push({:desc => desc, :actions => actions})
+ end
+
+ def undo
+ unless @@actionlist.length == 0 then
+ actionset = @@actionlist.pop
+ Redwood::log "undoing #{actionset[:desc]}..."
+ actionset[:actions].each{|action|
+ action.call
+ }
+ BufferManager.flash "undid #{actionset[:desc]}"
+ else
+ BufferManager.flash "nothing more to undo"
+ end
+ end
+
+ def clear
+ @@actionlist = []
+ end
+end
+end
SUP_EXTRA_FILES +
SUP_EXECUTABLES.map { |f| "bin/#{f}" } +
SUP_LIB_DIRS.map { |d| Dir["#{d}/*.rb"] }.flatten
+
+if $0 == __FILE__ # if executed from commandline
+ puts SUP_FILES
+end
end
def load_header offset
- MBox::read_header StringIO.new(raw_header(offset))
+ Source.parse_raw_email_header StringIO.new(raw_header(offset))
end
def load_message offset
yield f.gets
end
end
-
- # FIXME: this one was not mentioned in the source documentation, but
- # it's still required
- def has_errors?
-
- end
-
end
end
--- /dev/null
+#!/usr/bin/ruby
+
+require 'test/unit'
+require 'sup'
+require 'stringio'
+
+include Redwood
+
+class TestMBoxParsing < Test::Unit::TestCase
+ def setup
+ end
+
+ def teardown
+ end
+
+ def test_normal_headers
+ h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+To: Sally <sally@sally.com>
+EOS
+
+ assert_equal "Bob <bob@bob.com>", h["from"]
+ assert_equal "Sally <sally@sally.com>", h["to"]
+ assert_nil h["message-id"]
+ end
+
+ def test_multiline
+ h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+Subject: one two three
+ four five six
+To: Sally <sally@sally.com>
+References: <seven>
+ <eight>
+Seven: Eight
+EOS
+
+ assert_equal "one two three four five six", h["subject"]
+ assert_equal "Sally <sally@sally.com>", h["to"]
+ assert_equal "<seven> <eight>", h["references"]
+ end
+
+ def test_ignore_spacing
+ variants = [
+ "Subject:one two three end\n",
+ "Subject: one two three end\n",
+ "Subject: one two three end \n",
+ ]
+ variants.each do |s|
+ h = Source.parse_raw_email_header StringIO.new(s)
+ assert_equal "one two three end", h["subject"]
+ end
+ end
+
+ def test_message_id_ignore_spacing
+ variants = [
+ "Message-Id: <one@bob.com> \n",
+ "Message-Id:<one@bob.com> \n",
+ ]
+ variants.each do |s|
+ h = Source.parse_raw_email_header StringIO.new(s)
+ assert_equal "<one@bob.com>", h["message-id"]
+ end
+ end
+
+ def test_blank_lines
+ h = Source.parse_raw_email_header StringIO.new("")
+ assert_equal nil, h["message-id"]
+ end
+
+ def test_empty_headers
+ variants = [
+ "Message-Id: \n",
+ "Message-Id:\n",
+ ]
+ variants.each do |s|
+ h = Source.parse_raw_email_header StringIO.new(s)
+ assert_equal "", h["message-id"]
+ end
+ end
+
+ def test_detect_end_of_headers
+ h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+
+To: a dear friend
+EOS
+ assert_equal "Bob <bob@bob.com>", h["from"]
+ assert_nil h["to"]
+
+ h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+\r
+To: a dear friend
+EOS
+ assert_equal "Bob <bob@bob.com>", h["from"]
+ assert_nil h["to"]
+
+ h = Source.parse_raw_email_header StringIO.new(<<EOS)
+From: Bob <bob@bob.com>
+\r\n\r
+To: a dear friend
+EOS
+ assert_equal "Bob <bob@bob.com>", h["from"]
+ assert_nil h["to"]
+ end
+end
+++ /dev/null
-#!/usr/bin/ruby
-
-require 'test/unit'
-require 'sup'
-require 'stringio'
-
-include Redwood
-
-class TestMBoxParsing < Test::Unit::TestCase
- def setup
- end
-
- def teardown
- end
-
- def test_normal_headers
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-To: Sally <sally@sally.com>
-EOS
-
- assert_equal "Bob <bob@bob.com>", h["From"]
- assert_equal "Sally <sally@sally.com>", h["To"]
- assert_nil h["Message-Id"]
- end
-
- ## this is shitty behavior in retrospect, but it's built in now.
- def test_message_id_stripping
- h = MBox.read_header StringIO.new("Message-Id: <one@bob.com>\n")
- assert_equal "one@bob.com", h["Message-Id"]
-
- h = MBox.read_header StringIO.new("Message-Id: one@bob.com\n")
- assert_equal "one@bob.com", h["Message-Id"]
- end
-
- def test_multiline
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-Subject: one two three
- four five six
-To: Sally <sally@sally.com>
-References: seven
- eight
-Seven: Eight
-EOS
-
- assert_equal "one two three four five six", h["Subject"]
- assert_equal "Sally <sally@sally.com>", h["To"]
- assert_equal "seven eight", h["References"]
- end
-
- def test_ignore_spacing
- variants = [
- "Subject:one two three end\n",
- "Subject: one two three end\n",
- "Subject: one two three end \n",
- ]
- variants.each do |s|
- h = MBox.read_header StringIO.new(s)
- assert_equal "one two three end", h["Subject"]
- end
- end
-
- def test_message_id_ignore_spacing
- variants = [
- "Message-Id: <one@bob.com> \n",
- "Message-Id: one@bob.com \n",
- "Message-Id:<one@bob.com> \n",
- "Message-Id:one@bob.com \n",
- ]
- variants.each do |s|
- h = MBox.read_header StringIO.new(s)
- assert_equal "one@bob.com", h["Message-Id"]
- end
- end
-
- def test_blank_lines
- h = MBox.read_header StringIO.new("")
- assert_equal nil, h["Message-Id"]
- end
-
- def test_empty_headers
- variants = [
- "Message-Id: \n",
- "Message-Id:\n",
- ]
- variants.each do |s|
- h = MBox.read_header StringIO.new(s)
- assert_equal "", h["Message-Id"]
- end
- end
-
- def test_detect_end_of_headers
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-
-To: a dear friend
-EOS
- assert_equal "Bob <bob@bob.com>", h["From"]
- assert_nil h["To"]
-
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r
-To: a dear friend
-EOS
- assert_equal "Bob <bob@bob.com>", h["From"]
- assert_nil h["To"]
-
- h = MBox.read_header StringIO.new(<<EOS)
-From: Bob <bob@bob.com>
-\r\n\r
-To: a dear friend
-EOS
- assert_equal "Bob <bob@bob.com>", h["From"]
- assert_nil h["To"]
- end
-end
# Look at another header field whose first line was blank.
list_unsubscribe = sup_message.list_unsubscribe
- assert_equal(" <http://mailman2.widget.com/mailman/listinfo/monitor-list>, " +
+ assert_equal("<http://mailman2.widget.com/mailman/listinfo/monitor-list>, " +
"<mailto:monitor-list-request@widget.com?subject=unsubscribe>",
list_unsubscribe)