# i use vi
*.swp
.ditz-config
+# i use emacs
+*~
William Morgan <wmorgan-sup at the masanjin dot nets>
Ismo Puustinen <ismo at the iki dot fis>
+Nicolas Pouillard <nicolas.pouillard at the gmail dot coms>
Marcus Williams <marcus-sup at the bar-coded dot nets>
Lionel Ott <white.magic at the gmx dot des>
-Nicolas Pouillard <nicolas.pouillard at the gmail dot coms>
+Mark Alexander <marka at the pobox dot coms>
+Christopher Warrington <chrisw at the rice dot edus>
Marc Hartstein <marc.hartstein at the alum.vassar dot edus>
Ben Walton <bwalton at the artsci.utoronto dot cas>
Grant Hollingworth <grant at the antiflux dot orgs>
+Steve Goldman <sgoldman at the tower-research dot coms>
+Decklin Foster <decklin at the red-bean dot coms>
Jeff Balogh <its.jeff.balogh at the gmail dot coms>
-Christopher Warrington <chrisw at the rice dot edus>
Giorgio Lando <patroclo7 at the gmail dot coms>
-Decklin Foster <decklin at the red-bean dot coms>
+Israel Herraiz <israel.herraiz at the gmail dot coms>
Ian Taylor <ian at the lorf dot orgs>
-Sam Roberts <sroberts at the uniserve dot coms>
+Rich Lane <rlane at the club.cc.cmu dot edus>
+== 0.7 / 2009-03-16
+* Ferret index corruption issues fixed (hopefully!)
+* Text entry now scrolls to the right on overflow, i.e. is actually usable
+* Ctrl-C now asks user if Sup should die ungracefully
+* Add a limit:<int> search operator to limit the number of results
+* Added a --query option to sup-tweak-labels
+* Added a new hook: shutdown
+* Automatically add self as recipient on crypted sent messages
+* Read in X-Foo headers
+* Added global keybinding 'U' shows only unread messages
+* As always, many bugfixes and tweaks
+
== 0.6 / 2008-08-04
* new hooks: mark-as-spam, reply-to, reply-from
* configurable colors. finally!
== INSTALL:
-* gem install sup -y
+* gem install sup
== PROBLEMS:
== LICENSE:
-Copyright (c) 2006, 2007 William Morgan.
+Copyright (c) 2006--2009 William Morgan.
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
-require 'rubygems'
-require 'hoe'
-$:.unshift 'lib' # force loading from ./lib/ if it exists
-require 'sup'
-
-## remove hoe dependency entirely
-class Hoe
- def extra_dev_deps; @extra_dev_deps.reject { |x| x[0] == "hoe" } end
-end
-
-## allow people who use development versions by running "rake gem"
-## and installing the resulting gem it to be able to do this. (gem
-## versions must be in dotted-digit notation only and can be passed
-## with the REL environment variable to "rake gem").
-if ENV['REL']
- version = ENV['REL']
-else
- version = Redwood::VERSION == "git" ? "999" : Redwood::VERSION
-end
-Hoe.new('sup', version) do |p|
- p.rubyforge_name = 'sup'
- p.author = "William Morgan"
- p.summary = 'A console-based email client with the best features of GMail, mutt, and emacs. Features full text search, labels, tagged operations, multiple buffers, recent contacts, and more.'
- p.description = p.paragraphs_of('README.txt', 2..9).join("\n\n")
- p.url = p.paragraphs_of('README.txt', 0).first.split(/\n/)[2].gsub(/^\s+/, "")
- p.changes = p.paragraphs_of('History.txt', 0..0).join("\n\n")
- p.email = "wmorgan-sup@masanjin.net"
- p.extra_deps = [['ferret', '>= 0.10.13'], ['ncurses', '>= 0.9.1'], ['rmail', '>= 0.17'], 'highline', 'net-ssh', ['trollop', '>= 1.7'], 'lockfile', 'mime-types', 'gettext', 'fastthread']
-end
-
-rule 'ss?.png' => 'ss?-small.png' do |t|
-end
-
## is there really no way to make a rule for this?
WWW_FILES = %w(www/index.html README.txt doc/Philosophy.txt doc/FAQ.txt doc/NewUserGuide.txt www/main.css)
+rule 'ss?.png' => 'ss?-small.png' do |t|
+end
SCREENSHOTS = FileList["www/ss?.png"]
SCREENSHOTS_SMALL = []
SCREENSHOTS.each do |fn|
sh "ditz html ditz"
sh "rsync -essh -cavz ditz wmorgan@rubyforge.org:/var/www/gforge-projects/sup/"
end
+
+task :gem do |t|
+ sh "gem1.8 build sup.gemspec"
+end
+
+task :tarball do |t|
+ require "sup-files"
+ require "sup-version"
+ sh "tar cfvz sup-#{SUP_VERSION}.tgz #{SUP_FILES.join(' ')}"
+end
+Release 0.7:
+
+The big win in this release is that Ferret index corruption issues should now
+be fixed, thanks to an extensive programming of locking and
+thread-safety-adding.
+
+The other nice change is that text entry will now scroll to the right upon
+overflow, thanks to some arcane Curses magic that Steve Goldman discovered.
+
+As always, this release includes many other bugfixes and enhancements.
+
Release 0.6:
Message attachment searchability automatically takes effect on new messages,
No return value.
EOS
+Redwood::HookManager.register "shutdown", <<EOS
+Executes when sup is shutting down. May be run when sup is crashing,
+so don\'t do anything too important. Run before the label, contacts,
+and people are saved.
+No variables.
+No return value.
+EOS
+
if $opts[:list_hooks]
Redwood::HookManager.print_hooks
exit
global_keymap = Keymap.new do |k|
k.add :quit_ask, "Quit Sup, but ask first", 'q'
k.add :quit_now, "Quit Sup immediately", 'Q'
- k.add :help, "Show help", 'H', '?'
+ 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
Ncurses.cbreak
Ncurses.stdscr.keypad 1
+ Ncurses.use_default_colors
Ncurses.curs_set 0
Ncurses.start_color
$cursing = true
end
until Redwood::exceptions.nonempty? || SuicideManager.die?
- c = Ncurses.nonblocking_getch
+ c =
+ begin
+ Ncurses.nonblocking_getch
+ rescue Exception => e
+ if e.is_a?(Interrupt)
+ raise if BufferManager.ask_yes_or_no("Die ungracefully now?")
+ bm.draw_screen
+ nil
+ end
+ end
next unless c
bm.erase_flash
rescue InputSequenceAborted
:nothing
end
-
case action
when :quit_now
break if bm.kill_all_buffers_safely
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?
Index.stop_lock_update_thread
end
+ HookManager.run "shutdown"
+
Redwood::finish
stop_cursing
Redwood::log "stopped cursing"
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 =
- if entry
- entry[:label].split(/\s+/).map { |x| x.intern }
- else
- nil
- end
+ 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
when :asis
- m.labels = index_state if index_state
+ m.labels = ((m.labels - [:unread, :inbox]) + index_state).uniq if index_state
when :restore
## if the entry exists on disk
if restored_state[m.id]
## 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]
EOS
opt :add, "One or more labels (comma-separated) to add to every message from the specified sources", :type => String
opt :remove, "One or more labels (comma-separated) to remove from every message from the specified sources, if those labels are present", :type => String
+ opt :query, "A Sup search query", :type => String
text <<EOS
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
end.map { |s| s.id }
Trollop::die "nothing to do: no sources" if source_ids.empty?
- query = "+(" + source_ids.map { |id| "source_id:#{id}" }.join(" ") + ")"
+ query = "+(" + source_ids.map { |id| "source_id:#{id}" }.join(" OR ") + ")"
if add_labels.empty?
## if all we're doing is removing labels, we can further restrict the
## query to only messages with those labels
query += " +(" + remove_labels.map { |l| "label:#{l}" }.join(" ") + ")"
end
+ query += ' ' + opts[:query] if opts[:query]
+
+ qobj, opts = Redwood::Index.parse_user_query_string query
+ query = Redwood::Index.build_query opts.merge(:qobj => qobj)
results = index.ferret.search query, :limit => :all
num_total = results.total_hits
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
+--- !ditz.rubyforge.org,2008-03-06/issue
+title: make sup-sync-back work on IMAP folders
+desc: ""
+type: :feature
+component: sup-sync-back
+release:
+reporter: William Morgan <wmorgan-sup@masanjin.net>
+status: :unstarted
+disposition:
+creation_time: 2008-10-14 01:14:08.690909 Z
+references: []
+
+id: 2673f091c15dd90222a59621a1842d4ef0a743f7
+log_events:
+- - 2008-10-14 01:14:09.898338 Z
+ - William Morgan <wmorgan-sup@masanjin.net>
+ - created
+ - ""
+git_branch:
component: indexing
release:
reporter: William Morgan <wmorgan-sup@masanjin.net>
-status: :unstarted
-disposition:
+status: :closed
+disposition: :fixed
creation_time: 2008-04-25 19:28:51.369257 Z
references: []
- William Morgan <wmorgan-sup@masanjin.net>
- unassigned from release 0.6
- ""
+- - 2008-11-21 14:23:17.566852 Z
+ - Nicolas Pouillard <nicolas.pouillard@gmail.com>
+ - closed with disposition fixed
+ - |-
+ Loading options was not given to load_thread_for_message in
+ ThreadIndexMode.add_or_unhide.
git_branch:
component: sup
release:
reporter: William Morgan <wmorgan-sup@masanjin.net>
-status: :unstarted
-disposition:
+status: :closed
+disposition: :fixed
creation_time: 2008-05-19 23:42:25.910550 Z
references: []
- William Morgan <wmorgan-sup@masanjin.net>
- unassigned from release 0.6
- ""
+- - 2008-11-22 16:31:27.450146 Z
+ - Nicolas Pouillard <nicolas.pouillard@gmail.com>
+ - closed with disposition fixed
+ - This mapping and the PersonManager are now removed.
git_branch:
--- /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
+
Information passes from Sup to the hook code via Ruby variables
(actually method calls), and from the hook code back to Sup via a
-return value. Each hook description lists the variables and return
-value expected, if any.
+return value. The values of variables persists across calls to the
+same hook, but is NOT available to other hooks. To make the value of a
+variable available to other hooks, use the get and set methods. Each
+hook description lists the variables and return value expected, if
+any.
+
+The following special functions are available to hooks:
+* say msg
+ Displays the string msg to the user at the bottom of the screen.
+* log msg
+ Adds the string msg to the log, which the user can access via the
+ buffer list.
+* ask_yes_or_no question
+ Prompts the user with the string question for a yes or no
+ response. Returns true if the user answered yes, false otherwise.
+* get key
+ Gets the cross-hook value associated with key (which is typically a
+ string). If there is no value for a given key, nil is returned.
+* set key value
+ Sets the cross-hook value associated with key to value. key is
+ typically a string, while value can be whatever type it needs to be,
+ including nil.
Some example hooks:
`/usr/bin/w3m -dump -T #{content_type} '#{filename}'`
end
end
+
+startup:
+ ## runs a background task
+ @bgtask_pid = fork
+ if @bgtask_pid
+ set 'bgtask_pid' @bgtask_pid
+ Process.detach(@bgtask_pid) # so we don't have to wait on it when we go to kill it
+ else
+ exec "background-task args 2&>1 >> /tmp/logfile"
+ end
+
+after-poll:
+ ## kills the background task after the first poll
+ @bgtask_pid = get 'bgtask_pid'
+ Process.kill("TERM", @bgtask_pid) unless @bgtask_pid == nil
+ set 'bgtask_pid' nil
\ No newline at end of file
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
COLOR_FN = File.join(BASE_DIR, "colors.yaml")
SOURCE_FN = File.join(BASE_DIR, "sources.yaml")
LABEL_FN = File.join(BASE_DIR, "labels.txt")
- PERSON_FN = File.join(BASE_DIR, "people.txt")
CONTACT_FN = File.join(BASE_DIR, "contacts.txt")
DRAFT_DIR = File.join(BASE_DIR, "drafts")
SENT_FN = File.join(BASE_DIR, "sent.mbox")
end
def start
- Redwood::PersonManager.new Redwood::PERSON_FN
Redwood::SentManager.new Redwood::SENT_FN
Redwood::ContactManager.new Redwood::CONTACT_FN
Redwood::LabelManager.new Redwood::LABEL_FN
def finish
Redwood::LabelManager.save if Redwood::LabelManager.instantiated?
Redwood::ContactManager.save if Redwood::ContactManager.instantiated?
- Redwood::PersonManager.save if Redwood::PersonManager.instantiated?
Redwood::BufferManager.deinstantiate! if Redwood::BufferManager.instantiated?
end
:confirm_no_attachments => true,
:confirm_top_posting => true,
:discard_snippets_from_encrypted_messages => false,
+ :default_attachment_save_dir => "",
}
begin
FileUtils.mkdir_p Redwood::BASE_DIR
def initialize h
raise ArgumentError, "no name for account" unless h[:name]
- raise ArgumentError, "no name for email" unless h[:name]
- super h[:name], h[:email], 0, true
+ raise ArgumentError, "no email for account" unless h[:email]
+ super h[:name], h[:email]
@sendmail = h[:sendmail]
@signature = h[:signature]
end
hash[:alternates] ||= []
a = Account.new hash
- PersonManager.register a
@accounts[a] = true
if default
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
prefix, target = partial.split_on_commas_with_remainder
target ||= prefix.pop || ""
prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ")
- completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] }
+ completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] }
end
end
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!"
recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] }
contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] }
- completions = (recent + contacts).flatten.uniq.sort
+ completions = (recent + contacts).flatten.uniq
completions += HookManager.run("extra-contact-addresses") || []
answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default
if answer
- answer.split_on_commas.map { |x| ContactManager.contact_for(x.downcase) || PersonManager.person_for(x) }
+ answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) }
end
end
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"] },
- :index_old => { :fg => "white", :bg => "black" },
- :index_new => { :fg => "white", :bg => "black", :attrs => ["bold"] },
- :index_starred => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
- :index_draft => { :fg => "red", :bg => "black", :attrs => ["bold"] },
- :labellist_old => { :fg => "white", :bg => "black" },
- :labellist_new => { :fg => "white", :bg => "black", :attrs => ["bold"] },
- :twiddle => { :fg => "blue", :bg => "black" },
- :label => { :fg => "yellow", :bg => "black" },
+ :index_old => { :fg => "white", :bg => "default" },
+ :index_new => { :fg => "white", :bg => "default", :attrs => ["bold"] },
+ :index_starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
+ :index_draft => { :fg => "red", :bg => "default", :attrs => ["bold"] },
+ :labellist_old => { :fg => "white", :bg => "default" },
+ :labellist_new => { :fg => "white", :bg => "default", :attrs => ["bold"] },
+ :twiddle => { :fg => "blue", :bg => "default" },
+ :label => { :fg => "yellow", :bg => "default" },
:message_patina => { :fg => "black", :bg => "green" },
:alternate_patina => { :fg => "black", :bg => "blue" },
:missing_message => { :fg => "black", :bg => "red" },
- :attachment => { :fg => "cyan", :bg => "black" },
- :cryptosig_valid => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
- :cryptosig_unknown => { :fg => "cyan", :bg => "black" },
+ :attachment => { :fg => "cyan", :bg => "default" },
+ :cryptosig_valid => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
+ :cryptosig_unknown => { :fg => "cyan", :bg => "default" },
:cryptosig_invalid => { :fg => "yellow", :bg => "red", :attrs => ["bold"] },
- :generic_notice_patina => { :fg => "cyan", :bg => "black" },
- :quote_patina => { :fg => "yellow", :bg => "black" },
- :sig_patina => { :fg => "yellow", :bg => "black" },
- :quote => { :fg => "yellow", :bg => "black" },
- :sig => { :fg => "yellow", :bg => "black" },
- :to_me => { :fg => "green", :bg => "black" },
- :starred => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
+ :generic_notice_patina => { :fg => "cyan", :bg => "default" },
+ :quote_patina => { :fg => "yellow", :bg => "default" },
+ :sig_patina => { :fg => "yellow", :bg => "default" },
+ :quote => { :fg => "yellow", :bg => "default" },
+ :sig => { :fg => "yellow", :bg => "default" },
+ :to_me => { :fg => "green", :bg => "default" },
+ :starred => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
:starred_patina => { :fg => "yellow", :bg => "green", :attrs => ["bold"] },
:alternate_starred_patina => { :fg => "yellow", :bg => "blue", :attrs => ["bold"] },
- :snippet => { :fg => "cyan", :bg => "black" },
- :option => { :fg => "white", :bg => "black" },
- :tagged => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
- :draft_notification => { :fg => "red", :bg => "black", :attrs => ["bold"] },
- :completion_character => { :fg => "white", :bg => "black", :attrs => ["bold"] },
- :horizontal_selector_selected => { :fg => "yellow", :bg => "black", :attrs => ["bold"] },
- :horizontal_selector_unselected => { :fg => "cyan", :bg => "black" },
- :search_highlight => { :fg => "black", :bg => "yellow", :attrs => ["bold"] }
+ :snippet => { :fg => "cyan", :bg => "default" },
+ :option => { :fg => "white", :bg => "default" },
+ :tagged => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
+ :draft_notification => { :fg => "red", :bg => "default", :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"] },
+ :system_buf => { :fg => "blue", :bg => "default" },
+ :regular_buf => { :fg => "white", :bg => "default" },
+ :modified_buffer => { :fg => "yellow", :bg => "default", :attrs => ["bold"] },
}
def initialize
IO.foreach(fn) do |l|
l =~ /^([^:]*): (.*)$/ or raise "can't parse #{fn} line #{l.inspect}"
aalias, addr = $1, $2
- p = PersonManager.person_for addr, :definitive => true
+ p = Person.from_address addr
@p2a[p] = aalias
@a2p[aalias] = p unless aalias.nil? || aalias.empty?
end
payload_fn.write format_payload(payload)
payload_fn.close
- recipient_opts = to.map { |r| "--recipient '<#{r}>'" }.join(" ")
+ recipient_opts = (to + [ from ] ).map { |r| "--recipient '<#{r}>'" }.join(" ")
sign_opts = sign ? "--sign --local-user '#{from}'" : ""
gpg_output = run_gpg "--output - --armor --encrypt --textmode #{sign_opts} #{recipient_opts} #{payload_fn.path}"
raise Error, (gpg_output || "gpg command failed: #{cmd}") unless $?.success?
def discard m
docid, entry = Index.load_entry_for_id m.id
- raise ArgumentError, "can't find entry for draft: #{m.id.inspect}" unless entry
+ unless entry
+ Redwood::log "can't find entry for draft: #{m.id.inspect}. You probably already discarded it."
+ return
+ end
raise ArgumentError, "not a draft: source id #{entry[:source_id].inspect}, should be #{DraftManager.source_id.inspect} for #{m.id.inspect} / docno #{docid}" unless entry[:source_id].to_i == DraftManager.source_id
Index.drop_entry docid
File.delete @source.fn_for_offset(entry[:source_info])
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
end
end
+ def get tag
+ HookManager.tags[tag]
+ end
+
+ def set tag, value
+ HookManager.tags[tag] = value
+ end
+
def __binding
binding
end
@hooks = {}
@descs = {}
@contexts = {}
-
+ @tags = {}
+
Dir.mkdir dir unless File.exists? dir
self.class.i_am_the_instance self
end
+ attr_reader :tags
+
def run name, locals={}
hook = hook_for(name) or return
context = @contexts[hook] ||= HookContext.new(name)
require 'rmail'
require 'cgi'
+## TODO: remove synchronized method protector calls; use a Monitor instead
+## (ruby's reentrant mutex)
+
## fucking imap fucking sucks. what the FUCK kind of committee of dunces
## designed this shit.
##
## restriction. it can change any time you log in. it can change EVERY
## time you log in. of course the imap spec "strongly recommends" that it
## never change, but there's nothing to stop people from just setting it
-## to the current timestamp, and in fact that's exactly what the one imap
+## to the current timestamp, and in fact that's EXACTLY what the one imap
## server i have at my disposal does. thus the so-called uids are
## absolutely useless and imap provides no cross-session way of uniquely
## identifying a message. but thanks for the "strong recommendation",
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
synchronized :raw_message
+ def mark_as_deleted ids
+ ids = [ids].flatten # accept single arguments
+ unsynchronized_scan_mailbox
+ imap_ids = ids.map { |i| @imap_state[i] && @imap_state[i][:id] }.compact
+ return if imap_ids.empty?
+ @imap.store imap_ids, "+FLAGS", [:Deleted]
+ end
+ synchronized :mark_as_deleted
+
+ def expunge
+ @imap.expunge
+ unsynchronized_scan_mailbox true
+ true
+ end
+ synchronized :expunge
+
def connect
return if @imap
safely { } # do nothing!
end
synchronized :connect
- def scan_mailbox
- return if @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
+ def scan_mailbox force=false
+ return if !force && @last_scan && (Time.now - @last_scan) < SCAN_INTERVAL
last_id = safely do
@imap.examine mailbox
@imap.responses["EXISTS"].last
end
@last_scan = Time.now
+ @ids = [] if force
return if last_id == @ids.length
range = (@ids.length + 1) .. last_id
%w(RFC822.SIZE INTERNALDATE).each do |w|
raise FatalSourceError, "requested data not in IMAP response: #{w}" unless imap_stuff.attr[w]
end
-
+
msize, mdate = imap_stuff.attr['RFC822.SIZE'] % 10000000, Time.parse(imap_stuff.attr["INTERNALDATE"])
sprintf("%d%07d", mdate.to_i, msize).to_i
end
require 'fileutils'
require 'ferret'
+require 'fastthread'
+
begin
require 'chronic'
$have_chronic = true
include Singleton
+ ## these two accessors should ONLY be used by single-threaded programs.
+ ## otherwise you will have a naughty ferret on your hands.
attr_reader :index
alias ferret index
+
def initialize dir=BASE_DIR
+ @index_mutex = Monitor.new
+
@dir = dir
@sources = {}
@sources_dirty = false
+ @source_mutex = Monitor.new
wsa = Ferret::Analysis::WhiteSpaceAnalyzer.new false
sa = Ferret::Analysis::StandardAnalyzer.new [], true
end
def add_source source
- raise "duplicate source!" if @sources.include? source
- @sources_dirty = true
- max = @sources.max_of { |id, s| s.is_a?(DraftLoader) || s.is_a?(SentLoader) ? 0 : id }
- source.id ||= (max || 0) + 1
- ##source.id += 1 while @sources.member? source.id
- @sources[source.id] = source
+ @source_mutex.synchronize do
+ raise "duplicate source!" if @sources.include? source
+ @sources_dirty = true
+ max = @sources.max_of { |id, s| s.is_a?(DraftLoader) || s.is_a?(SentLoader) ? 0 : id }
+ source.id ||= (max || 0) + 1
+ ##source.id += 1 while @sources.member? source.id
+ @sources[source.id] = source
+ end
end
def sources
## favour the inbox by listing non-archived sources first
- @sources.values.sort_by { |s| s.id }.partition { |s| !s.archived? }.flatten
+ @source_mutex.synchronize { @sources.values }.sort_by { |s| s.id }.partition { |s| !s.archived? }.flatten
end
def source_for uri; sources.find { |s| s.is_source_for? uri }; end
def load_index dir=File.join(@dir, "ferret")
if File.exists? dir
Redwood::log "loading index..."
- @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
- Redwood::log "loaded index of #{@index.size} messages"
+ @index_mutex.synchronize do
+ @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
+ Redwood::log "loaded index of #{@index.size} messages"
+ end
else
Redwood::log "creating index..."
- field_infos = Ferret::Index::FieldInfos.new :store => :yes
- field_infos.add_field :message_id, :index => :untokenized
- field_infos.add_field :source_id
- field_infos.add_field :source_info
- field_infos.add_field :date, :index => :untokenized
- field_infos.add_field :body
- field_infos.add_field :label
- field_infos.add_field :attachments
- field_infos.add_field :subject
- field_infos.add_field :from
- field_infos.add_field :to
- field_infos.add_field :refs
- field_infos.add_field :snippet, :index => :no, :term_vector => :no
- field_infos.create_index dir
- @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
+ @index_mutex.synchronize do
+ field_infos = Ferret::Index::FieldInfos.new :store => :yes
+ field_infos.add_field :message_id, :index => :untokenized
+ field_infos.add_field :source_id
+ field_infos.add_field :source_info
+ field_infos.add_field :date, :index => :untokenized
+ field_infos.add_field :body
+ field_infos.add_field :label
+ field_infos.add_field :attachments
+ field_infos.add_field :subject
+ field_infos.add_field :from
+ field_infos.add_field :to
+ field_infos.add_field :refs
+ field_infos.add_field :snippet, :index => :no, :term_vector => :no
+ field_infos.create_index dir
+ @index = Ferret::Index::Index.new(:path => dir, :analyzer => @analyzer)
+ end
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
- 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
+ @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})"
:snippet => snippet, # always override
:label => labels.uniq.join(" "),
:attachments => (entry[:attachments] || m.attachments.uniq.join(" ")),
- :from => (entry[:from] || (m.from ? m.from.indexable_content : "")),
- :to => (entry[:to] || (m.to + m.cc + m.bcc).map { |x| x.indexable_content }.join(" ")),
+
+ ## always override :from and :to.
+ ## older versions of Sup would often store the wrong thing in the index
+ ## (because they were canonicalizing email addresses, resulting in the
+ ## wrong name associated with each.) the correct address is read from
+ ## the original header when these messages are opened in thread-view-mode,
+ ## so this allows people to forcibly update the address in the index by
+ ## marking those threads for saving.
+ :from => (m.from ? m.from.indexable_content : ""),
+ :to => (m.to + m.cc + m.bcc).map { |x| x.indexable_content }.join(" "),
+
:subject => (entry[:subject] || wrap_subj(Message.normalize_subj(m.subj))),
:refs => (entry[:refs] || (m.refs + m.replytos).uniq.join(" ")),
}
- @index.delete docid if docid
- @index.add_document d
-
- 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
+ @index_mutex.synchronize do
+ @index.delete docid if docid
+ @index.add_document d
+ end
+
+ ## 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")
end
def contains_id? id
- @index.search(Ferret::Search::TermQuery.new(:message_id, id)).total_hits > 0
+ @index_mutex.synchronize { @index.search(Ferret::Search::TermQuery.new(:message_id, id)).total_hits > 0 }
end
- def contains? m; contains_id? m.id; end
- def size; @index.size; end
+ def contains? m; contains_id? m.id end
+ def size; @index_mutex.synchronize { @index.size } end
+ def empty?; size == 0 end
## you should probably not call this on a block that doesn't break
## rather quickly because the results can be very large.
EACH_BY_DATE_NUM = 100
def each_id_by_date opts={}
- return if @index.size == 0 # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
+ return if empty? # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
query = build_query opts
offset = 0
while true
- results = @index.search(query, :sort => "date DESC", :limit => EACH_BY_DATE_NUM, :offset => offset)
+ limit = (opts[:limit])? [EACH_BY_DATE_NUM, opts[:limit] - offset].min : EACH_BY_DATE_NUM
+ results = @index_mutex.synchronize { @index.search query, :sort => "date DESC", :limit => limit, :offset => offset }
Redwood::log "got #{results.total_hits} results for query (offset #{offset}) #{query.inspect}"
- results.hits.each { |hit| yield @index[hit.doc][:message_id], lambda { build_message hit.doc } }
- break if offset >= results.total_hits - EACH_BY_DATE_NUM
- offset += EACH_BY_DATE_NUM
+ results.hits.each do |hit|
+ yield @index_mutex.synchronize { @index[hit.doc][:message_id] }, lambda { build_message hit.doc }
+ end
+ break if opts[:limit] and offset >= opts[:limit] - limit
+ break if offset >= results.total_hits - limit
+ offset += limit
end
end
def num_results_for opts={}
- return 0 if @index.size == 0 # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
+ return 0 if empty? # otherwise ferret barfs ###TODO: remove this once my ferret patch is accepted
q = build_query opts
- index.search(q, :limit => 1).total_hits
+ @index_mutex.synchronize { @index.search(q, :limit => 1).total_hits }
end
## yield all messages in the thread containing 'm' by repeatedly
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
q = build_query :qobj => q
- p1 = @index.search(q).hits.map { |hit| @index[hit.doc][:message_id] }
+ p1 = @index_mutex.synchronize { @index.search(q).hits.map { |hit| @index[hit.doc][:message_id] } }
Redwood::log "found #{p1.size} results for subject query #{q}"
- p2 = @index.search(q.to_s, :limit => :all).hits.map { |hit| @index[hit.doc][:message_id] }
+ p2 = @index_mutex.synchronize { @index.search(q.to_s, :limit => :all).hits.map { |hit| @index[hit.doc][:message_id] } }
Redwood::log "found #{p2.size} results in string form"
pending = (pending + p1 + p2).uniq
num_queries += 1
killed = false
- @index.search_each(q, :limit => :all) do |docid, score|
- break if opts[:limit] && messages.size >= opts[:limit]
- if @index[docid][:label].split(/\s+/).include?("killed") && opts[:skip_killed]
- killed = true
- break
- end
- mid = @index[docid][:message_id]
- unless messages.member?(mid)
- #Redwood::log "got #{mid} as a child of #{id}"
- messages[mid] ||= lambda { build_message docid }
- refs = @index[docid][:refs].split(" ")
- pending += refs.select { |id| !searched[id] }
+ @index_mutex.synchronize do
+ @index.search_each(q, :limit => :all) do |docid, score|
+ break if opts[:limit] && messages.size >= opts[:limit]
+ if @index[docid][:label].split(/\s+/).include?("killed") && opts[:skip_killed]
+ killed = true
+ break
+ end
+ mid = @index[docid][:message_id]
+ unless messages.member?(mid)
+ #Redwood::log "got #{mid} as a child of #{id}"
+ messages[mid] ||= lambda { build_message docid }
+ refs = @index[docid][:refs].split
+ pending += refs.select { |id| !searched[id] }
+ end
end
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
## builds a message object from a ferret result
def build_message docid
- doc = @index[docid]
- source = @sources[doc[:source_id].to_i]
- #puts "building message #{doc[:message_id]} (#{source}##{doc[:source_info]})"
- raise "invalid source #{doc[:source_id]}" unless source
-
- fake_header = {
- "date" => Time.at(doc[:date].to_i),
- "subject" => unwrap_subj(doc[:subject]),
- "from" => doc[:from],
- "to" => doc[:to].split(/\s+/).join(", "), # reformat
- "message-id" => doc[:message_id],
- "references" => doc[:refs].split(/\s+/).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
+ @index_mutex.synchronize do
+ doc = @index[docid]
+
+ source = @source_mutex.synchronize { @sources[doc[:source_id].to_i] }
+ raise "invalid source #{doc[:source_id]}" unless source
+
+ #puts "building message #{doc[:message_id]} (#{source}##{doc[:source_info]})"
+
+ fake_header = {
+ "date" => Time.at(doc[:date].to_i),
+ "subject" => unwrap_subj(doc[:subject]),
+ "from" => doc[:from],
+ "to" => doc[:to].split.join(", "), # reformat
+ "message-id" => doc[:message_id],
+ "references" => doc[:refs].split.map { |x| "<#{x}>" }.join(" "),
+ }
+
+ 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
def fresh_thread_id; @next_thread_id += 1; end
def wrap_subj subj; "__START_SUBJECT__ #{subj} __END_SUBJECT__"; end
def unwrap_subj subj; subj =~ /__START_SUBJECT__ (.*?) __END_SUBJECT__/ && $1; end
- def drop_entry docno; @index.delete docno; end
+ def drop_entry docno; @index_mutex.synchronize { @index.delete docno } end
def load_entry_for_id mid
- results = @index.search(Ferret::Search::TermQuery.new(:message_id, mid))
- return if results.total_hits == 0
- docid = results.hits[0].doc
- [docid, @index[docid]]
+ @index_mutex.synchronize do
+ results = @index.search Ferret::Search::TermQuery.new(:message_id, mid)
+ return if results.total_hits == 0
+ docid = results.hits[0].doc
+ entry = @index[docid]
+ entry_dup = entry.fields.inject({}) { |h, f| h[f] = entry[f]; h }
+ [docid, entry_dup]
+ end
end
def load_contacts emails, h={}
Redwood::log "contact search: #{q}"
contacts = {}
num = h[:num] || 20
- @index.search_each(q, :sort => "date DESC", :limit => :all) do |docid, score|
- break if contacts.size >= num
- #Redwood::log "got message #{docid} to: #{@index[docid][:to].inspect} and from: #{@index[docid][:from].inspect}"
- f = @index[docid][:from]
- t = @index[docid][:to]
-
- if AccountManager.is_account_email? f
- t.split(" ").each { |e| contacts[PersonManager.person_for(e)] = true }
- else
- contacts[PersonManager.person_for(f)] = true
+ @index_mutex.synchronize do
+ @index.search_each q, :sort => "date DESC", :limit => :all do |docid, score|
+ break if contacts.size >= num
+ #Redwood::log "got message #{docid} to: #{@index[docid][:to].inspect} and from: #{@index[docid][:from].inspect}"
+ f = @index[docid][:from]
+ t = @index[docid][:to]
+
+ if AccountManager.is_account_email? f
+ t.split(" ").each { |e| contacts[Person.from_address(e)] = true }
+ else
+ contacts[Person.from_address(f)] = true
+ end
end
end
def load_sources fn=Redwood::SOURCE_FN
source_array = (Redwood::load_yaml_obj(fn) || []).map { |o| Recoverable.new o }
- @sources = Hash[*(source_array).map { |s| [s.id, s] }.flatten]
- @sources_dirty = false
+ @source_mutex.synchronize do
+ @sources = Hash[*(source_array).map { |s| [s.id, s] }.flatten]
+ @sources_dirty = false
+ end
end
def has_any_from_source_with_label? source, label
q = Ferret::Search::BooleanQuery.new
q.add_query Ferret::Search::TermQuery.new("source_id", source.id.to_s), :must
q.add_query Ferret::Search::TermQuery.new("label", label.to_s), :must
- index.search(q, :limit => 1).total_hits > 0
+ @index_mutex.synchronize { @index.search(q, :limit => 1).total_hits > 0 }
end
protected
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"
end
subs = nil if chronic_failure
end
+
+ ## limit:42 restrict the search to 42 results
+ subs = subs.gsub(/\blimit:(\S+)\b/) do
+ lim = $1
+ if lim =~ /^\d+$/
+ extraopts[:limit] = lim.to_i
+ ''
+ else
+ BufferManager.flash "Can't understand limit #{lim.inspect}!"
+ subs = nil
+ end
+ end
if subs
[@qparser.parse(subs), extraopts]
end
def save_sources fn=Redwood::SOURCE_FN
- if @sources_dirty || @sources.any? { |id, s| s.dirty? }
- bakfn = fn + ".bak"
- if File.exists? fn
+ @source_mutex.synchronize do
+ if @sources_dirty || @sources.any? { |id, s| s.dirty? }
+ bakfn = fn + ".bak"
+ if File.exists? fn
+ File.chmod 0600, fn
+ FileUtils.mv fn, bakfn, :force => true unless File.exists?(bakfn) && File.size(fn) == 0
+ end
+ Redwood::save_yaml_obj sources.sort_by { |s| s.id.to_i }, fn, true
File.chmod 0600, fn
- FileUtils.mv fn, bakfn, :force => true unless File.exists?(bakfn) && File.size(fn) == 0
end
- Redwood::save_yaml_obj sources.sort_by { |s| s.id.to_i }, fn, true
- File.chmod 0600, fn
+ @sources_dirty = false
end
- @sources_dirty = false
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 ]
[]
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)\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
- 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: header[last = $1] = $2
- when /^(Message-Id):#{HEADER_RE}$/i: header[mid_field = last = $1] = $2
-
- ## these next 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 /^\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
+ BREAK_RE = /^From \S+ (.+)$/
+
+ def is_break_line? l
+ l =~ BREAK_RE or return false
+ time = $1
+ begin
+ ## hack -- make Time.parse fail when trying to substitute values from Time.now
+ Time.parse time, 0
+ true
+ rescue NoMethodError
+ Redwood::log "found invalid date in potential mbox split line, not splitting: #{l.inspect}"
+ false
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
+ module_function :is_break_line?
end
end
attr_accessor :labels
## uri_or_fp is horrific. need to refactor.
- def initialize uri_or_fp, start_offset=nil, usual=true, archived=false, id=nil, labels=[]
+ def initialize uri_or_fp, start_offset=0, usual=true, archived=false, id=nil, labels=[]
@mutex = Mutex.new
@labels = ((labels || []) - LabelManager::RESERVED_LABELS).uniq.freeze
@mutex.synchronize do
@f.seek offset
l = @f.gets
- unless l =~ BREAK_RE
+ 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
@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? || MBox::is_break_line?(l = @f.gets)
+ RMail::Parser.read string
rescue RMail::Parser::Error => e
raise FatalSourceError, "error parsing mbox file: #{e.message}"
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
def raw_message offset
ret = ""
- each_raw_message_line(offset) { |l| ret += l }
+ each_raw_message_line(offset) { |l| ret << l }
ret
end
@mutex.synchronize do
@f.seek offset
yield @f.gets
- until @f.eof? || (l = @f.gets) =~ BREAK_RE
+ until @f.eof? || MBox::is_break_line?(l = @f.gets)
yield l
end
end
## 2. at the beginning of an mbox separator (in all other
## cases).
- l = @f.gets or raise "next while at EOF"
+ l = @f.gets or return nil
if l =~ /^\s*$/ # case 1
returned_offset = @f.tell
@f.gets # now we're at a BREAK_RE, so skip past it
end
while(line = @f.gets)
- break if line =~ BREAK_RE
+ break if MBox::is_break_line? line
next_offset = @f.tell
end
end
module Redwood
module Chunk
+ WRAP_LEN = 80 # wrap messages and text attachments at this width
+
class Attachment
HookManager.register "mime-decode", <<EOS
-Executes when decoding a MIME attachment.
+Decodes a MIME attachment into text form. The text will be displayed
+directly in Sup. For attachments that you wish to use a separate program
+to view (e.g. images), you should use the mime-view hook instead.
+
Variables:
content_type: the content-type of the message
filename: the filename of the attachment as saved to disk
EOS
HookManager.register "mime-view", <<EOS
-Executes when viewing a MIME attachment, i.e., launching a separate
-viewer program.
+Views a non-text MIME attachment. This hook allows you to run
+third-party programs for attachments that require such a thing (e.g.
+images). To instead display a text version of the attachment directly in
+Sup, use the mime-decode hook instead.
+
+Note that by default (at least on systems that have a run-mailcap command),
+Sup uses the default mailcap handler for the attachment's MIME type. If
+you want a particular behavior to be global, you may wish to change your
+mailcap instead.
+
Variables:
content_type: the content-type of the attachment
filename: the filename of the attachment as saved to disk
Return value:
- True if the viewing was successful, false otherwise.
+ True if the viewing was successful, false otherwise. If false, calling
+ /usr/bin/run-mailcap will be tried.
EOS
#' stupid ruby-mode
@lines = nil
if text
@lines = text.gsub("\r\n", "\n").gsub(/\t/, " ").gsub(/\r/, "").split("\n")
+ @lines = lines.map {|l| l.chomp.wrap WRAP_LEN}.flatten
@quotable = true
end
end
if expandable?
"Attachment: #{filename} (#{lines.length} lines)"
else
- "Attachment: #{filename} (#{content_type})"
+ "Attachment: #{filename} (#{content_type}; #{@raw_content.size.to_human_size})"
end
end
def initial_state; :open end
def viewable?; @lines.nil? end
def view_default! path
- cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}' > /dev/null 2> /dev/null"
+ cmd = "/usr/bin/run-mailcap --action=view '#{@content_type}:#{path}' 2>/dev/null"
Redwood::log "running: #{cmd.inspect}"
system cmd
$? == 0
end
class Text
- WRAP_LEN = 80 # wrap at this width
attr_reader :lines
def initialize lines
def inlineable?; false end
def quotable?; false end
def expandable?; true end
- def initial_state; :open end
+ def initial_state; :closed end
def viewable?; false end
def patina_color; :generic_notice_patina_color end
## 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.each { |k, v| header[k.downcase] = v }
-
- 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"]
- PersonManager.person_for header["from"]
- else
- fakename = "Sup Auto-generated Fake Sender <sup@fake.sender.example.com>"
- PersonManager.person_for 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 = PersonManager.people_for header["to"]
- @cc = PersonManager.people_for header["cc"]
- @bcc = PersonManager.people_for header["bcc"]
+ @to = Person.from_address_list header["to"]
+ @cc = Person.from_address_list header["cc"]
+ @bcc = Person.from_address_list header["bcc"]
## before loading our full header from the source, we can actually
## have some extra refs set by the UI. (this happens when the user
@refs = (@refs + refs).uniq
@replytos = (header["in-reply-to"] || "").scan(/<(.+?)>/).map { |x| sanitize_message_id x.first }
- @replyto = PersonManager.person_for header["reply-to"]
+ @replyto = Person.from_address header["reply-to"]
@list_address =
if header["list-post"]
- @list_address = PersonManager.person_for header["list-post"].gsub(/^<mailto:|>$/, "")
+ @list_address = Person.from_address header["list-post"].gsub(/^<mailto:|>$/, "")
else
nil
end
@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
## 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 =
elsif m.header.content_type == "message/rfc822"
payload = RMail::Parser.read(m.body)
from = payload.header.from.first
- from_person = from ? PersonManager.person_for(from.format) : nil
- [Chunk::EnclosedMessage.new(from_person, payload.to_s)]
+ from_person = from ? Person.from_address(from.format) : nil
+ [Chunk::EnclosedMessage.new(from_person, payload.to_s)] +
+ message_to_chunks(payload, encrypted)
else
filename =
## first, paw through the headers looking for a filename
# 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 self.convert_from body, charset
- charset = "utf-8" if charset =~ /UTF_?8/i
begin
raise MessageFormatError, "RubyMail decode returned a null body" unless body
return body unless charset
- Iconv.iconv($encoding + "//IGNORE", charset, body + " ").join[0 .. -2]
+ Iconv.easy_decode($encoding, charset, body)
rescue Errno::EINVAL, Iconv::InvalidEncoding, Iconv::IllegalSequence, MessageFormatError => e
Redwood::log "warning: error (#{e.class.name}) decoding message body from #{charset}: #{e.message}"
File.open(File.join(BASE_DIR,"unable-to-decode.txt"), "w") { |f| f.write body }
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 }
## do whatever crypto transformation is necessary
if @crypto_selector && @crypto_selector.val != :none
- from_email = PersonManager.person_for(@header["From"]).email
- to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| PersonManager.person_for(p).email }
+ from_email = Person.from_address(@header["From"]).email
+ to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
end
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
end
def sig_lines
- p = PersonManager.person_for(@header["From"])
+ p = Person.from_address(@header["From"])
from_email = p && p.email
## first run the hook
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
hook_reply_from = HookManager.run "reply-from", :message => @m
## sanity check that selection is a Person (or we'll fail below)
- ## don't check that it's an Account, though; assume they know what they're doing.
+ ## don't check that it's an Account, though; assume they know what they're
+ ## doing.
if hook_reply_from && !(hook_reply_from.is_a? Person)
- Redwood::log "reply-from returned non-Person, using default from."
- hook_reply_from = nil
+ Redwood::log "reply-from returned non-Person, using default from."
+ hook_reply_from = nil
end
- from =
- if hook_reply_from
- hook_reply_from
- elsif @m.recipient_email && AccountManager.is_account_email?(@m.recipient_email)
- PersonManager.person_for(@m.recipient_email)
- elsif(b = (@m.to + @m.cc).find { |p| AccountManager.is_account? p })
- b
- else
- AccountManager.default_account
- end
+ ## determine the from address of a reply.
+ ## if we have a value from a hook, use it.
+ from = if hook_reply_from
+ hook_reply_from
+ ## otherwise, if the original email had an envelope-to header, try and use
+ ## it, and look up the corresponding name form the list of accounts.
+ ##
+ ## this is for the case where mail is received from a mailing lists (so the
+ ## To: is the list id itself). if the user subscribes via a particular
+ ## alias, we want to use that alias in the reply.
+ elsif @m.recipient_email && (a = AccountManager.account_for(@m.recipient_email))
+ Person.new a.name, @m.recipient_email
+ ## otherwise, try and find an account somewhere in the list of to's
+ ## and cc's.
+ elsif(b = (@m.to + @m.cc).find { |p| AccountManager.is_account? p })
+ b
+ ## if all else fails, use the default
+ else
+ AccountManager.default_account
+ end
## now, determine to: and cc: addressess. we ignore reply-to for list
## messages because it's typically set to the list address, which we
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
UpdateManager.register self
+ @save_thread_mutex = Mutex.new
+
@last_load_more_size = nil
to_load_more do |size|
next if @last_load_more_size == 0
end
end
+ def unsaved?; dirty? end
def lines; @text.length; end
def [] i; @text[i]; end
def contains_thread? t; @threads.include?(t) end
BufferManager.flash "#{threads.size.pluralize 'thread'} killed."
end
- def save
- dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
- return if dirty_threads.empty?
+ def save background=true
+ if background
+ Redwood::reporting_thread("saving thread") { actually_save }
+ else
+ actually_save
+ end
+ end
- BufferManager.say("Saving threads...") do |say_id|
- dirty_threads.each_with_index do |t, i|
- BufferManager.say "Saving modified thread #{i + 1} of #{dirty_threads.length}...", say_id
- t.save Index
+ def actually_save
+ @save_thread_mutex.synchronize do
+ BufferManager.say("Saving contacts...") { ContactManager.instance.save }
+ dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
+ next if dirty_threads.empty?
+
+ BufferManager.say("Saving threads...") do |say_id|
+ dirty_threads.each_with_index do |t, i|
+ BufferManager.say "Saving modified thread #{i + 1} of #{dirty_threads.length}...", say_id
+ t.save Index
+ end
end
end
end
sleep 0.1 # TODO: necessary?
BufferManager.erase_flash
end
- save
+ save false
super
end
end
def tag_matching
- query = BufferManager.ask :search, "tag threads matching: "
+ query = BufferManager.ask :search, "tag threads matching (regex): "
return if query.nil? || query.empty?
- query = /#{query}/i
+ query = begin
+ /#{query}/i
+ rescue RegexpError => e
+ BufferManager.flash "error interpreting '#{query}': #{e.message}"
+ return
+ end
@mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
regen_text
end
end
def multi_edit_labels threads
- user_labels = BufferManager.ask_for_labels :add_labels, "Add labels: ", [], @hidden_labels
+ user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
return unless user_labels
-
- hl = user_labels.select { |l| @hidden_labels.member? l }
+
+ 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 }
unless hl.empty?
BufferManager.flash "'#{hl}' is a reserved label!"
return
old_labels = threads.map { |t| t.labels.dup }
threads.each do |t|
- user_labels.each do |l|
- t.apply_label l
- LabelManager << l
- UpdateManager.relay self, :labeled, t.first
+ user_labels.each do |(l, to_remove)|
+ if to_remove
+ t.remove_label l
+ else
+ t.apply_label l
+ LabelManager << l
+ end
end
end
def add_or_unhide m
@ts_mutex.synchronize do
if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
- @ts.load_thread_for_message m
+ @ts.load_thread_for_message m, @load_thread_opts
end
@hidden_threads.delete @ts.thread_for(m)
(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
k.add :activate_chunk, "Expand/collapse or activate item", :enter
k.add :expand_all_messages, "Expand/collapse all messages", 'E'
k.add :edit_draft, "Edit draft", 'e'
+ k.add :send_draft, "Send draft", 'y'
k.add :edit_labels, "Edit or add labels for a thread", 'l'
k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
k.add :jump_to_next_open, "Jump to next open message", 'n'
def subscribe_to_list
m = @message_lines[curpos] or return
if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
- ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
+ ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
else
BufferManager.flash "Can't find List-Subscribe header for this message."
end
def unsubscribe_from_list
m = @message_lines[curpos] or return
if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
- ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
+ ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
else
BufferManager.flash "Can't find List-Unsubscribe header for this message."
end
chunk = @chunk_lines[curpos] or return
case chunk
when Chunk::Attachment
- fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", chunk.filename
+ default_dir = File.join(($config[:default_attachment_save_dir] || "."), chunk.filename)
+ fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_dir
save_to_file(fn) { |f| f.print chunk.raw_content } if fn
else
m = @message_lines[curpos]
end
end
+ def send_draft
+ m = @message_lines[curpos] or return
+ if m.is_draft?
+ mode = ResumeMode.new m
+ BufferManager.spawn "Send message", mode
+ BufferManager.kill_buffer self.buffer
+ mode.send_message
+ else
+ BufferManager.flash "Not a draft message!"
+ end
+ end
+
def jump_to_first_open loose_alignment=false
m = @message_lines[0] or return
if @layout[m].state != :closed
[[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
when Message
message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
- (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. To edit, hit 'e'. <<<"]]] : [])
+ (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
else
raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
module Redwood
-class PersonManager
- include Singleton
-
- def initialize fn
- @fn = fn
- @@people = {}
-
- ## read in stored people
- IO.readlines(fn).map do |l|
- l =~ /^(.*)?:\s+(\d+)\s+(.*)$/ or next
- email, time, name = $1, $2, $3
- @@people[email] = Person.new name, email, time, false
- end if File.exists? fn
-
- self.class.i_am_the_instance self
- end
-
- def save
- File.open(@fn, "w") do |f|
- @@people.each do |email, p|
- next if p.email == p.name
- next if p.name =~ /=/ # drop rfc2047-encoded, and lots of other useless emails. definitely a heuristic.
- f.puts "#{p.email}: #{p.timestamp} #{p.name}"
- end
- end
- end
-
- def self.people_for s, opts={}
- return [] if s.nil?
- s.split_on_commas.map { |ss| self.person_for ss, opts }
- end
-
- def self.person_for s, opts={}
- p = Person.from_address(s) or return nil
- p.definitive = true if opts[:definitive]
- register p
- end
-
- def self.register p
- oldp = @@people[p.email]
-
- if oldp.nil? || p.better_than?(oldp)
- @@people[p.email] = p
- end
-
- @@people[p.email].touch!
- @@people[p.email]
- end
-end
-
-## don't create these by hand. rather, go through personmanager, to
-## ensure uniqueness and overriding.
class Person
- attr_accessor :name, :email, :timestamp
- bool_accessor :definitive
+ attr_accessor :name, :email
- def initialize name, email, timestamp=0, definitive=false
+ def initialize name, email
raise ArgumentError, "email can't be nil" unless email
if name
end
@email = email.gsub(/^\s+|\s+$/, "").gsub(/\s+/, " ").downcase
- @definitive = definitive
- @timestamp = timestamp
- end
-
- ## heuristic: whether the name attached to this email is "real", i.e.
- ## we should bother to store it.
- def generic?
- @email =~ /no\-?reply/
- end
-
- def better_than? o
- return false if o.definitive? || generic?
- return true if definitive?
- o.name.nil? || (name && name.length > o.name.length && name =~ /[a-z]/)
end
def to_s; "#@name <#@email>" end
- def touch!; @timestamp = Time.now.to_i end
-
# def == o; o && o.email == email; end
# alias :eql? :==
# def hash; [name, email].hash; end
return nil if s.nil?
## try and parse an email address and name
- name, email =
- case s
+ name, email = case s
+ when /(.+?) ((\S+?)@\S+) \3/
+ ## ok, this first match cause is insane, but bear with me. email
+ ## addresses are stored in the to/from/etc fields of the index in a
+ ## weird format: "name address first-part-of-address", i.e. spaces
+ ## separating those three bits, and no <>'s. this is the output of
+ ## #indexable_content. here, we reverse-engineer that format to extract
+ ## a valid address.
+ ##
+ ## we store things this way to allow searches on a to/from/etc field to
+ ## match any of those parts. a more robust solution would be to store a
+ ## separate, non-indexed field with the proper headers. but this way we
+ ## save precious bits, and it's backwards-compatible with older indexes.
+ [$1, $2]
when /["'](.*?)["'] <(.*?)>/, /([^,]+) <(.*?)>/
a, b = $1, $2
[a.gsub('\"', '"'), b]
Person.new name, email
end
+ def self.from_address_list ss
+ return [] if ss.nil?
+ ss.split_on_commas.map { |s| self.from_address s }
+ end
+
+ ## see comments in self.from_address
def indexable_content
[name, email, email.split(/@/).first].join(" ")
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
numi = 0
add_messages_from source do |m, offset, entry|
## always preserve the labels on disk.
- m.labels = entry[:label].split(/\s+/).map { |x| x.intern } 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}"
# WORD.
end
- charset = "utf-8" if charset =~ /UTF_?8/i
-
- # Convert:
- #
- # Remember - Iconv.open(to, from)!
begin
- text = Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]
+ Iconv.easy_decode(target, charset, text)
rescue Iconv::InvalidCharacter
text
end
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("~")
@w, @y, @x, @width = window, y, x, width
@question = question
@completion_block = block
- @field = Ncurses::Form.new_field 1, @width - question.length, @y, @x + question.length, 0, 0
+ @field = Ncurses::Form.new_field 1, @width - question.length, @y, @x + question.length, 256, 0
@form = Ncurses::Form.new_form [@field]
@value = default
Ncurses::Form.post_form @form
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
@m.synchronize { !@over && @over = true }
end
end
+
+class Iconv
+ def self.easy_decode target, charset, text
+ return text if charset =~ /^(x-unknown|unknown[-_ ]?8bit|ascii[-_ ]?7[-_ ]?bit)$/i
+ charset = case charset
+ when /UTF[-_ ]?8/i: "utf-8"
+ when /(iso[-_ ])?latin[-_ ]?1$/i: "ISO-8859-1"
+ when /iso[-_ ]?8859[-_ ]?15/i: 'ISO-8859-15'
+ when /unicode[-_ ]1[-_ ]1[-_ ]utf[-_]7/i: "utf-7"
+ else charset
+ end
+
+ # Convert:
+ #
+ # Remember - Iconv.open(to, from)!
+ Iconv.iconv(target + "//IGNORE", charset, text + " ").join[0 .. -2]
+ end
+end
git checkout -b release-<releasename>
vi lib/sup.rb bin/sup # and bump BOTH version numbers
# ... git add, commit, etc
-rake release VERSION=<releasename>
+rake gem
+rake tarball
+# manually upload
git publish-branch
rake upload_webpage
rake upload_report
-git checkout next
-git merge master
--- /dev/null
+SUP_LIB_DIRS = %w(lib lib/sup lib/sup/modes lib/sup/mbox)
+SUP_EXECUTABLES = %w(sup sup-add sup-config sup-dump sup-recover-sources sup-sync sup-sync-back sup-tweak-labels)
+SUP_EXTRA_FILES = %w(CONTRIBUTORS README.txt LICENSE History.txt ReleaseNotes)
+SUP_FILES =
+ 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
--- /dev/null
+## allow people who use development versions by running "rake gem"
+## and installing the resulting gem it to be able to do this. (gem
+## versions must be in dotted-digit notation only and can be passed
+## with the REL environment variable to "rake gem").
+SUP_VERSION = if ENV['REL']
+ ENV['REL']
+else
+ $:.unshift 'lib' # force loading from ./lib/ if it exists
+ require 'sup'
+ if Redwood::VERSION == "git"
+ "999"
+ else
+ Redwood::VERSION
+ end
+end
--- /dev/null
+$:.push "lib"
+
+require "sup-files"
+require "sup-version"
+
+Gem::Specification.new do |s|
+ s.name = %q{sup}
+ s.version = SUP_VERSION
+ s.date = Time.now.to_s
+ s.authors = ["William Morgan"]
+ s.email = %q{wmorgan-sup@masanjin.net}
+ s.summary = %q{A console-based email client with the best features of GMail, mutt, and emacs. Features full text search, labels, tagged operations, multiple buffers, recent contacts, and more.}
+ s.homepage = %q{http://sup.rubyforge.org/}
+ s.description = %q{Sup is a console-based email client for people with a lot of email. It supports tagging, very fast full-text search, automatic contact-list management, and more. If you're the type of person who treats email as an extension of your long-term memory, Sup is for you. Sup makes it easy to: - Handle massive amounts of email. - Mix email from different sources: mbox files (even across different machines), Maildir directories, IMAP folders, POP accounts, and GMail accounts. - Instantaneously search over your entire email collection. Search over body text, or use a query language to combine search predicates in any way. - Handle multiple accounts. Replying to email sent to a particular account will use the correct SMTP server, signature, and from address. - Add custom code to handle certain types of messages or to handle certain types of text within messages. - Organize email with user-defined labels, automatically track recent contacts, and much more! The goal of Sup is to become the email client of choice for nerds everywhere.}
+ s.files = SUP_FILES
+ s.executables = SUP_EXECUTABLES
+
+ s.add_dependency "ferret", ">= 0.11.6"
+ s.add_dependency "ncurses", ">= 0.9.1"
+ s.add_dependency "rmail", ">= 0.17"
+ s.add_dependency "highline"
+ s.add_dependency "net-ssh"
+ s.add_dependency "trollop", ">= 1.12"
+ s.add_dependency "lockfile"
+ s.add_dependency "mime-types", "~> 1"
+ s.add_dependency "gettext"
+ s.add_dependency "fastthread"
+
+ puts s.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
+
+ 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_ignore_empty_lines
- variants = [
- "",
- "Message-Id: \n",
- "Message-Id:\n",
- ]
- variants.each do |s|
- h = MBox.read_header StringIO.new(s)
- assert_nil 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
class TestMessage < Test::Unit::TestCase
def setup
- person_file = StringIO.new("")
- # this is a singleton
- if not PersonManager.instantiated?
- @person_manager = PersonManager.new(person_file)
- end
end
def teardown
end
+ def test_blank_header_lines
+
+ message = <<EOS
+Return-Path: <monitor-list-bounces@widget.com>
+X-Original-To: nobody@localhost
+Delivered-To: nobody@localhost.eng.widget.com
+Received: from localhost (localhost.localdomain [127.0.0.1])
+ by soquel.eng.widget.com (Postfix) with ESMTP id 609BC13C0DB1
+ for <nobody@localhost>; Thu, 19 Mar 2009 13:43:21 -0700 (PDT)
+MIME-Version: 1.0
+Received: from pa-excas-vip.widget.com [10.16.67.200]
+ by localhost with IMAP (fetchmail-6.2.5)
+ for nobody@localhost (single-drop); Thu, 19 Mar 2009 13:43:21 -0700 (PDT)
+Received: from pa-exht01.widget.com (10.113.81.167) by pa-excaht11.widget.com
+ (10.113.81.197) with Microsoft SMTP Server (TLS) id 8.1.311.2; Thu, 19 Mar
+ 2009 13:42:30 -0700
+Received: from mailman2.widget.com (10.16.64.159) by pa-exht01.widget.com
+ (10.113.81.167) with Microsoft SMTP Server id 8.1.336.0; Thu, 19 Mar 2009
+ 13:42:30 -0700
+Received: by mailman2.widget.com (Postfix) id 47095AE30856; Thu, 19 Mar 2009
+ 13:42:29 -0700 (PDT)
+Received: from countchocula.widget.com (localhost.localdomain [127.0.0.1]) by
+ mailman2.widget.com (Postfix) with ESMTP id 5F782ABC5948; Thu, 19 Mar 2009
+ 13:42:28 -0700 (PDT)
+Received: from mailhost4.widget.com (mailhost4.widget.com [10.16.67.124]) by
+ mailman2.widget.com (Postfix) with ESMTP id 6CDCCABC5948 for
+ <monitor-list@mailman2.widget.com>; Thu, 19 Mar 2009 13:42:26 -0700 (PDT)
+Received: by mailhost4.widget.com (Postfix) id 2364AC9AC4; Thu, 19 Mar 2009
+ 13:42:26 -0700 (PDT)
+Received: from pa-exht01.widget.com (pa-exht01.widget.com [10.113.81.167]) by
+ mailhost4.widget.com (Postfix) with ESMTP id 17A68C9AC3 for
+ <monitor-list@widget.com>; Thu, 19 Mar 2009 13:42:26 -0700 (PDT)
+Received: from PA-EXMBX04.widget.com ([10.113.81.142]) by pa-exht01.widget.com
+ ([10.113.81.167]) with mapi; Thu, 19 Mar 2009 13:42:26 -0700
+From: Some User <someuser@widget.com>
+To: "monitor-list@widget.com" <monitor-list@widget.com>
+Sender: "monitor-list-bounces@widget.com" <monitor-list-bounces@widget.com>
+Date: Thu, 19 Mar 2009 13:42:25 -0700
+Subject: Looking for a mac
+Thread-Topic: Looking for a mac
+Thread-Index: AQHJqNM1xIqqjNRWuUCUBaxzPFK5eQ==
+Message-ID:
+ <D3C12B2AD838B44DA9D6B2CA334246D011E72A73A4@PA-EXMBX04.widget.com>
+List-Help: <mailto:monitor-list-request@widget.com?subject=help>
+List-Subscribe: <http://mailman2.widget.com/mailman/listinfo/monitor-list>,
+ <mailto:monitor-list-request@widget.com?subject=subscribe>
+List-Unsubscribe:
+ <http://mailman2.widget.com/mailman/listinfo/monitor-list>,
+ <mailto:monitor-list-request@widget.com?subject=unsubscribe>
+Accept-Language: en-US
+Content-Language: en-US
+X-MS-Exchange-Organization-AuthAs: Anonymous
+X-MS-Exchange-Organization-AuthSource: pa-exht01.widget.com
+X-MS-Has-Attach:
+X-Auto-Response-Suppress: All
+X-MS-TNEF-Correlator:
+acceptlanguage: en-US
+delivered-to: monitor-list@widget.com
+errors-to: monitor-list-bounces@widget.com
+list-id: engineering monitor related <monitor-list.widget.com>
+x-mailman-version: 2.1.8
+x-beenthere: monitor-list@widget.com
+x-original-to: monitor-list@mailman2.widget.com
+list-post: <mailto:monitor-list@widget.com>
+list-archive: <http://mailman2.widget.com/pipermail/monitor-list>
+Content-Type: text/plain; charset="us-ascii"
+Content-Transfer-Encoding: quoted-printable
+
+Hi all,
+
+ Just wondering if anybody can lend me a mac to reproduce PR 384931 ?
+ Thanks.
+
+Michael=
+EOS
+
+ source = DummySource.new("sup-test://test_blank_header_lines")
+ source.messages = [ message ]
+ source_info = 0
+
+ sup_message = Message.new( {:source => source, :source_info => source_info } )
+
+ # See how well parsing the message ID went.
+ id = sup_message.id
+ assert_equal("D3C12B2AD838B44DA9D6B2CA334246D011E72A73A4@PA-EXMBX04.widget.com", id)
+
+ # 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>, " +
+ "<mailto:monitor-list-request@widget.com?subject=unsubscribe>",
+ list_unsubscribe)
+
+ end
+
# TODO: test different error cases, malformed messages etc.
# TODO: test different quoting styles, see that they are all divided
<h2>Status</h2>
<p>
- The current version of Sup is 0.6, released 2008-08-03. This is a
+ The current version of Sup is 0.7, released 2009-03-25. This is a
beta release. It supports mbox, mbox over ssh, IMAP, IMAPS, and Maildir mailstores.
</p>
- <p>Issue and release status is available on the <a href="ditz/">Sup ditz page</a>.</p>
+ <!-- <p>Issue and release status is available on the <a href="ditz/">Sup ditz page</a>.</p> -->
+ <p>Sup news can often be see on <a href="http://all-thing.net/label/sup/">William's blog</a>.</p>
<h2>Getting it</h2>
<p>
Sup is brought to you by <a href="http://cs.stanford.edu/~ruby/">William Morgan</a> and the following honorable contributors:
<ul>
- Ismo Puustinen <ismo at the iki dot fis>
- Marcus Williams <marcus-sup at the bar-coded dot nets>
- Lionel Ott <white.magic at the gmx dot des>
- Nicolas Pouillard <nicolas.pouillard at the gmail dot coms>
- Marc Hartstein <marc.hartstein at the alum.vassar dot edus>
- Ben Walton <bwalton at the artsci.utoronto dot cas>
- Grant Hollingworth <grant at the antiflux dot orgs>
- Jeff Balogh <its.jeff.balogh at the gmail dot coms>
- Christopher Warrington <chrisw at the rice dot edus>
- Giorgio Lando <patroclo7 at the gmail dot coms>
- Decklin Foster <decklin at the red-bean dot coms>
- Ian Taylor <ian at the lorf dot orgs>
+<li>William Morgan <wmorgan-sup at the masanjin dot nets></li>
+<li>Ismo Puustinen <ismo at the iki dot fis></li>
+<li>Nicolas Pouillard <nicolas.pouillard at the gmail dot coms></li>
+<li>Marcus Williams <marcus-sup at the bar-coded dot nets></li>
+<li>Lionel Ott <white.magic at the gmx dot des></li>
+<li>Christopher Warrington <chrisw at the rice dot edus></li>
+<li>Marc Hartstein <marc.hartstein at the alum.vassar dot edus></li>
+<li>Ben Walton <bwalton at the artsci.utoronto dot cas></li>
+<li>Grant Hollingworth <grant at the antiflux dot orgs></li>
+<li>Steve Goldman <sgoldman at the tower-research dot coms></li>
+<li>Decklin Foster <decklin at the red-bean dot coms></li>
+<li>Jeff Balogh <its.jeff.balogh at the gmail dot coms></li>
+<li>Giorgio Lando <patroclo7 at the gmail dot coms></li>
+<li>Israel Herraiz <israel.herraiz at the gmail dot coms></li>
+<li>Ian Taylor <ian at the lorf dot orgs></li>
+<li>Rich Lane <rlane at the club.cc.cmu dot edus></li>
</ul>
</p>