imap-backup 4.0.4 → 4.1.1

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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/bin/imap-backup +5 -2
  3. data/lib/email/provider/apple_mail.rb +2 -2
  4. data/lib/email/provider/base.rb +8 -0
  5. data/lib/email/provider/fastmail.rb +2 -2
  6. data/lib/email/provider/gmail.rb +2 -2
  7. data/lib/email/provider/{default.rb → unknown.rb} +2 -3
  8. data/lib/email/provider.rb +2 -2
  9. data/lib/imap/backup/account/connection.rb +70 -58
  10. data/lib/imap/backup/account/folder.rb +23 -3
  11. data/lib/imap/backup/account.rb +102 -0
  12. data/lib/imap/backup/cli/accounts.rb +43 -0
  13. data/lib/imap/backup/cli/folders.rb +3 -1
  14. data/lib/imap/backup/cli/helpers.rb +8 -9
  15. data/lib/imap/backup/cli/local.rb +4 -2
  16. data/lib/imap/backup/cli/setup.rb +1 -1
  17. data/lib/imap/backup/cli/utils.rb +3 -2
  18. data/lib/imap/backup/{configuration/store.rb → configuration.rb} +49 -14
  19. data/lib/imap/backup/downloader.rb +26 -12
  20. data/lib/imap/backup/logger.rb +42 -0
  21. data/lib/imap/backup/sanitizer.rb +42 -0
  22. data/lib/imap/backup/serializer/mbox_store.rb +2 -2
  23. data/lib/imap/backup/setup/account.rb +177 -0
  24. data/lib/imap/backup/{configuration → setup}/asker.rb +5 -5
  25. data/lib/imap/backup/setup/connection_tester.rb +26 -0
  26. data/lib/imap/backup/{configuration → setup}/folder_chooser.rb +25 -17
  27. data/lib/imap/backup/setup/helpers.rb +15 -0
  28. data/lib/imap/backup/{configuration/setup.rb → setup.rb} +33 -25
  29. data/lib/imap/backup/uploader.rb +2 -2
  30. data/lib/imap/backup/version.rb +2 -2
  31. data/lib/imap/backup.rb +7 -33
  32. data/lib/retry_on_error.rb +1 -1
  33. data/spec/features/backup_spec.rb +1 -0
  34. data/spec/features/support/email_server.rb +5 -2
  35. data/spec/features/support/shared/connection_context.rb +7 -5
  36. data/spec/support/higline_test_helpers.rb +1 -1
  37. data/spec/support/silence_logging.rb +1 -1
  38. data/spec/unit/email/provider/{default_spec.rb → base_spec.rb} +1 -7
  39. data/spec/unit/email/provider_spec.rb +2 -2
  40. data/spec/unit/imap/backup/account/connection_spec.rb +22 -26
  41. data/spec/unit/imap/backup/cli/accounts_spec.rb +47 -0
  42. data/spec/unit/imap/backup/cli/local_spec.rb +15 -4
  43. data/spec/unit/imap/backup/cli/utils_spec.rb +54 -42
  44. data/spec/unit/imap/backup/{configuration/store_spec.rb → configuration_spec.rb} +23 -24
  45. data/spec/unit/imap/backup/downloader_spec.rb +1 -1
  46. data/spec/unit/imap/backup/logger_spec.rb +48 -0
  47. data/spec/unit/imap/backup/{configuration → setup}/account_spec.rb +78 -70
  48. data/spec/unit/imap/backup/{configuration → setup}/asker_spec.rb +2 -2
  49. data/spec/unit/imap/backup/{configuration → setup}/connection_tester_spec.rb +10 -10
  50. data/spec/unit/imap/backup/{configuration → setup}/folder_chooser_spec.rb +25 -26
  51. data/spec/unit/imap/backup/{configuration/setup_spec.rb → setup_spec.rb} +81 -52
  52. metadata +54 -51
  53. data/lib/imap/backup/configuration/account.rb +0 -159
  54. data/lib/imap/backup/configuration/connection_tester.rb +0 -14
  55. data/lib/imap/backup/configuration/list.rb +0 -53
  56. data/spec/support/shared_examples/account_flagging.rb +0 -23
  57. data/spec/unit/imap/backup/configuration/list_spec.rb +0 -89
  58. data/spec/unit/imap/backup_spec.rb +0 -28
