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?
require 'trollop'
require "sup"
+PROGRESS_UPDATE_INTERVAL = 15 # seconds
+
class Float
def to_s; sprintf '%.2f', self; end
def to_time_s
IO.foreach opts[:restore] do |l|
l =~ /^(\S+) \((.*?)\)$/ or raise "Can't read dump line: #{l.inspect}"
mid, labels = $1, $2
- dump[mid] = labels.split(" ").map { |x| x.intern }
+ dump[mid] = labels.symbolistize
end
$stderr.puts "Read #{dump.size} entries from dump file."
dump
unless target == :new
if opts[:start_at]
- sources.each { |s| s.seek_to! opts[:start_at] }
+ Trollop::die :start_at, "can only be used on one source" unless sources.size == 1
+ sources.first.seek_to! opts[:start_at]
+ sources.first.correct_offset! if sources.first.respond_to?(:correct_offset!)
else
sources.each { |s| s.reset! }
end
num_scanned += 1
seen[m.id] = true
+ if Time.now - last_info_time > PROGRESS_UPDATE_INTERVAL
+ last_info_time = Time.now
+ elapsed = last_info_time - start_time
+ start = opts[:start_at] || source.start_offset
+ pctdone = 100.0 * (source.cur_offset - start).to_f / (source.end_offset - start).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
+
## skip if we're operating only on changed messages, the message
## is in the index, and it's unchanged from what the source is
## reporting.
next if target == :changed && entry && entry[:source_id].to_i == source.id && entry[:source_info].to_i == offset
## get the state currently in the index
- index_state = entry[:label].split(/\s+/).map { |x| x.intern } if entry
+ index_state = entry[:label].symbolistize if entry
## skip if we're operating on restored messages, and this one
## ain't.
## to default source state modification flags.
m.labels -= [:inbox] if opts[:archive]
m.labels -= [:unread] if opts[:read]
- m.labels += opts[:extra_labels].split(/\s*,\s*/).map { |x| x.intern } if opts[:extra_labels]
+ m.labels += opts[:extra_labels].strip.split(/\s*,\s*/).map { |x| x.intern } if opts[:extra_labels]
## assign message labels based on the operation we're performing
case op
## nothin! use default source labels
end
- if Time.now - last_info_time > 60
+ 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.puts "## #{num_scanned} (#{pctdone}%) read; #{elapsed.to_time_s} elapsed; #{remaining.to_time_s} remaining"
+ $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} with state {#{m.labels * ', '}}" if opts[:verbose]
+ puts "Adding message #{source}##{offset} from #{m.from} with state {#{m.labels * ', '}}" if opts[:verbose]
num_added += 1
else
puts "Updating message #{source}##{offset}, source #{entry[:source_id]} => #{source.id}, offset #{entry[:source_info]} => #{offset}, state {#{index_state * ', '}} => {#{m.labels * ', '}}" if opts[:verbose]
## API.
##
## TODO: move this to Index, i suppose.
-
-
- if target == :all || target == :changed
+ if (target == :all || target == :changed) && !opts[:start_at]
$stderr.puts "Deleting missing messages from the index..."
num_del, num_scanned = 0, 0
sources.each do |source|
num_scanned += 1
if entry
- labels = entry[:label].split.map { |x| x.intern }.to_boolean_h
+ labels = entry[:label].symbolistize.to_boolean_h
if labels.member? :deleted
if opts[:drop_deleted]
Other options:
EOS
opt :verbose, "Print message ids as they're processed."
+ opt :very_verbose, "Print message names and subjects as they're processed."
opt :all_sources, "Scan over all sources.", :short => :none
opt :dry_run, "Don't actually modify the index. Probably only useful with --verbose.", :short => "-n"
opt :version, "Show version information", :short => :none
end
+opts[:verbose] = true if opts[:very_verbose]
add_labels = (opts[:add] || "").split(",").map { |l| l.intern }.uniq
remove_labels = (opts[:remove] || "").split(",").map { |l| l.intern }.uniq
unless m.labels.sort_by { |s| s.to_s } == old_labels.sort_by { |s| s.to_s }
num_changed += 1
+ puts "From #{m.from}, subject: #{m.subj}" if opts[:very_verbose]
puts "#{m.id}: {#{old_labels.join ','}} => {#{m.labels.join ','}}" if opts[:verbose]
+ puts if opts[:very_verbose]
index.sync_message m unless opts[:dry_run]
end
--- /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
+
Welcome to Sup! Here's how to get started.
-First, try running 'sup'. Since this is your first time, you'll be
+First, try running `sup`. Since this is your first time, you'll be
confronted with a mostly blank screen, and a notice at the bottom that
you have no new messages. That's because Sup doesn't hasn't loaded
anything into its index yet, and has no idea where to look for them
thread, Sup requests the full content of all the messages from the
source.
-The easiest way to set up all your sources is to run "sup-config".
+The easiest way to set up all your sources is to run `sup-config`.
This will interactively walk you through some basic configuration,
prompt you for all the sources you need, and optionally import
messages from them. Sup-config uses two other tools, sup-add and
sup-sync, to load messages into the index. In the future you may make
use of these tools directly (see below).
-Once you've run sup-config, you're ready to run 'sup'. You should see
+Once you've run sup-config, you're ready to run `sup`. You should see
the most recent unarchived messages appear in your inbox.
Congratulations, you've got Sup working!
If you're coming from the world of traditional MUAs, there are a
couple differences you should be aware of at this point. First, Sup
has no folders. Instead, you organize and find messages by a
-combination of search and labels (knowns as 'tags' everywhere else in
+combination of search and labels (known as "tags" everywhere else in
the world). Search and labels are an integral part of Sup because in
Sup, rather than viewing the contents of a folder, you view the
results of a search. I mentioned above that your inbox is, by
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
and press enter to view all the messages with that label.
What you just did was actually a specific search. For a general search,
-press "\" (backslash---forward slash is used for in-buffer search,
+press '\' (backslash---forward slash is used for in-buffer search,
following console conventions). Now type in your query (again, Ctrl-G to
cancel at any point.) You can just type in arbitrary text, which will be
matched on a per-word basis against the bodies of all email in the
That's the bad news. The good news is that Sup is pretty good at being
able to detect this type of situation, and fixing it is just a matter
-of running sup-sync --changed on the source. Sup will even tell you
+of running `sup-sync --changed` on the source. Sup will even tell you
how to invoke sup-sync when it detects a problem. This is a
complication you will almost certainly run in to if you use both Sup
and another MUA on the same source, so it's good to be aware of it.
Have fun, and let me know if you have any problems!
-Appending A: sup-add and sup-sync
+Appendix A: sup-add and sup-sync
---------------------------------
Instead of using sup-config to add a new source, you can manually run
-'sup-add' with a URI pointing to it. The URI should be of the form:
+`sup-add` with a URI pointing to it. The URI should be of the form:
- mbox://path/to/a/filename, for an mbox file on disk.
- maildir://path/to/a/filename, for a maildir directory on disk.
- imap://imap.server/folder for an unsecure IMAP folder.
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
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
return unless answer
- user_labels = answer.split(/\s+/).map { |l| l.intern }
+ user_labels = answer.symbolistize
user_labels.each do |l|
if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l)
BufferManager.flash "'#{l}' is a reserved label!"
Curses::COLOR_YELLOW, Curses::COLOR_BLUE,
Curses::COLOR_MAGENTA, Curses::COLOR_CYAN,
Curses::COLOR_WHITE, Curses::COLOR_DEFAULT]
- NUM_COLORS = 15
+ NUM_COLORS = (CURSES_COLORS.size - 1) * (CURSES_COLORS.size - 1)
DEFAULT_COLORS = {
:status => { :fg => "white", :bg => "blue", :attrs => ["bold"] },
: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,
## but merge in the labels.
if entry[:source_id] && entry[:source_info] && entry[:label] &&
((entry[:source_id].to_i > source_id) || (entry[:source_info].to_i < m.source_info))
- labels = (entry[:label].split(/\s+/).map { |l| l.intern } + m.labels).uniq
+ labels = (entry[:label].symbolistize + m.labels).uniq
#Redwood::log "found updated version of message #{m.id}: #{m.subj}"
#Redwood::log "previous version was at #{entry[:source_id].inspect}:#{entry[:source_info].inspect}, this version at #{source_id.inspect}:#{m.source_info.inspect}"
#Redwood::log "merged labels are #{labels.inspect} (index #{entry[:label].inspect}, message #{m.labels.inspect})"
: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")
q = Ferret::Search::BooleanQuery.new true
sq = Ferret::Search::PhraseQuery.new(:subject)
- wrap_subj(Message.normalize_subj(m.subj)).split(/\s+/).each do |t|
+ wrap_subj(Message.normalize_subj(m.subj)).split.each do |t|
sq.add_term t
end
q.add_query sq, :must
unless messages.member?(mid)
#Redwood::log "got #{mid} as a child of #{id}"
messages[mid] ||= lambda { build_message docid }
- refs = @index[docid][:refs].split(" ")
+ refs = @index[docid][:refs].split
pending += refs.select { |id| !searched[id] }
end
end
Redwood::log "thread for #{m.id} is killed, ignoring"
false
else
- Redwood::log "ran #{num_queries} queries to build thread of #{messages.size + 1} messages for #{m.id}: #{m.subj}" if num_queries > 0
+ Redwood::log "ran #{num_queries} queries to build thread of #{messages.size} messages for #{m.id}: #{m.subj}" if num_queries > 0
messages.each { |mid, builder| yield mid, builder }
true
end
"date" => Time.at(doc[:date].to_i),
"subject" => unwrap_subj(doc[:subject]),
"from" => doc[:from],
- "to" => doc[:to].split(/\s+/).join(", "), # reformat
+ "to" => doc[:to].split.join(", "), # reformat
"message-id" => doc[:message_id],
- "references" => doc[:refs].split(/\s+/).map { |x| "<#{x}>" }.join(" "),
+ "references" => doc[:refs].split.map { |x| "<#{x}>" }.join(" "),
}
- 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
+ m = Message.new :source => source, :source_info => doc[:source_info].to_i,
+ :labels => doc[:label].symbolistize,
+ :snippet => doc[:snippet]
+ m.parse_header fake_header
+ m
end
end
subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
break if chronic_failure
field, datestr = $1, ($3 || $4)
- realdate = Chronic.parse(datestr, :guess => false, :context => :none)
+ realdate = Chronic.parse(datestr, :guess => false, :context => :past)
if realdate
case field
when "after"
## 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 ]
[]
end
@labels = {}
+ @new_labels = {}
@modified = false
labels.each { |t| @labels[t] = true }
self.class.i_am_the_instance self
end
- ## all listable (just user-defined at the moment) labels, ordered
+ def new_label? l; @new_labels.include?(l) end
+
+ ## 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
## reverse the label->string mapping, for convenience!
def string_for l
if RESERVED_LABELS.include? l
- l.to_s.ucfirst
+ l.to_s.capitalize
else
l.to_s
end
t = t.intern unless t.is_a? Symbol
unless @labels.member?(t) || RESERVED_LABELS.member?(t)
@labels[t] = true
+ @new_labels[t] = true
@modified = true
end
end
def delete t
- if @labels.delete t
+ if @labels.delete(t)
@modified = true
end
end
def save
return unless @modified
File.open(@fn, "w") { |f| f.puts @labels.keys.sort_by { |l| l.to_s } }
+ @new_labels = {}
end
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 is_break_line? l
l =~ BREAK_RE or return false
end
end
module_function :is_break_line?
-
- 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 is_break_line?(l)
- body << l.chomp
- end
- body
- end
-
- module_function :read_header, :read_body
end
end
unless MBox::is_break_line? l
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
end
end
+ ## scan forward until we're at the valid start of a message
+ def correct_offset!
+ @mutex.synchronize do
+ @f.seek cur_offset
+ string = ""
+ until @f.eof? || (l = @f.gets) =~ BREAK_RE
+ string << l
+ end
+ self.cur_offset += string.length
+ end
+ end
+
def raw_header offset
ret = ""
@mutex.synchronize do
@f.seek offset
until @f.eof? || (l = @f.gets) =~ /^\r*$/
- ret += l
+ ret << l
end
end
ret
## specific module that would detect and link to /ruby-talk:\d+/
## sequences in the text of an email. (how sweet would that be?)
##
-## this class cathces all source exceptions. if the underlying source throws
-## an error, it is caught and handled.
+## this class catches all source exceptions. if the underlying source
+## throws an error, it is caught and handled.
class Message
SNIPPET_LEN = 80
@snippet = opts[:snippet]
@snippet_contains_encrypted_content = false
@have_snippet = !(opts[:snippet].nil? || opts[:snippet].empty?)
- @labels = [] + (opts[:labels] || [])
+ @labels = (opts[:labels] || []).to_set_of_symbols
@dirty = false
@encrypted = false
@chunks = nil
## 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
def has_label? t; @labels.member? t; end
def add_label t
return if @labels.member? t
- @labels.push t
+ @labels = (@labels + [t]).to_set_of_symbols
@dirty = true
end
def remove_label t
end
def labels= l
- @labels = l
+ @labels = l.to_set_of_symbols
@dirty = true
end
# Lowercase the filename because searches are easier that way
@attachments.push filename.downcase unless filename =~ /^sup-attachment-/
add_label :attachment unless filename =~ /^sup-attachment-/
- [Chunk::Attachment.new(m.header.content_type, filename, m, sibling_types)]
+ content_type = m.header.content_type || "application/unknown" # sometimes RubyMail gives us nil
+ [Chunk::Attachment.new(content_type, filename, m, sibling_types)]
## otherwise, it's body text
else
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
header = {}
header["From"] = (opts[:from] || AccountManager.default_account).full_address
header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
- header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc]
header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc]
header["Subject"] = opts[:subj] if opts[:subj]
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 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 }
@labels = []
counts.map do |label, string, total, unread|
- if total == 0 && !LabelManager::RESERVED_LABELS.include?(label)
+ ## if we've done a search and there are no messages for this label, we can delete it from the
+ ## list. BUT if it's a brand-new label, the user may not have sync'ed it to the index yet, so
+ ## don't delete it in this case.
+ ##
+ ## this is all a hack. what should happen is:
+ ## TODO make the labelmanager responsible for label counts
+ ## and then it can listen to labeled and unlabeled events, etc.
+ if total == 0 && !LabelManager::RESERVED_LABELS.include?(label) && !LabelManager.new_label?(label)
Redwood::log "no hits for label #{label}, deleting"
LabelManager.delete label
next
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", '#'
end
end
end
+ def unsaved?; dirty? end
def lines; @text.length; end
def [] i; @text[i]; end
def contains_thread? t; @threads.include?(t) 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
numi = 0
add_messages_from source do |m, offset, entry|
## always preserve the labels on disk.
- m.labels = ((m.labels - [:unread, :inbox]) + entry[:label].split(/\s+/).map { |x| x.intern }).uniq if entry
+ m.labels = ((m.labels - [:unread, :inbox]) + entry[:label].symbolistize).uniq if entry
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("~")
ret
end
- ## one of the few things i miss from perl
- def ucfirst
- self[0 .. 0].upcase + self[1 .. -1]
- end
-
## a very complicated regex found on teh internets to split on
## commas, unless they occurr within double quotes.
def split_on_commas
def normalize_whitespace
gsub(/\t/, " ").gsub(/\r/, "")
end
+
+ ## takes a space-separated list of words, and returns an array of symbols.
+ ## typically used in Sup for translating Ferret's representation of a list
+ ## of labels (a string) to an array of label symbols.
+ def symbolistize; split.map { |x| x.intern } end
end
class Numeric
def last= e; self[-1] = e end
def nonempty?; !empty? end
+
+ def to_set_of_symbols
+ map { |x| x.is_a?(Symbol) ? x : x.intern }.uniq
+ end
end
class Time
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
--- /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
+
+ def test_from_line_splitting
+ l = MBox::Loader.new StringIO.new(<<EOS)
+From sup-talk-bounces@rubyforge.org Mon Apr 27 12:56:18 2009
+From: Bob <bob@bob.com>
+To: a dear friend
+
+Hello there friend. How are you?
+
+From sea to shining sea
+
+From bob@bob.com I get only spam.
+
+From bob@bob.com
+
+From bob@bob.com
+
+(that second one has spaces at the endj
+
+This is the end of the email.
+EOS
+ offset, labels = l.next
+ assert_equal 0, offset
+ offset, labels = l.next
+ assert_nil offset
+ end
+
+ def test_more_from_line_splitting
+ l = MBox::Loader.new StringIO.new(<<EOS)
+From sup-talk-bounces@rubyforge.org Mon Apr 27 12:56:18 2009
+From: Bob <bob@bob.com>
+To: a dear friend
+
+Hello there friend. How are you?
+
+From bob@bob.com Mon Apr 27 12:56:19 2009
+From: Bob <bob@bob.com>
+To: a dear friend
+
+Hello again! Would you like to buy my products?
+EOS
+ offset, labels = l.next
+ assert_not_nil offset
+
+ offset, labels = l.next
+ assert_not_nil offset
+
+ offset, labels = l.next
+ assert_nil offset
+ 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
-
- def test_from_line_splitting
- l = MBox::Loader.new StringIO.new(<<EOS)
-From sup-talk-bounces@rubyforge.org Mon Apr 27 12:56:18 2009
-From: Bob <bob@bob.com>
-To: a dear friend
-
-Hello there friend. How are you?
-
-From sea to shining sea
-
-From bob@bob.com I get only spam.
-
-From bob@bob.com
-
-From bob@bob.com
-
-(that second one has spaces at the endj
-
-This is the end of the email.
-EOS
- offset, labels = l.next
- assert_equal 0, offset
- offset, labels = l.next
- assert_nil offset
- end
-
- def test_more_from_line_splitting
- l = MBox::Loader.new StringIO.new(<<EOS)
-From sup-talk-bounces@rubyforge.org Mon Apr 27 12:56:18 2009
-From: Bob <bob@bob.com>
-To: a dear friend
-
-Hello there friend. How are you?
-
-From bob@bob.com Mon Apr 27 12:56:19 2009
-From: Bob <bob@bob.com>
-To: a dear friend
-
-Hello again! Would you like to buy my products?
-EOS
- offset, labels = l.next
- assert_not_nil offset
-
- offset, labels = l.next
- assert_not_nil offset
-
- offset, labels = l.next
- assert_nil offset
- 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)