segfault-larch 1.0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/larch ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'highline/import' # optional dep: termios
5
+ require 'trollop'
6
+
7
+ require 'larch'
8
+
9
+ module Larch
10
+
11
+ # Parse command-line options.
12
+ options = Trollop.options do
13
+ version "Larch #{APP_VERSION}\n" << APP_COPYRIGHT
14
+ banner <<-EOS
15
+ Larch syncs messages from one IMAP server to another. Awesomely.
16
+
17
+ Usage:
18
+ larch [config section] [options]
19
+ larch --from <uri> --to <uri> [options]
20
+
21
+ Server Options:
22
+ EOS
23
+ opt :from, "URI of the source IMAP server.", :short => '-f', :type => :string
24
+ opt :from_folder, "Source folder to copy from", :short => '-F', :default => Config::DEFAULT['from-folder']
25
+ opt :from_pass, "Source server password (default: prompt)", :short => '-p', :type => :string
26
+ opt :from_user, "Source server username (default: prompt)", :short => '-u', :type => :string
27
+ opt :to, "URI of the destination IMAP server.", :short => '-t', :type => :string
28
+ opt :to_folder, "Destination folder to copy to", :short => '-T', :default => Config::DEFAULT['to-folder']
29
+ opt :to_pass, "Destination server password (default: prompt)", :short => '-P', :type => :string
30
+ opt :to_user, "Destination server username (default: prompt)", :short => '-U', :type => :string
31
+
32
+ text "\nSync Options:"
33
+ opt :all, "Copy all folders recursively", :short => '-a'
34
+ opt :all_subscribed, "Copy all subscribed folders recursively", :short => '-s'
35
+ opt :exclude, "List of mailbox names/patterns that shouldn't be copied", :short => :none, :type => :strings, :multi => true
36
+ opt :exclude_file, "Filename containing mailbox names/patterns that shouldn't be copied", :short => :none, :type => :string
37
+
38
+ text "\nGeneral Options:"
39
+ opt :config, "Specify a non-default config file to use", :short => '-c', :default => Config::DEFAULT['config']
40
+ opt :database, "Specify a non-default message database to use", :short => :none, :default => Config::DEFAULT['database']
41
+ opt :dry_run, "Don't actually make any changes", :short => '-n'
42
+ opt :max_retries, "Maximum number of times to retry after a recoverable error", :short => :none, :default => Config::DEFAULT['max-retries']
43
+ opt :no_create_folder, "Don't create destination folders that don't already exist", :short => :none
44
+ opt :ssl_certs, "Path to a trusted certificate bundle to use to verify server SSL certificates", :short => :none, :type => :string
45
+ opt :ssl_verify, "Verify server SSL certificates", :short => :none
46
+ opt :verbosity, "Output verbosity: debug, info, warn, error, or fatal", :short => '-V', :default => Config::DEFAULT['verbosity']
47
+ end
48
+
49
+ # Load config.
50
+ config = Config.new(ARGV.shift || 'default', options[:config], options)
51
+
52
+ if options[:config_given]
53
+ Trollop.die :config, ": file not found: #{options[:config]}" unless File.exist?(options[:config])
54
+ end
55
+
56
+ # Validate config.
57
+ begin
58
+ config.validate
59
+ rescue Config::Error => e
60
+ abort "Config error: #{e}"
61
+ end
62
+
63
+ # Create URIs.
64
+ uri_from = URI(config.from)
65
+ uri_to = URI(config.to)
66
+
67
+ # Use --from-folder and --to-folder unless folders were specified in the URIs.
68
+ uri_from.path ||= '/' + CGI.escape(config.from_folder.gsub(/^\//, ''))
69
+ uri_to.path ||= '/' + CGI.escape(config.to_folder.gsub(/^\//, ''))
70
+
71
+ # --all and --all-subscribed options override folders
72
+ if config.all || config.all_subscribed
73
+ uri_from.path = ''
74
+ uri_to.path = ''
75
+ end
76
+
77
+ # Usernames and passwords specified as arguments override those in the URIs
78
+ uri_from.user = CGI.escape(config.from_user) if config.from_user
79
+ uri_from.password = CGI.escape(config.from_pass) if config.from_pass
80
+ uri_to.user = CGI.escape(config.to_user) if config.to_user
81
+ uri_to.password = CGI.escape(config.to_pass) if config.to_pass
82
+
83
+ # If usernames/passwords aren't specified in either URIs or config, then prompt.
84
+ uri_from.user ||= CGI.escape(ask("Source username (#{uri_from.host}): "))
85
+ uri_from.password ||= CGI.escape(ask("Source password (#{uri_from.host}): ") {|q| q.echo = false })
86
+ uri_to.user ||= CGI.escape(ask("Destination username (#{uri_to.host}): "))
87
+ uri_to.password ||= CGI.escape(ask("Destination password (#{uri_to.host}): ") {|q| q.echo = false })
88
+
89
+ # Go go go!
90
+ init(config)
91
+
92
+ imap_from = Larch::IMAP.new(uri_from,
93
+ :dry_run => config[:dry_run],
94
+ :max_retries => config[:max_retries],
95
+ :ssl_certs => config[:ssl_certs] || nil,
96
+ :ssl_verify => config[:ssl_verify]
97
+ )
98
+
99
+ imap_to = Larch::IMAP.new(uri_to,
100
+ :create_mailbox => !config[:no_create_folder] && !config[:dry_run],
101
+ :dry_run => config[:dry_run],
102
+ :max_retries => config[:max_retries],
103
+ :ssl_certs => config[:ssl_certs] || nil,
104
+ :ssl_verify => config[:ssl_verify]
105
+ )
106
+
107
+ unless RUBY_PLATFORM =~ /mswin|mingw|bccwin|wince|java/
108
+ begin
109
+ for sig in [:SIGINT, :SIGQUIT, :SIGTERM]
110
+ trap(sig) { @log.fatal "Interrupted (#{sig})"; Kernel.exit }
111
+ end
112
+ rescue => e
113
+ end
114
+ end
115
+
116
+ if config.all
117
+ copy_all(imap_from, imap_to)
118
+ elsif config.all_subscribed
119
+ copy_all(imap_from, imap_to, true)
120
+ else
121
+ copy_folder(imap_from, imap_to)
122
+ end
123
+ end
data/lib/larch.rb ADDED
@@ -0,0 +1,254 @@
1
+ # Prepend this file's directory to the include path if it's not there already.
2
+ $:.unshift(File.dirname(File.expand_path(__FILE__)))
3
+ $:.uniq!
4
+
5
+ require 'cgi'
6
+ require 'digest/md5'
7
+ require 'fileutils'
8
+ require 'net/imap'
9
+ require 'time'
10
+ require 'uri'
11
+ require 'yaml'
12
+
13
+ require 'sequel'
14
+ require 'sequel/extensions/migration'
15
+
16
+ require 'larch/config'
17
+ require 'larch/errors'
18
+ require 'larch/imap'
19
+ require 'larch/imap/mailbox'
20
+ require 'larch/logger'
21
+ require 'larch/version'
22
+
23
+ module Larch
24
+
25
+ class << self
26
+ attr_reader :config, :db, :log, :exclude
27
+
28
+ EXCLUDE_COMMENT = /#.*$/
29
+ EXCLUDE_REGEX = /^\s*\/(.*)\/\s*/
30
+ GLOB_PATTERNS = {'*' => '.*', '?' => '.'}
31
+ LIB_DIR = File.join(File.dirname(File.expand_path(__FILE__)), 'larch')
32
+
33
+ def init(config)
34
+ raise ArgumentError, "config must be a Larch::Config instance" unless config.is_a?(Config)
35
+
36
+ @config = config
37
+ @log = Logger.new(@config[:verbosity])
38
+ @db = open_db(@config[:database])
39
+
40
+ @exclude = @config[:exclude].map do |e|
41
+ if e =~ EXCLUDE_REGEX
42
+ Regexp.new($1, Regexp::IGNORECASE)
43
+ else
44
+ glob_to_regex(e.strip)
45
+ end
46
+ end
47
+
48
+ load_exclude_file(@config[:exclude_file]) if @config[:exclude_file]
49
+
50
+ Net::IMAP.debug = true if @log.level == :insane
51
+
52
+ # Stats
53
+ @copied = 0
54
+ @failed = 0
55
+ @total = 0
56
+ end
57
+
58
+ # Recursively copies all messages in all folders from the source to the
59
+ # destination.
60
+ def copy_all(imap_from, imap_to, subscribed_only = false)
61
+ raise ArgumentError, "imap_from must be a Larch::IMAP instance" unless imap_from.is_a?(IMAP)
62
+ raise ArgumentError, "imap_to must be a Larch::IMAP instance" unless imap_to.is_a?(IMAP)
63
+
64
+ @copied = 0
65
+ @failed = 0
66
+ @total = 0
67
+
68
+ imap_from.each_mailbox do |mailbox_from|
69
+ next if excluded?(mailbox_from.name)
70
+ next if subscribed_only && !mailbox_from.subscribed?
71
+
72
+ mailbox_to = imap_to.mailbox(mailbox_from.name, mailbox_from.delim)
73
+ mailbox_to.subscribe if mailbox_from.subscribed?
74
+
75
+ copy_messages(mailbox_from, mailbox_to)
76
+ end
77
+
78
+ rescue => e
79
+ @log.fatal e.message
80
+
81
+ ensure
82
+ summary
83
+ end
84
+
85
+ # Copies the messages in a single IMAP folder and all its subfolders
86
+ # (recursively) from the source to the destination.
87
+ def copy_folder(imap_from, imap_to)
88
+ raise ArgumentError, "imap_from must be a Larch::IMAP instance" unless imap_from.is_a?(IMAP)
89
+ raise ArgumentError, "imap_to must be a Larch::IMAP instance" unless imap_to.is_a?(IMAP)
90
+
91
+ @copied = 0
92
+ @failed = 0
93
+ @total = 0
94
+
95
+ mailbox_from = imap_from.mailbox(imap_from.uri_mailbox || 'INBOX')
96
+ mailbox_to = imap_to.mailbox(imap_to.uri_mailbox || 'INBOX')
97
+
98
+ copy_mailbox(mailbox_from, mailbox_to)
99
+
100
+ imap_from.disconnect
101
+ imap_to.disconnect
102
+
103
+ rescue => e
104
+ @log.fatal e.message
105
+
106
+ ensure
107
+ summary
108
+ end
109
+
110
+ # Opens a connection to the Larch message database, creating it if
111
+ # necessary.
112
+ def open_db(database)
113
+ filename = File.expand_path(database)
114
+ directory = File.dirname(filename)
115
+
116
+ unless File.exist?(directory)
117
+ FileUtils.mkdir_p(directory)
118
+ File.chmod(0700, directory)
119
+ end
120
+
121
+ begin
122
+ db = Sequel.connect("sqlite://#{filename}")
123
+ db.test_connection
124
+ rescue => e
125
+ @log.fatal "unable to open message database: #{e}"
126
+ abort
127
+ end
128
+
129
+ # Ensure that the database schema is up to date.
130
+ migration_dir = File.join(LIB_DIR, 'db', 'migrate')
131
+
132
+ unless Sequel::Migrator.get_current_migration_version(db) ==
133
+ Sequel::Migrator.latest_migration_version(migration_dir)
134
+ begin
135
+ Sequel::Migrator.apply(db, migration_dir)
136
+ rescue => e
137
+ @log.fatal "unable to migrate message database: #{e}"
138
+ abort
139
+ end
140
+ end
141
+
142
+ require 'larch/db/message'
143
+ require 'larch/db/mailbox'
144
+ require 'larch/db/account'
145
+
146
+ db
147
+ end
148
+
149
+ def summary
150
+ @log.info "#{@copied} message(s) copied, #{@failed} failed, #{@total - @copied - @failed} untouched out of #{@total} total"
151
+ end
152
+
153
+ private
154
+
155
+ def copy_mailbox(mailbox_from, mailbox_to)
156
+ raise ArgumentError, "mailbox_from must be a Larch::IMAP::Mailbox instance" unless mailbox_from.is_a?(Larch::IMAP::Mailbox)
157
+ raise ArgumentError, "mailbox_to must be a Larch::IMAP::Mailbox instance" unless mailbox_to.is_a?(Larch::IMAP::Mailbox)
158
+
159
+ return if excluded?(mailbox_from.name) || excluded?(mailbox_to.name)
160
+
161
+ mailbox_to.subscribe if mailbox_from.subscribed?
162
+ copy_messages(mailbox_from, mailbox_to)
163
+
164
+ mailbox_from.each_mailbox do |child_from|
165
+ next if excluded?(child_from.name)
166
+ child_to = mailbox_to.imap.mailbox(child_from.name, child_from.delim)
167
+ copy_mailbox(child_from, child_to)
168
+ end
169
+ end
170
+
171
+ def copy_messages(mailbox_from, mailbox_to)
172
+ raise ArgumentError, "mailbox_from must be a Larch::IMAP::Mailbox instance" unless mailbox_from.is_a?(Larch::IMAP::Mailbox)
173
+ raise ArgumentError, "mailbox_to must be a Larch::IMAP::Mailbox instance" unless mailbox_to.is_a?(Larch::IMAP::Mailbox)
174
+
175
+ return if excluded?(mailbox_from.name) || excluded?(mailbox_to.name)
176
+
177
+ imap_from = mailbox_from.imap
178
+ imap_to = mailbox_to.imap
179
+
180
+ @log.info "copying messages from #{imap_from.host}/#{mailbox_from.name} to #{imap_to.host}/#{mailbox_to.name}"
181
+
182
+ @total += mailbox_from.length
183
+
184
+ mailbox_from.each_guid do |guid|
185
+ next if mailbox_to.has_guid?(guid)
186
+
187
+ begin
188
+ next unless msg = mailbox_from.peek(guid)
189
+
190
+ if msg.envelope.from
191
+ env_from = msg.envelope.from.first
192
+ from = "#{env_from.mailbox}@#{env_from.host}"
193
+ else
194
+ from = '?'
195
+ end
196
+
197
+ @log.info "copying message: #{from} - #{msg.envelope.subject}"
198
+
199
+ mailbox_to << msg
200
+ @copied += 1
201
+
202
+ rescue Larch::IMAP::Error => e
203
+ @failed += 1
204
+ @log.error e.message
205
+ next
206
+ end
207
+ end
208
+ end
209
+
210
+ def excluded?(name)
211
+ name = name.downcase
212
+
213
+ @exclude.each do |e|
214
+ return true if (e.is_a?(Regexp) ? !!(name =~ e) : File.fnmatch?(e, name))
215
+ end
216
+
217
+ return false
218
+ end
219
+
220
+ def glob_to_regex(str)
221
+ str.gsub!(/(.)/) {|c| GLOB_PATTERNS[$1] || Regexp.escape(c) }
222
+ Regexp.new("^#{str}$", Regexp::IGNORECASE)
223
+ end
224
+
225
+ def load_exclude_file(filename)
226
+ @exclude ||= []
227
+ lineno = 0
228
+
229
+ File.open(filename, 'rb') do |f|
230
+ f.each do |line|
231
+ lineno += 1
232
+
233
+ # Strip comments.
234
+ line.sub!(EXCLUDE_COMMENT, '')
235
+ line.strip!
236
+
237
+ # Skip empty lines.
238
+ next if line.empty?
239
+
240
+ if line =~ EXCLUDE_REGEX
241
+ @exclude << Regexp.new($1, Regexp::IGNORECASE)
242
+ else
243
+ @exclude << glob_to_regex(line)
244
+ end
245
+ end
246
+ end
247
+
248
+ rescue => e
249
+ raise Larch::IMAP::FatalError, "error in exclude file at line #{lineno}: #{e}"
250
+ end
251
+
252
+ end
253
+
254
+ end
@@ -0,0 +1,105 @@
1
+ module Larch
2
+
3
+ class Config
4
+ attr_reader :filename, :section
5
+
6
+ DEFAULT = {
7
+ 'all' => false,
8
+ 'all-subscribed' => false,
9
+ 'config' => File.join('~', '.larch', 'config.yaml'),
10
+ 'database' => File.join('~', '.larch', 'larch.db'),
11
+ 'dry-run' => false,
12
+ 'exclude' => [],
13
+ 'exclude-file' => nil,
14
+ 'from' => nil,
15
+ 'from-folder' => 'INBOX',
16
+ 'from-pass' => nil,
17
+ 'from-user' => nil,
18
+ 'max-retries' => 3,
19
+ 'no-create-folder' => false,
20
+ 'ssl-certs' => nil,
21
+ 'ssl-verify' => false,
22
+ 'to' => nil,
23
+ 'to-folder' => 'INBOX',
24
+ 'to-pass' => nil,
25
+ 'to-user' => nil,
26
+ 'verbosity' => 'info'
27
+ }.freeze
28
+
29
+ def initialize(section = 'default', filename = DEFAULT['config'], override = {})
30
+ @section = section.to_s
31
+ @override = {}
32
+
33
+ override.each do |k, v|
34
+ k = k.to_s.gsub('_', '-')
35
+ @override[k] = v if DEFAULT.has_key?(k) && v != DEFAULT[k]
36
+ end
37
+
38
+ load_file(filename)
39
+ end
40
+
41
+ def fetch(name)
42
+ (@cached || {})[name.to_s.gsub('_', '-')] || nil
43
+ end
44
+ alias [] fetch
45
+
46
+ def load_file(filename)
47
+ @filename = File.expand_path(filename)
48
+
49
+ config = {}
50
+
51
+ if File.exist?(@filename)
52
+ begin
53
+ config = YAML.load_file(@filename)
54
+ rescue => e
55
+ raise Larch::Config::Error, "config error in #{filename}: #{e}"
56
+ end
57
+ end
58
+
59
+ @lookup = [@override, config[@section] || {}, config['default'] || {}, DEFAULT]
60
+ cache_config
61
+ end
62
+
63
+ def method_missing(name)
64
+ fetch(name)
65
+ end
66
+
67
+ def validate
68
+ ['from', 'to'].each do |s|
69
+ raise Error, "'#{s}' must be a valid IMAP URI (e.g. imap://example.com)" unless fetch(s) =~ IMAP::REGEX_URI
70
+ end
71
+
72
+ unless Logger::LEVELS.has_key?(verbosity.to_sym)
73
+ raise Error, "'verbosity' must be one of: #{Logger::LEVELS.keys.join(', ')}"
74
+ end
75
+
76
+ if exclude_file
77
+ raise Error, "exclude file not found: #{exclude_file}" unless File.file?(exclude_file)
78
+ raise Error, "exclude file cannot be read: #{exclude_file}" unless File.readable?(exclude_file)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ # Merges configs such that those earlier in the lookup chain override those
85
+ # later in the chain.
86
+ def cache_config
87
+ @cached = {}
88
+
89
+ @lookup.reverse.each do |c|
90
+ c.each {|k, v| @cached[k] = config_merge(@cached[k] || {}, v) }
91
+ end
92
+ end
93
+
94
+ def config_merge(master, value)
95
+ if value.is_a?(Hash)
96
+ value.each {|k, v| master[k] = config_merge(master[k] || {}, v) }
97
+ return master
98
+ end
99
+
100
+ value
101
+ end
102
+
103
+ end
104
+
105
+ end