@@ -1,11 +1,13 @@
1
1
  require "json"
2
2
  require "os"
3
3
 
4
- module Imap::Backup
5
- module Configuration; end
4
+ require "imap/backup/account"
6
5
 
7
- class Configuration::Store
6
+ module Imap::Backup
7
+ class Configuration
8
8
  CONFIGURATION_DIRECTORY = File.expand_path("~/.imap-backup")
9
+ DEFAULT_DOWNLOAD_BLOCK_SIZE = 1
10
+ VERSION = "2.0"
9
11
 
10
12
  attr_reader :pathname
11
13
 
@@ -19,6 +21,8 @@ module Imap::Backup
19
21
 
20
22
  def initialize(pathname = self.class.default_pathname)
21
23
  @pathname = pathname
24
+ @saved_debug = nil
25
+ @debug = nil
22
26
  end
23
27
 
24
28
  def path
@@ -26,53 +30,84 @@ module Imap::Backup
26
30
  end
27
31
 
28
32
  def save
33
+ ensure_loaded!
29
34
  FileUtils.mkdir(path) if !File.directory?(path)
30
35
  make_private(path) if !windows?
31
36
  remove_modified_flags
32
37
  remove_deleted_accounts
33
- File.open(pathname, "w") { |f| f.write(JSON.pretty_generate(data)) }
38
+ save_data = {
39
+ version: VERSION,
40
+ accounts: accounts.map(&:to_h),
41
+ debug: debug?
42
+ }
43
+ File.open(pathname, "w") { |f| f.write(JSON.pretty_generate(save_data)) }
34
44
  FileUtils.chmod(0o600, pathname) if !windows?
45
+ @data = nil
35
46
  end
36
47
 
37
48
  def accounts
38
- data[:accounts]
49
+ @accounts ||= begin
50
+ ensure_loaded!
51
+ data[:accounts].map { |data| Account.new(data) }
52
+ end
53
+ end
54
+
55
+ def download_block_size
56
+ size = ENV["DOWNLOAD_BLOCK_SIZE"].to_i
57
+ if size > 0
58
+ size
59
+ else
60
+ DEFAULT_DOWNLOAD_BLOCK_SIZE
61
+ end
39
62
  end
40
63
 
41
64
  def modified?
42
- accounts.any? { |a| a[:modified] || a[:delete] }
65
+ ensure_loaded!
66
+ return true if @saved_debug != @debug
67
+
68
+ accounts.any? { |a| a.modified? || a.marked_for_deletion? }
43
69
  end
44
70
 
45
71
  def debug?
46
- data[:debug]
72
+ ensure_loaded!
73
+ @debug
47
74
  end
48
75
 
49
76
  def debug=(value)
50
- data[:debug] = [true, false].include?(value) ? value : false
77
+ ensure_loaded!
78
+ @debug = [true, false].include?(value) ? value : false
51
79
  end
52
80
 
53
81
  private
54
82
 
83
+ def ensure_loaded!
84
+ return true if @data
85
+
86
+ data
87
+ @debug = data.key?(:debug) ? data[:debug] == true : false
88
+ @saved_debug = @debug
89
+ true
90
+ end
91
+
55
92
  def data
56
93
  @data ||=
57
94
  begin
58
95
  if File.exist?(pathname)
59
96
  Utils.check_permissions(pathname, 0o600) if !windows?
60
97
  contents = File.read(pathname)
61
- data = JSON.parse(contents, symbolize_names: true)
98
+ JSON.parse(contents, symbolize_names: true)
62
99
  else
63
- data = {accounts: []}
100
+ {accounts: []}
64
101
  end
65
- data[:debug] = data.key?(:debug) ? data[:debug] == true : false
66
- data
67
102
  end
68
103
  end
69
104
 
70
105
  def remove_modified_flags
71
- accounts.each { |a| a.delete(:modified) }
106
+ accounts.each { |a| a.clear_changes! }
72
107
  end
73
108
 
74
109
  def remove_deleted_accounts
