imap-backup 5.2.0 → 6.0.0.rc2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -2
  3. data/docs/development.md +10 -4
  4. data/lib/cli_coverage.rb +1 -1
  5. data/lib/imap/backup/account/connection.rb +7 -11
  6. data/lib/imap/backup/account.rb +31 -11
  7. data/lib/imap/backup/cli/folders.rb +3 -3
  8. data/lib/imap/backup/cli/migrate.rb +3 -3
  9. data/lib/imap/backup/cli/utils.rb +2 -2
  10. data/lib/imap/backup/configuration.rb +1 -11
  11. data/lib/imap/backup/downloader.rb +13 -9
  12. data/lib/imap/backup/serializer/directory.rb +37 -0
  13. data/lib/imap/backup/serializer/imap.rb +120 -0
  14. data/lib/imap/backup/serializer/mbox.rb +23 -94
  15. data/lib/imap/backup/serializer/mbox_enumerator.rb +2 -0
  16. data/lib/imap/backup/serializer.rb +180 -3
  17. data/lib/imap/backup/setup/account.rb +52 -29
  18. data/lib/imap/backup/setup/helpers.rb +1 -1
  19. data/lib/imap/backup/thunderbird/mailbox_exporter.rb +1 -1
  20. data/lib/imap/backup/version.rb +3 -3
  21. data/lib/imap/backup.rb +0 -1
  22. data/spec/features/backup_spec.rb +8 -16
  23. data/spec/features/support/aruba.rb +4 -3
  24. data/spec/unit/imap/backup/account/connection_spec.rb +36 -8
  25. data/spec/unit/imap/backup/account/folder_spec.rb +10 -0
  26. data/spec/unit/imap/backup/account_spec.rb +246 -0
  27. data/spec/unit/imap/backup/cli/accounts_spec.rb +12 -1
  28. data/spec/unit/imap/backup/cli/backup_spec.rb +19 -0
  29. data/spec/unit/imap/backup/cli/folders_spec.rb +39 -0
  30. data/spec/unit/imap/backup/cli/local_spec.rb +26 -7
  31. data/spec/unit/imap/backup/cli/migrate_spec.rb +80 -0
  32. data/spec/unit/imap/backup/cli/restore_spec.rb +67 -0
  33. data/spec/unit/imap/backup/cli/setup_spec.rb +17 -0
  34. data/spec/unit/imap/backup/cli/utils_spec.rb +68 -5
  35. data/spec/unit/imap/backup/cli_spec.rb +93 -0
  36. data/spec/unit/imap/backup/client/apple_mail_spec.rb +9 -0
  37. data/spec/unit/imap/backup/configuration_spec.rb +2 -2
  38. data/spec/unit/imap/backup/downloader_spec.rb +59 -7
  39. data/spec/unit/imap/backup/migrator_spec.rb +1 -1
  40. data/spec/unit/imap/backup/sanitizer_spec.rb +42 -0
  41. data/spec/unit/imap/backup/serializer/directory_spec.rb +37 -0
  42. data/spec/unit/imap/backup/serializer/imap_spec.rb +218 -0
  43. data/spec/unit/imap/backup/serializer/mbox_spec.rb +62 -183
  44. data/spec/unit/imap/backup/serializer_spec.rb +296 -0
  45. data/spec/unit/imap/backup/setup/account_spec.rb +120 -25
  46. data/spec/unit/imap/backup/setup/helpers_spec.rb +15 -0
  47. data/spec/unit/imap/backup/thunderbird/mailbox_exporter_spec.rb +116 -0
  48. data/spec/unit/imap/backup/uploader_spec.rb +1 -1
  49. data/spec/unit/retry_on_error_spec.rb +34 -0
  50. metadata +36 -7
  51. data/lib/imap/backup/serializer/mbox_store.rb +0 -217
  52. data/spec/unit/imap/backup/serializer/mbox_store_spec.rb +0 -329
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 164962561883b82372b32826843681c6a802e88982f4906778758a073e1479d8
4
- data.tar.gz: 8a71ad18952f675f61bc6342a3a9a60c2fc71797444142c2af5f98ef18ac13e9
3
+ metadata.gz: 4f9cac7966fac9858fdc380d8e9638c8d479035ee4053444a104b8c3eb5a304c
4
+ data.tar.gz: 5d9785b5e34d90535c8052d216d9b3dbf46ea743672f05129604729662f42f34
5
5
  SHA512:
6
- metadata.gz: 600ff4b72954cecfb2037b9ef12b16029248a6c6e27b2b801661b5c746264a8aed7a99aa689cbeecc8e89f571b9f4c26350669e582bc087a518981bceb677141
7
- data.tar.gz: b706026f0b8231f10dfbb0a0c39162ccdf6a8671c513b6cbf637352dcd8d99987a3f93d2b523db7f4a8e3ff7178877dc3d84c1ad506245a9773330abea2a792f
6
+ metadata.gz: a4595e4316dcdc20123a8ed2bec524d37c1de95da49281a30484349c317a996cf3c6946617a489880a0c41db41698b2ee862d9aee4f9c9b8fa34d67707ba924c
7
+ data.tar.gz: ccee268de9cb6225c72fe0cb11a2552fc793f664429d8dfa918e5b4697961c8e5e15e47ed653dac241f377385806443f17683614b8ff76075a8ff2f12e07f98c
data/README.md CHANGED
@@ -1,3 +1,8 @@
1
+ ![Version](https://img.shields.io/gem/v/imap-backup?label=Version&logo=rubygems)
2
+ [![Build Status](https://github.com/joeyates/imap-backup/actions/workflows/main.yml/badge.svg)][CI Status]
3
+ ![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/joeyates/b54fe758bfb405c04bef72dad293d707/raw/coverage.json)
4
+ ![License](https://img.shields.io/github/license/joeyates/imap-backup?color=brightgreen&label=License)
5
+
1
6
  # imap-backup
2
7
 
3
8
  *Backup GMail (or other IMAP) accounts to disk*
@@ -5,10 +10,12 @@
5
10
  * [Source Code]
6
11
  * [API documentation]
7
12
  * [Rubygem]
13
+ * [CI Status]
8
14
 
9
15
  [Source Code]: https://github.com/joeyates/imap-backup "Source code at GitHub"
10
- [API documentation]: http://rubydoc.info/gems/imap-backup/frames "RDoc API Documentation at Rubydoc.info"
11
- [Rubygem]: http://rubygems.org/gems/imap-backup "Ruby gem at rubygems.org"
16
+ [API documentation]: https://rubydoc.info/gems/imap-backup/frames "RDoc API Documentation at Rubydoc.info"
17
+ [Rubygem]: https://rubygems.org/gems/imap-backup "Ruby gem at rubygems.org"
18
+ [CI Status]: https://github.com/joeyates/imap-backup/actions/workflows/main.yml
12
19
 
13
20
  # Installation
14
21
 
data/docs/development.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Testing
2
2
 
3
- ## Integration Tests
3
+ ## Feature Specs
4
4
 
5
- Integration tests (feature specs) are run against a local IMAP server
6
- controlled by Docker Compose, which needs to be started
7
- before running the test suite.
5
+ Specs under `specs/features` are integration specs run against a local IMAP server
6
+ controlled by Docker Compose.
7
+ Before running the test suite, it needs to be started:
8
8
 
9
9
  ```sh
10
10
  $ docker-compose up -d
@@ -26,6 +26,12 @@ or
26
26
  $ rspec --tag ~docker
27
27
  ```
28
28
 
29
+ ### Debugging
30
+
31
+ The feature specs are run 'out of process' via the Aruba gem.
32
+ In order to see debugging output from the process,
33
+ use `last_command_started.output`.
34
+
29
35
  ## Access Docker imap server
30
36
 
31
37
  ```ruby
data/lib/cli_coverage.rb CHANGED
@@ -4,7 +4,7 @@ class CliCoverage
4
4
  require "simplecov"
5
5
 
6
6
  # Collect coverage separately
7
- SimpleCov.command_name "#{ENV['COVERAGE']} coverage"
7
+ SimpleCov.command_name "#{ENV['COVERAGE']} #{ARGV.join(' ')} coverage"
8
8
 
9
9
  # Silence output
10
10
  SimpleCov.formatter = SimpleCov::Formatter::SimpleFormatter
@@ -1,5 +1,6 @@
1
1
  require "imap/backup/client/apple_mail"
2
2
  require "imap/backup/client/default"
3
+ require "imap/backup/serializer/directory"
3
4
 
4
5
  require "retry_on_error"
5
6
 
@@ -52,7 +53,7 @@ module Imap::Backup
52
53
  def status
53
54
  ensure_account_folder
54
55
  backup_folders.map do |folder|
55
- s = Serializer::Mbox.new(account.local_path, folder.name)
56
+ s = Serializer.new(account.local_path, folder.name)
56
57
  {name: folder.name, local: s.uids, remote: folder.uids}
57
58
  end
58
59
  end
@@ -69,7 +70,7 @@ module Imap::Backup
69
70
  serializer.apply_uid_validity(folder.uid_validity)
70
71
  begin
71
72
  Downloader.new(
72
- folder, serializer, block_size: config.download_block_size
73
+ folder, serializer, multi_fetch_size: account.multi_fetch_size
73
74
  ).run
74
75
  rescue Net::IMAP::ByeResponseError
75
76
  reconnect
@@ -86,7 +87,7 @@ module Imap::Backup
86
87
  base = Pathname.new(account.local_path)
87
88
  Pathname.glob(glob) do |path|
88
89
  name = path.relative_path_from(base).to_s[0..-6]
89
- serializer = Serializer::Mbox.new(account.local_path, name)
90
+ serializer = Serializer.new(account.local_path, name)
90
91
  folder = Account::Folder.new(self, name)
91
92
  yield serializer, folder
92
93
  end
@@ -110,7 +111,6 @@ module Imap::Backup
110
111
  def reset
111
112
  @backup_folders = nil
112
113
  @client = nil
113
- @config = nil
114
114
  @folder_names = nil
115
115
  @provider = nil
116
116
  @server = nil
@@ -144,7 +144,7 @@ module Imap::Backup
144
144
 
145
145
  def each_folder
146
146
  backup_folders.each do |folder|
147
- serializer = Serializer::Mbox.new(account.local_path, folder.name)
147
+ serializer = Serializer.new(account.local_path, folder.name)
148
148
  yield folder, serializer
149
149
  end
150
150
  end
@@ -161,7 +161,7 @@ module Imap::Backup
161
161
  Imap::Backup::Logger.logger.debug(
162
162
  "Backup '#{old_name}' renamed and restored to '#{new_name}'"
163
163
  )
164
- new_serializer = Serializer::Mbox.new(account.local_path, new_name)
164
+ new_serializer = Serializer.new(account.local_path, new_name)
165
165
  new_folder = Account::Folder.new(self, new_name)
166
166
  new_folder.create
167
167
  new_serializer.force_uid_validity(new_folder.uid_validity)
@@ -180,7 +180,7 @@ module Imap::Backup
180
180
  Utils.make_folder(
181
181
  File.dirname(account.local_path),
182
182
  File.basename(account.local_path),
183
- Serializer::DIRECTORY_PERMISSIONS
183
+ Serializer::Directory::DIRECTORY_PERMISSIONS
184
184
  )
185
185
  end
186
186
 
@@ -195,9 +195,5 @@ module Imap::Backup
195
195
  def provider_options
196
196
  provider.options.merge(account.connection_options || {})
197
197
  end
198
-
199
- def config
200
- @config ||= Configuration.new
201
- end
202
198
  end
203
199
  end
@@ -1,5 +1,7 @@
1
1
  module Imap::Backup
2
2
  class Account
3
+ DEFAULT_MULTI_FETCH_SIZE = 1
4
+
3
5
  attr_reader :username
4
6
  attr_reader :password
5
7
  attr_reader :local_path
@@ -7,7 +9,6 @@ module Imap::Backup
7
9
  attr_reader :server
8
10
  attr_reader :connection_options
9
11
  attr_reader :changes
10
- attr_reader :marked_for_deletion
11
12
 
12
13
  def initialize(options)
13
14
  @username = options[:username]
@@ -16,6 +17,7 @@ module Imap::Backup
16
17
  @folders = options[:folders]
17
18
  @server = options[:server]
18
19
  @connection_options = options[:connection_options]
20
+ @multi_fetch_size = options[:multi_fetch_size]
19
21
  @changes = {}
20
22
  @marked_for_deletion = false
21
23
  end
@@ -25,18 +27,18 @@ module Imap::Backup
25
27
  end
26
28
 
27
29
  def valid?
28
- username && password
30
+ username && password ? true : false
29
31
  end
30
32
 
31
33
  def modified?
32
34
  changes.any?
33
35
  end
34
36
 
35
- def clear_changes!
37
+ def clear_changes
36
38
  @changes = {}
37
39
  end
38
40
 
39
- def mark_for_deletion!
41
+ def mark_for_deletion
40
42
  @marked_for_deletion = true
41
43
  end
42
44
 
@@ -53,6 +55,7 @@ module Imap::Backup
53
55
  h[:folders] = @folders if @folders
54
56
  h[:server] = @server if @server
55
57
  h[:connection_options] = @connection_options if @connection_options
58
+ h[:multi_fetch_size] = multi_fetch_size if @multi_fetch_size
56
59
  h
57
60
  end
58
61
 
@@ -82,20 +85,37 @@ module Imap::Backup
82
85
  update(:connection_options, parsed)
83
86
  end
84
87
 
88
+ def multi_fetch_size
89
+ int = @multi_fetch_size.to_i
90
+ if int.positive?
91
+ int
92
+ else
93
+ DEFAULT_MULTI_FETCH_SIZE
94
+ end
95
+ end
96
+
97
+ def multi_fetch_size=(value)
98
+ parsed = value.to_i
99
+ parsed = DEFAULT_MULTI_FETCH_SIZE if !parsed.positive?
100
+ update(:multi_fetch_size, parsed)
101
+ end
102
+
85
103
  private
86
104
 
87
105
  def update(field, value)
106
+ key = :"@#{field}"
88
107
  if changes[field]
89
108
  change = changes[field]
90
- changes.delete(field) if change[:from] == value
109
+ if change[:from] == value
110
+ changes.delete(field)
111
+ else
112
+ change[:to] = value
113
+ end
114
+ else
115
+ current = instance_variable_get(key)
116
+ changes[field] = {from: current, to: value}
91
117
  end
92
- set_field!(field, value)
93
- end
94
118
 
95
- def set_field!(field, value)
96
- key = :"@#{field}"
97
- current = instance_variable_get(key)
98
- changes[field] = {from: current, to: value}
99
119
  instance_variable_set(key, value)
100
120
  end
101
121
  end
@@ -13,15 +13,15 @@ module Imap::Backup
13
13
  no_commands do
14
14
  def run
15
15
  each_connection(account_names) do |connection|
16
- puts connection.account.username
16
+ Kernel.puts connection.account.username
17
17
  # TODO: Make folder_names private once this command
18
18
  # has been removed.
19
19
  folders = connection.folder_names
20
20
  if folders.nil?
21
- warn "Unable to list account folders"
21
+ Kernel.warn "Unable to list account folders"
22
22
  return false
23
23
  end
24
- folders.each { |f| puts "\t#{f}" }
24
+ folders.each { |f| Kernel.puts "\t#{f}" }
25
25
  end
26
26
  end
27
27
  end
@@ -39,11 +39,11 @@ module Imap::Backup
39
39
  end
40
40
 
41
41
  if !destination_account
42
- raise "Account #{destination_email} does not exist"
42
+ raise "Account '#{destination_email}' does not exist"
43
43
  end
44
44
 
45
45
  if !source_account
46
- raise "Account #{source_email} does not exist"
46
+ raise "Account '#{source_email}' does not exist"
47
47
  end
48
48
  end
49
49
 
@@ -61,7 +61,7 @@ module Imap::Backup
61
61
  glob = File.join(source_local_path, "**", "*.imap")
62
62
  Pathname.glob(glob) do |path|
63
63
  name = source_folder_name(path)
64
- serializer = Serializer::Mbox.new(source_local_path, name)
64
+ serializer = Serializer.new(source_local_path, name)
65
65
  folder = folder_for(name)
66
66
  yield serializer, folder
67
67
  end
@@ -14,7 +14,7 @@ module Imap::Backup
14
14
  connection.backup_folders.each do |folder|
15
15
  next if !folder.exist?
16
16
 
17
- serializer = Serializer::Mbox.new(connection.account.local_path, folder.name)
17
+ serializer = Serializer.new(connection.account.local_path, folder.name)
18
18
  do_ignore_folder_history(folder, serializer)
19
19
  end
20
20
  end
@@ -75,7 +75,7 @@ module Imap::Backup
75
75
  Skipped #{uid}
76
76
  MESSAGE
77
77
 
78
- serializer.save(uid, message)
78
+ serializer.append uid, message
79
79
  end
80
80
  end
81
81
 
@@ -6,7 +6,6 @@ require "imap/backup/account"
6
6
  module Imap::Backup
7
7
  class Configuration
8
8
  CONFIGURATION_DIRECTORY = File.expand_path("~/.imap-backup")
9
- DEFAULT_DOWNLOAD_BLOCK_SIZE = 1
10
9
  VERSION = "2.0"
11
10
 
12
11
  attr_reader :pathname
@@ -52,15 +51,6 @@ module Imap::Backup
52
51
  end
53
52
  end
54
53
 
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
62
- end
63
-
64
54
  def modified?
65
55
  ensure_loaded!
66
56
  return true if @saved_debug != @debug
@@ -103,7 +93,7 @@ module Imap::Backup
103
93
  end
104
94
 
105
95
  def remove_modified_flags
106
- accounts.each { |a| a.clear_changes! }
96
+ accounts.each { |a| a.clear_changes }
107
97
  end
108
98
 
109
99
  def remove_deleted_accounts
@@ -1,33 +1,34 @@
1
1
  module Imap::Backup
2
+ class MultiFetchFailedError < StandardError; end
3
+
2
4
  class Downloader
3
5
  attr_reader :folder
4
6
  attr_reader :serializer
5
- attr_reader :block_size
7
+ attr_reader :multi_fetch_size
6
8
 
7
- def initialize(folder, serializer, block_size: 1)
9
+ def initialize(folder, serializer, multi_fetch_size: 1)
8
10
  @folder = folder
9
11
  @serializer = serializer
10
- @block_size = block_size
12
+ @multi_fetch_size = multi_fetch_size
11
13
  end
12
14
 
13
15
  def run
14
16
  uids = folder.uids - serializer.uids
15
17
  count = uids.count
16
18
  debug "#{count} new messages"
17
- uids.each_slice(block_size).with_index do |block, i|
19
+ uids.each_slice(multi_fetch_size).with_index do |block, i|
18
20
  uids_and_bodies = folder.fetch_multi(block)
19
21
  if uids_and_bodies.nil?
20
- if block_size > 1
22
+ if multi_fetch_size > 1
21
23
  debug("Multi fetch failed for UIDs #{block.join(", ")}, switching to single fetches")
22
- @block_size = 1
23
- redo
24
+ raise MultiFetchFailedError
24
25
  else
25
26
  debug("Fetch failed for UID #{block[0]} - skipping")
26
27
  next
27
28
  end
28
29
  end
29
30
 
30
- offset = i * block_size + 1
31
+ offset = i * multi_fetch_size + 1
31
32
  uids_and_bodies.each.with_index do |uid_and_body, j|
32
33
  uid = uid_and_body[:uid]
33
34
  body = uid_and_body[:body]
@@ -38,10 +39,13 @@ module Imap::Backup
38
39
  info("Fetch returned empty UID - skipping")
39
40
  else
40
41
  debug("uid: #{uid} (#{offset + j}/#{count}) - #{body.size} bytes")
41
- serializer.save(uid, body)
42
+ serializer.append uid, body
42
43
  end
43
44
  end
44
45
  end
46
+ rescue MultiFetchFailedError
47
+ @multi_fetch_size = 1
48
+ retry
45
49
  end
46
50
 
47
51
  private
@@ -0,0 +1,37 @@
1
+ require "os"
2
+
3
+ module Imap::Backup
4
+ class Serializer; end
5
+
6
+ class Serializer::Directory
7
+ DIRECTORY_PERMISSIONS = 0o700
8
+
9
+ attr_reader :relative
10
+ attr_reader :path
11
+
12
+ def initialize(path, relative)
13
+ @path = path
14
+ @relative = relative
15
+ end
16
+
17
+ def ensure_exists
18
+ if !File.directory?(full_path)
19
+ Utils.make_folder(
20
+ path, relative, DIRECTORY_PERMISSIONS
21
+ )
22
+ end
23
+
24
+ return if OS.windows?
25
+ return if Utils.mode(full_path) == DIRECTORY_PERMISSIONS
26
+
27
+ FileUtils.chmod DIRECTORY_PERMISSIONS, full_path
28
+ end
29
+
30
+ private
31
+
32
+ def full_path
33
+ containing_directory = File.join(path, relative)
34
+ File.expand_path(containing_directory)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,120 @@
1
+ require "json"
2
+
3
+ module Imap::Backup
4
+ class Serializer::Imap
5
+ CURRENT_VERSION = 2
6
+
7
+ attr_reader :folder_path
8
+ attr_reader :loaded
9
+
10
+ def initialize(folder_path)
11
+ @folder_path = folder_path
12
+ @loaded = false
13
+ @uid_validity = nil
14
+ @uids = nil
15
+ end
16
+
17
+ def append(uid)
18
+ uids << uid
19
+ save
20
+ end
21
+
22
+ def exist?
23
+ File.exist?(pathname)
24
+ end
25
+
26
+ def include?(uid)
27
+ uids.include?(uid)
28
+ end
29
+
30
+ def index(uid)
31
+ uids.find_index(uid)
32
+ end
33
+
34
+ def rename(new_path)
35
+ if exist?
36
+ old_pathname = pathname
37
+ @folder_path = new_path
38
+ File.rename(old_pathname, pathname)
39
+ else
40
+ @folder_path = new_path
41
+ end
42
+ end
43
+
44
+ def uid_validity
45
+ ensure_loaded
46
+ @uid_validity
47
+ end
48
+
49
+ def uid_validity=(value)
50
+ ensure_loaded
51
+ @uid_validity = value
52
+ @uids ||= []
53
+ save
54
+ end
55
+
56
+ def uids
57
+ ensure_loaded
58
+ @uids || []
59
+ end
60
+
61
+ def update_uid(old, new)
62
+ index = uids.find_index(old.to_i)
63
+ return if index.nil?
64
+
65
+ uids[index] = new.to_i
66
+ save
67
+ end
68
+
69
+ private
70
+
71
+ def pathname
72
+ "#{folder_path}.imap"
73
+ end
74
+
75
+ def ensure_loaded
76
+ return if loaded
77
+
78
+ data = load
79
+ if data
80
+ @uids = data[:uids].map(&:to_i)
81
+ @uid_validity = data[:uid_validity]
82
+ else
83
+ @uids = []
84
+ @uid_validity = nil
85
+ end
86
+ @loaded = true
87
+ end
88
+
89
+ def load
90
+ return nil if !exist?
91
+
92
+ data = nil
93
+ begin
94
+ content = File.read(pathname)
95
+ data = JSON.parse(content, symbolize_names: true)
96
+ rescue JSON::ParserError
97
+ return nil
98
+ end
99
+
100
+ return nil if !data.key?(:uids)
101
+ return nil if !data[:uids].is_a?(Array)
102
+
103
+ data
104
+ end
105
+
106
+ def save
107
+ ensure_loaded
108
+
109
+ raise "Cannot save metadata without a uid_validity" if !uid_validity
110
+
111
+ data = {
112
+ version: CURRENT_VERSION,
113
+ uid_validity: @uid_validity,
114
+ uids: @uids
115
+ }
116
+ content = data.to_json
117
+ File.open(pathname, "w") { |f| f.write content }
118
+ end
119
+ end
120
+ end