75
- accounts.reject! { |a| a[:delete] }
110
+ accounts.reject! { |a| a.marked_for_deletion? }
76
111
  end
77
112
 
78
113
  def make_private(path)
@@ -2,27 +2,41 @@ module Imap::Backup
2
2
  class Downloader
3
3
  attr_reader :folder
4
4
  attr_reader :serializer
5
+ attr_reader :block_size
5
6
 
6
- def initialize(folder, serializer)
7
+ def initialize(folder, serializer, block_size: 1)
7
8
  @folder = folder
8
9
  @serializer = serializer
10
+ @block_size = block_size
9
11
  end
10
12
 
11
13
  def run
12
14
  uids = folder.uids - serializer.uids
13
15
  count = uids.count
14
- Imap::Backup.logger.debug "[#{folder.name}] #{count} new messages"
15
- uids.each.with_index do |uid, i|
16
- body = folder.fetch(uid)
17
- log_prefix = "[#{folder.name}] uid: #{uid} (#{i + 1}/#{count}) -"
18
- if body.nil?
19
- Imap::Backup.logger.debug("#{log_prefix} not available - skipped")
20
- next
16
+ Imap::Backup::Logger.logger.debug "[#{folder.name}] #{count} new messages"
17
+ uids.each_slice(block_size).with_index do |block, i|
18
+ offset = i * block_size + 1
19
+ uids_and_bodies = folder.fetch_multi(block)
20
+ if uids_and_bodies.nil?
21
+ if block_size > 1
22
+ Imap::Backup::Logger.logger.debug("[#{folder.name}] Multi fetch failed for UIDs #{block.join(", ")}, switching to single fetches")
23
+ @block_size = 1
24
+ redo
25
+ else
26
+ Imap::Backup::Logger.logger.debug("[#{folder.name}] Fetch failed for UID #{block[0]} - skipping")
27
+ next
28
+ end
29
+ end
30
+
31
+ uids_and_bodies.each.with_index do |uid_and_body, j|
32
+ uid = uid_and_body[:uid]
33
+ body = uid_and_body[:body]
34
+ Imap::Backup::Logger.logger.debug(
35
+ "[#{folder.name}] uid: #{uid} (#{offset +j}/#{count}) - " \
36
+ "#{body.size} bytes"
37
+ )
38
+ serializer.save(uid, body)
21
39
  end
22
- Imap::Backup.logger.debug(
23
- "#{log_prefix} #{body.size} bytes"
24
- )
25
- serializer.save(uid, body)
26
40
  end
27
41
  end
28
42
  end
@@ -0,0 +1,42 @@
1
+ require "logger"
2
+ require "singleton"
3
+
4
+ require "imap/backup/configuration"
5
+ require "imap/backup/sanitizer"
6
+
7
+ module Imap::Backup
8
+ class Logger
9
+ include Singleton
10
+
11
+ def self.logger
12
+ Logger.instance.logger
13
+ end
14
+
15
+ def self.setup_logging(config = Configuration.new)
16
+ logger.level =
17
+ if config.debug?
18
+ ::Logger::Severity::DEBUG
19
+ else
20
+ ::Logger::Severity::ERROR
21
+ end
22
+ Net::IMAP.debug = config.debug?
23
+ end
24
+
25
+ def self.sanitize_stderr
26
+ sanitizer = Sanitizer.new($stdout)
27
+ previous_stderr = $stderr
28
+ $stderr = sanitizer
29
+ yield
30
+ ensure
31
+ sanitizer.flush
32
+ $stderr = previous_stderr
33
+ end
34
+
35
+ attr_reader :logger
36
+
37
+ def initialize
38
+ @logger = ::Logger.new($stdout)
39
+ $stdout.sync = true
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ module Imap::Backup
2
+ class Sanitizer
3
+ attr_reader :output
4
+
5
+ def initialize(output)
6
+ @output = output
7
+ @current = ""
8
+ end
9
+
10
+ def write(*args)
11
+ output.write(*args)
12
+ end
13
+
14
+ def print(*args)
15
+ @current << args.join
16
+ loop do
17
+ line, newline, rest = @current.partition("\n")
18
+ break if newline != "\n"
19
+ clean = sanitize(line)
20
+ output.puts clean
21
+ @current = rest
22
+ end
23
+ end
24
+
25
+ def flush
26
+ return if @current == ""
27
+
28
+ clean = sanitize(@current)
29
+ output.puts clean
30
+ end
31
+
32
+ private
33
+
34
+ def sanitize(t)
35
+ # Hide password in Net::IMAP debug output
36
+ t.gsub(
37
+ /\A(C: RUBY\d+ LOGIN \S+) \S+/,
38
+ "\\1 [PASSWORD REDACTED]"
39
+ )
40
+ end
41
+ end
42
+ end
@@ -46,7 +46,7 @@ module Imap::Backup
46
46
 
47
47
  uid = uid.to_i
48
48
  if uids.include?(uid)
49
- Imap::Backup.logger.debug(
49
+ Imap::Backup::Logger.logger.debug(
50
50
  "[#{folder}] message #{uid} already downloaded - skipping"
51
51
  )
52
52
  return
@@ -65,7 +65,7 @@ module Imap::Backup
65
65
  #{body}. #{e}:
66
66
  #{e.backtrace.join("\n")}"
67
67
  ERROR
68
- Imap::Backup.logger.warn message
68
+ Imap::Backup::Logger.logger.warn message
69
69
  ensure
70
70
  mbox&.close
71
71
  end
@@ -0,0 +1,177 @@
1
+ require "imap/backup/setup/helpers"
2
+
3
+ module Imap::Backup
4
+ class Setup; end
5
+
6
+ Setup::Account = Struct.new(:config, :account, :highline) do
7
+ def initialize(config, account, highline)
8
+ super
9
+ end
10
+
11
+ def run
12
+ catch :done do
13
+ loop do
14
+ Kernel.system("clear")
15
+ create_menu
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def create_menu
23
+ highline.choose do |menu|
24
+ header menu
25
+ modify_email menu
26
+ modify_password menu
27
+ modify_backup_path menu
28
+ choose_folders menu
29
+ modify_server menu
30
+ modify_connection_options menu
31
+ test_connection menu
32
+ delete_account menu
33
+ menu.choice("(q) return to main menu") { throw :done }
34
+ menu.hidden("quit") { throw :done }
35
+ end
36
+ end
37
+
38
+ def header(menu)
39
+ modified = account.modified? ? "*" : ""
40
+ connection_options =
41
+ if account.connection_options
42
+ escaped =
43
+ JSON.generate(account.connection_options).
44
+ gsub('"', '\"')
45
+ "\n connection options #{escaped}"
46
+ end
47
+ menu.header = <<~HEADER.chomp
48
+ #{helpers.title_prefix} Account#{modified}
49
+
50
+ email #{account.username}
51
+ password #{masked_password}
52
+ path #{account.local_path}
53
+ folders #{folders.map { |f| f[:name] }.join(', ')}
54
+ server #{account.server}#{connection_options}
55
+
56
+ Choose an action
57
+ HEADER
58
+ end
59
+
60
+ def modify_email(menu)
61
+ menu.choice("modify email") do
62
+ username = Setup::Asker.email(username)
63
+ Kernel.puts "username: #{username}"
64
+ other_accounts = config.accounts.reject { |a| a == account }
65
+ others = other_accounts.map { |a| a.username }
66
+ Kernel.puts "others: #{others.inspect}"
67
+ if others.include?(username)
68
+ Kernel.puts(
69
+ "There is already an account set up with that email address"
70
+ )
71
+ else
72
+ account.username = username
73
+ # rubocop:disable Style/IfUnlessModifier
74
+ default = default_server(username)
75
+ if default && (account.server.nil? || (account.server == ""))
76
+ account.server = default
77
+ end
78
+ # rubocop:enable Style/IfUnlessModifier
79
+ end
80
+ end
81
+ end
82
+
83
+ def modify_password(menu)
84
+ menu.choice("modify password") do
85
+ password = Setup::Asker.password
86
+
87
+ account.password = password if !password.nil?
88
+ end
89
+ end
90
+
91
+ def modify_server(menu)
92
+ menu.choice("modify server") do
93
+ server = highline.ask("server: ")
94
+ account.server = server if !server.nil?
95
+ end
96
+ end
97
+
98
+ def modify_connection_options(menu)
99
+ menu.choice("modify connection options") do
100
+ connection_options = highline.ask("connections options (as JSON): ")
101
+ account.connection_options = connection_options if !connection_options.nil?
102
+ end
103
+ end
104
+
105
+ def path_modification_validator(path)
106
+ same = config.accounts.find do |a|
107
+ a.username != account.username && a.local_path == path
108
+ end
109
+ if same
110
+ Kernel.puts "The path '#{path}' is used to backup " \
111
+ "the account '#{same.username}'"
112
+ false
113
+ else
114
+ true
115
+ end
116
+ end
117
+
118
+ def modify_backup_path(menu)
119
+ menu.choice("modify backup path") do
120
+ existing = account.local_path.clone
121
+ account.local_path = Setup::Asker.backup_path(
122
+ account.local_path, ->(path) { path_modification_validator(path) }
123
+ )
124
+ end
125
+ end
126
+
127
+ def choose_folders(menu)
128
+ menu.choice("choose backup folders") do
129
+ Setup::FolderChooser.new(account).run
130
+ end
131
+ end
132
+
133
+ def test_connection(menu)
134
+ menu.choice("test connection") do
135
+ result = Setup::ConnectionTester.new(account).test
136
+ Kernel.puts result
137
+ highline.ask "Press a key "
138
+ end
139
+ end
140
+
141
+ def delete_account(menu)
142
+ menu.choice("delete") do
143
+ if highline.agree("Are you sure? (y/n) ")
144
+ account.mark_for_deletion!
145
+ throw :done
146
+ end
147
+ end
148
+ end
149
+
150
+ def folders
151
+ account.folders || []
152
+ end
153
+
154
+ def masked_password
155
+ if (account.password == "") || account.password.nil?
156
+ "(unset)"
157
+ else
158
+ account.password.gsub(/./, "x")
159
+ end
160
+ end
161
+
162
+ def default_server(username)
163
+ provider = Email::Provider.for_address(username)
164
+
165
+ if provider.is_a?(Email::Provider::Unknown)
166
+ Kernel.puts "Can't decide provider for email address '#{username}'"
167
+ return nil
168
+ end
169
+
170
+ provider.host
171
+ end
172
+
173
+ def helpers
174
+ Setup::Helpers.new
175
+ end
176
+ end
177
+ end
@@ -1,7 +1,7 @@
1
1
  module Imap::Backup
2
- module Configuration; end
2
+ class Setup; end
3
3
 
4
- Configuration::Asker = Struct.new(:highline) do
4
+ Setup::Asker = Struct.new(:highline) do
5
5
  EMAIL_MATCHER = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]+$/i.freeze
6
6
 
7
7
  def initialize(highline)
@@ -40,15 +40,15 @@ module Imap::Backup
40
40
  end
41
41
 
42
42
  def self.email(default = "")
43
- new(Configuration::Setup.highline).email(default)
43
+ new(Setup.highline).email(default)
44
44
  end
45
45
 
46
46
  def self.password
47
- new(Configuration::Setup.highline).password
47
+ new(Setup.highline).password
48
48
  end
49
49
 
50
50
  def self.backup_path(default, validator)
51
- new(Configuration::Setup.highline).backup_path(default, validator)
51
+ new(Setup.highline).backup_path(default, validator)
52
52
  end
53
53
  end
54
54
  end
@@ -0,0 +1,26 @@
1
+ module Imap::Backup
2
+ class Setup; end
3
+
4
+ class Setup::ConnectionTester
5
+ attr_reader :account
6
+
7
+ def initialize(account)
8
+ @account = account
9
+ end
10
+
11
+ def test
12
+ connection.client
13
+ "Connection successful"
14
+ rescue Net::IMAP::NoResponseError
15
+ "No response"
16
+ rescue StandardError => e
17
+ "Unexpected error: #{e}"
18
+ end
19
+
20
+ private
21
+
22
+ def connection
23
+ Account::Connection.new(account)
24
+ end
25
+ end
26
+ end
@@ -1,7 +1,9 @@
1
+ require "imap/backup/setup/helpers"
2
+
1
3
  module Imap::Backup
2
- module Configuration; end
4
+ class Setup; end
3
5
 
4
- class Configuration::FolderChooser
6
+ class Setup::FolderChooser
5
7
  attr_reader :account
6
8
 
7
9
  def initialize(account)
@@ -10,13 +12,13 @@ module Imap::Backup
10
12
 
11
13
  def run
12
14
  if connection.nil?
13
- Imap::Backup.logger.warn "Connection failed"
15
+ Imap::Backup::Logger.logger.warn "Connection failed"
14
16
  highline.ask "Press a key "
15
17
  return
16
18
  end
17
19
 
18
20
  if imap_folders.nil?
19
- Imap::Backup.logger.warn "Unable to get folder list"
21
+ Imap::Backup::Logger.logger.warn "Unable to get folder list"
20
22
  highline.ask "Press a key "
21
23
  return
22
24
  end
@@ -35,10 +37,14 @@ module Imap::Backup
35
37
 
36
38
  def show_menu
37
39
  highline.choose do |menu|
38
- menu.header = "Add/remove folders"
40
+ menu.header = <<~MENU.chomp
41
+ #{helpers.title_prefix} Add/remove folders
42
+
43
+ Select a folder (toggles)
44
+ MENU
39
45
  menu.index = :number
40
46
  add_folders menu
41
- menu.choice("return to the account menu") { throw :done }
47
+ menu.choice("(q) return to the account menu") { throw :done }
42
48
  menu.hidden("quit") { throw :done }
43
49
  end
44
50
  end
@@ -53,7 +59,7 @@ module Imap::Backup
53
59
  end
54
60
 
55
61
  def selected?(folder_name)
56
- config_folders = account[:folders]
62
+ config_folders = account.folders
57
63
  return false if config_folders.nil?
58
64
 
59
65
  config_folders.find { |f| f[:name] == folder_name }
@@ -62,7 +68,7 @@ module Imap::Backup
62
68
  def remove_missing
63
69
  removed = []
64
70
  config_folders = []
65
- account[:folders].each do |f|
71
+ account.folders.each do |f|
66
72
  found = imap_folders.find { |folder| folder == f[:name] }
67
73
  if found
68
74
  config_folders << f
@@ -73,8 +79,7 @@ module Imap::Backup
73
79
 
74
80
  return if removed.empty?
75
81
 
76
- account[:folders] = config_folders
77
- account[:modified] = true
82
+ account.folders = config_folders
78
83
 
79
84
  Kernel.puts <<~MESSAGE
80
85
  The following folders have been removed: #{removed.join(', ')}
@@ -85,12 +90,11 @@ module Imap::Backup
85
90
 
86
91
  def toggle_selection(folder_name)
87
92
  if selected?(folder_name)
88
- changed = account[:folders].reject! { |f| f[:name] == folder_name }
89
- account[:modified] = true if changed
93
+ new_list = account.folders.select { |f| f[:name] != folder_name }
94
+ account.folders = new_list
90
95
  else
91
- account[:folders] ||= []
92
- account[:folders] << {name: folder_name}
93
- account[:modified] = true
96
+ existing = account.folders || []
97
+ account.folders = existing + [{name: folder_name}]
94
98
  end
95
99
  end
96
100
 
@@ -101,11 +105,15 @@ module Imap::Backup
101
105
  end
102
106
 
103
107
  def imap_folders
104
- @imap_folders ||= connection.folders
108
+ @imap_folders ||= connection.folder_names
105
109
  end
106
110
 
107
111
  def highline
108
- Configuration::Setup.highline
112
+ Setup.highline
113
+ end
114
+
115
+ def helpers
116
+ Setup::Helpers.new
109
117
  end
110
118
  end
111
119
  end
@@ -0,0 +1,15 @@
1
+ require "imap/backup/version"
2
+
3
+ module Imap::Backup
4
+ class Setup; end
5
+
6
+ class Setup::Helpers
7
+ def title_prefix
8
+ "imap-backup –"
9
+ end
10
+
11
+ def version
12
+ Version::VERSION
13
+ end
14
+ end
15
+ end