imap-backup 2.0.0 → 2.2.2

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +0 -1
  3. data/.rspec-all +2 -0
  4. data/.rubocop.yml +15 -2
  5. data/.rubocop_todo.yml +58 -0
  6. data/.travis.yml +15 -2
  7. data/README.md +14 -22
  8. data/Rakefile +6 -3
  9. data/bin/imap-backup +5 -11
  10. data/imap-backup.gemspec +10 -6
  11. data/lib/email/mboxrd/message.rb +16 -16
  12. data/lib/imap/backup/account/connection.rb +38 -22
  13. data/lib/imap/backup/account/folder.rb +23 -7
  14. data/lib/imap/backup/configuration/account.rb +25 -21
  15. data/lib/imap/backup/configuration/asker.rb +3 -2
  16. data/lib/imap/backup/configuration/connection_tester.rb +1 -1
  17. data/lib/imap/backup/configuration/folder_chooser.rb +32 -5
  18. data/lib/imap/backup/configuration/list.rb +2 -0
  19. data/lib/imap/backup/configuration/setup.rb +2 -1
  20. data/lib/imap/backup/configuration/store.rb +3 -6
  21. data/lib/imap/backup/downloader.rb +8 -7
  22. data/lib/imap/backup/serializer/mbox.rb +44 -25
  23. data/lib/imap/backup/serializer/mbox_enumerator.rb +31 -0
  24. data/lib/imap/backup/serializer/mbox_store.rb +35 -32
  25. data/lib/imap/backup/uploader.rb +11 -2
  26. data/lib/imap/backup/utils.rb +11 -9
  27. data/lib/imap/backup/version.rb +2 -2
  28. data/spec/features/backup_spec.rb +6 -5
  29. data/spec/features/helper.rb +1 -1
  30. data/spec/features/restore_spec.rb +75 -27
  31. data/spec/features/support/backup_directory.rb +7 -7
  32. data/spec/features/support/email_server.rb +15 -11
  33. data/spec/features/support/shared/connection_context.rb +2 -2
  34. data/spec/features/support/shared/message_fixtures.rb +8 -0
  35. data/spec/spec_helper.rb +1 -1
  36. data/spec/support/fixtures.rb +2 -2
  37. data/spec/support/higline_test_helpers.rb +1 -1
  38. data/spec/unit/email/mboxrd/message_spec.rb +73 -53
  39. data/spec/unit/email/provider_spec.rb +3 -5
  40. data/spec/unit/imap/backup/account/connection_spec.rb +82 -59
  41. data/spec/unit/imap/backup/account/folder_spec.rb +75 -37
  42. data/spec/unit/imap/backup/configuration/account_spec.rb +95 -61
  43. data/spec/unit/imap/backup/configuration/asker_spec.rb +43 -45
  44. data/spec/unit/imap/backup/configuration/connection_tester_spec.rb +21 -22
  45. data/spec/unit/imap/backup/configuration/folder_chooser_spec.rb +66 -33
  46. data/spec/unit/imap/backup/configuration/list_spec.rb +32 -11
  47. data/spec/unit/imap/backup/configuration/setup_spec.rb +97 -56
  48. data/spec/unit/imap/backup/configuration/store_spec.rb +30 -25
  49. data/spec/unit/imap/backup/downloader_spec.rb +28 -26
  50. data/spec/unit/imap/backup/serializer/mbox_enumerator_spec.rb +45 -0
  51. data/spec/unit/imap/backup/serializer/mbox_spec.rb +109 -51
  52. data/spec/unit/imap/backup/serializer/mbox_store_spec.rb +232 -20
  53. data/spec/unit/imap/backup/uploader_spec.rb +23 -9
  54. data/spec/unit/imap/backup/utils_spec.rb +14 -15
  55. data/spec/unit/imap/backup_spec.rb +28 -0
  56. metadata +13 -7
@@ -0,0 +1,31 @@
1
+ module Imap::Backup
2
+ class Serializer::MboxEnumerator
3
+ attr_reader :mbox_pathname
4
+
5
+ def initialize(mbox_pathname)
6
+ @mbox_pathname = mbox_pathname
7
+ end
8
+
9
+ def each
10
+ return enum_for(:each) if !block_given?
11
+
12
+ File.open(mbox_pathname, "rb") do |f|
13
+ lines = []
14
+
15
+ loop do
16
+ line = f.gets
17
+ break if !line
18
+
19
+ if line.start_with?("From ")
20
+ yield lines.join if lines.count.positive?
21
+ lines = [line]
22
+ else
23
+ lines << line
24
+ end
25
+ end
26
+
27
+ yield lines.join if lines.count.positive?
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
 
3
3
  require "email/mboxrd/message"
4
+ require "imap/backup/serializer/mbox_enumerator"
4
5
 
5
6
  module Imap::Backup
6
7
  class Serializer::MboxStore
@@ -42,6 +43,7 @@ module Imap::Backup
42
43
  def add(uid, message)
43
44
  do_load if !loaded
44
45
  raise "Can't add messages without uid_validity" if !uid_validity
46
+
45
47
  uid = uid.to_i
46
48
  if uids.include?(uid)
47
49
  Imap::Backup.logger.debug(
@@ -58,7 +60,7 @@ module Imap::Backup
58
60
  mbox.write mboxrd_message.to_serialized
59
61
  @uids << uid
60
62
  write_imap_file
61
- rescue => e
63
+ rescue StandardError => e
62
64
  message = <<-ERROR.gsub(/^\s*/m, "")
63
65
  [#{folder}] failed to save message #{uid}:
64
66
  #{body}. #{e}:
@@ -66,20 +68,39 @@ module Imap::Backup
66
68
  ERROR
67
69
  Imap::Backup.logger.warn message
68
70
  ensure
69
- mbox.close if mbox
71
+ mbox&.close
70
72
  end
71
73
  end
72
74
 
73
- def load(uid)
75
+ def load(uid_maybe_string)
74
76
  do_load if !loaded
77
+ uid = uid_maybe_string.to_i
75
78
  message_index = uids.find_index(uid)
76
79
  return nil if message_index.nil?
80
+
77
81
  load_nth(message_index)
78
82
  end
79
83
 
84
+ def each_message(required_uids)
85
+ return enum_for(:each_message, required_uids) if !block_given?
86
+
87
+ indexes = required_uids.each.with_object({}) do |uid, acc|
88
+ index = uids.find_index(uid)
89
+ acc[index] = uid if index
90
+ end
91
+ enumerator = Serializer::MboxEnumerator.new(mbox_pathname)
92
+ enumerator.each.with_index do |raw, i|
93
+ uid = indexes[i]
94
+ next if !uid
95
+
96
+ yield uid, Email::Mboxrd::Message.from_serialized(raw)
97
+ end
98
+ end
99
+
80
100
  def update_uid(old, new)
81
101
  index = uids.find_index(old.to_i)
82
102
  return if index.nil?
103
+
83
104
  uids[index] = new.to_i
84
105
  write_imap_file
85
106
  end
@@ -93,23 +114,19 @@ module Imap::Backup
93
114
  end
94
115
 
95
116
  def rename(new_name)
96
- new_mbox_pathname = absolute_path(new_name + ".mbox")
97
- new_imap_pathname = absolute_path(new_name + ".imap")
117
+ new_mbox_pathname = absolute_path("#{new_name}.mbox")
118
+ new_imap_pathname = absolute_path("#{new_name}.imap")
98
119
  File.rename(mbox_pathname, new_mbox_pathname)
99
120
  File.rename(imap_pathname, new_imap_pathname)
100
121
  @folder = new_name
101
122
  end
102
123
 
103
- def relative_path
104
- File.dirname(folder)
105
- end
106
-
107
124
  private
108
125
 
109
126
  def do_load
110
127
  data = imap_data
111
128
  if data
112
- @uids = data[:uids].map(&:to_i).sort
129
+ @uids = data[:uids].map(&:to_i)
113
130
  @uid_validity = data[:uid_validity]
114
131
  @loaded = true
115
132
  else
@@ -128,7 +145,7 @@ module Imap::Backup
128
145
  return nil
129
146
  end
130
147
 
131
- return nil if !imap_data.has_key?(:uids)
148
+ return nil if !imap_data.key?(:uids)
132
149
  return nil if !imap_data[:uids].is_a?(Array)
133
150
 
134
151
  imap_data
@@ -137,37 +154,23 @@ module Imap::Backup
137
154
  def imap_ok?
138
155
  return false if !exist?
139
156
  return false if !imap_looks_like_json?
157
+
140
158
  true
141
159
  end
142
160
 
143
161
  def load_nth(index)
144
- each_mbox_message.with_index do |raw, i|
162
+ enumerator = Serializer::MboxEnumerator.new(mbox_pathname)
163
+ enumerator.each.with_index do |raw, i|
145
164
  next unless i == index
165
+
146
166
  return Email::Mboxrd::Message.from_serialized(raw)
147
167
  end
148
168
  nil
149
169
  end
150
170
 
151
- def each_mbox_message
152
- Enumerator.new do |e|
153
- File.open(mbox_pathname) do |f|
154
- lines = []
155
-
156
- while line = f.gets
157
- if line.start_with?("From ")
158
- e.yield lines.join if lines.count > 0
159
- lines = [line]
160
- else
161
- lines << line
162
- end
163
- end
164
- e.yield lines.join if lines.count > 0
165
- end
166
- end
167
- end
168
-
169
171
  def imap_looks_like_json?
170
172
  return false unless imap_exist?
173
+
171
174
  content = File.read(imap_pathname)
172
175
  content.start_with?("{")
173
176
  end
@@ -204,11 +207,11 @@ module Imap::Backup
204
207
  end
205
208
 
206
209
  def mbox_pathname
207
- absolute_path(folder + ".mbox")
210
+ absolute_path("#{folder}.mbox")
208
211
  end
209
212
 
210
213
  def imap_pathname
211
- absolute_path(folder + ".imap")
214
+ absolute_path("#{folder}.imap")
212
215
  end
213
216
  end
214
217
  end
@@ -9,9 +9,18 @@ module Imap::Backup
9
9
  end
10
10
 
11
11
  def run
12
- missing_uids.each do |uid|
13
- message = serializer.load(uid)
12
+ count = missing_uids.count
13
+ return if count.zero?
14
+
15
+ Imap::Backup.logger.debug "[#{folder.name}] #{count} to restore"
16
+ serializer.each_message(missing_uids).with_index do |(uid, message), i|
14
17
  next if message.nil?
18
+
19
+ log_prefix = "[#{folder.name}] uid: #{uid} (#{i + 1}/#{count}) -"
20
+ Imap::Backup.logger.debug(
21
+ "#{log_prefix} #{message.supplied_body.size} bytes"
22
+ )
23
+
15
24
  new_uid = folder.append(message)
16
25
  serializer.update_uid(uid, new_uid)
17
26
  end
@@ -5,15 +5,16 @@ module Imap::Backup
5
5
  def self.check_permissions(filename, limit)
6
6
  actual = mode(filename)
7
7
  return nil if actual.nil?
8
+
8
9
  mask = ~limit & 0o777
9
- if actual & mask != 0
10
- message = format(
11
- "Permissions on '%<filename>s' " \
12
- "should be 0%<limit>o, not 0%<actual>o",
13
- filename: filename, limit: limit, actual: actual
14
- )
15
- raise message
16
- end
10
+ return if (actual & mask).zero?
11
+
12
+ message = format(
13
+ "Permissions on '%<filename>s' " \
14
+ "should be 0%<limit>o, not 0%<actual>o",
15
+ filename: filename, limit: limit, actual: actual
16
+ )
17
+ raise message
17
18
  end
18
19
 
19
20
  def self.mode(filename)
@@ -25,7 +26,8 @@ module Imap::Backup
25
26
 
26
27
  def self.make_folder(base_path, path, permissions)
27
28
  parts = path.split("/")
28
- return if parts.size == 0
29
+ return if parts.empty?
30
+
29
31
  full_path = File.join(base_path, path)
30
32
  FileUtils.mkdir_p full_path
31
33
  path = base_path
@@ -2,7 +2,7 @@ module Imap; end
2
2
 
3
3
  module Imap::Backup
4
4
  MAJOR = 2
5
- MINOR = 0
6
- REVISION = 0
5
+ MINOR = 2
6
+ REVISION = 2
7
7
  VERSION = [MAJOR, MINOR, REVISION].compact.map(&:to_s).join(".")
8
8
  end
@@ -10,7 +10,7 @@ RSpec.describe "backup", type: :feature, docker: true do
10
10
  let(:folder) { "my-stuff" }
11
11
  let(:email1) { send_email folder, msg1 }
12
12
  let(:email2) { send_email folder, msg2 }
13
- let!(:pre) { }
13
+ let!(:pre) {}
14
14
  let!(:setup) do
15
15
  server_create_folder folder
16
16
  email1
@@ -29,7 +29,7 @@ RSpec.describe "backup", type: :feature, docker: true do
29
29
  expect(mbox_content(folder)).to eq(messages_as_mbox)
30
30
  end
31
31
 
32
- context "IMAP metadata" do
32
+ describe "IMAP metadata" do
33
33
  let(:imap_metadata) { imap_parsed(folder) }
34
34
  let(:folder_uids) { server_uids(folder) }
35
35
 
@@ -52,14 +52,15 @@ RSpec.describe "backup", type: :feature, docker: true do
52
52
  context "when uid_validity does not match" do
53
53
  let(:new_name) { "NEWNAME" }
54
54
  let(:email3) { send_email folder, msg3 }
55
+ let(:original_folder_uid_validity) { server_uid_validity(folder) }
55
56
  let!(:pre) do
56
57
  server_create_folder folder
57
58
  email3
58
- @original_folder_uid_validity = server_uid_validity(folder)
59
+ original_folder_uid_validity
59
60
  connection.run_backup
60
61
  server_rename_folder folder, new_name
61
62
  end
62
- let(:renamed_folder) { folder + "." + @original_folder_uid_validity.to_s }
63
+ let(:renamed_folder) { "#{folder}.#{original_folder_uid_validity}" }
63
64
 
64
65
  after do
65
66
  server_delete_folder new_name
@@ -81,7 +82,7 @@ RSpec.describe "backup", type: :feature, docker: true do
81
82
  end
82
83
 
83
84
  it "moves the old backup to a uniquely named directory" do
84
- renamed = folder + "." + @original_folder_uid_validity.to_s + ".1"
85
+ renamed = "#{folder}.#{original_folder_uid_validity}.1"
85
86
  expect(mbox_content(renamed)).to eq(message_as_mbox_entry(msg3))
86
87
  end
87
88
  end
@@ -1,2 +1,2 @@
1
1
  support_glob = File.expand_path("support/**/*.rb", __dir__)
2
- Dir[support_glob].each { |f| require f }
2
+ Dir[support_glob].sort.each { |f| require f }
@@ -72,41 +72,89 @@ RSpec.describe "restore", type: :feature, docker: true do
72
72
  end
73
73
 
74
74
  context "when the uid_validity doesn't match" do
75
- let(:pre) do
76
- server_create_folder folder
77
- email3
78
- end
79
- let(:new_folder) { "#{folder}.#{uid_validity}" }
80
- let(:cleanup) do
81
- server_delete_folder new_folder
82
- super()
83
- end
75
+ context "when the folder is empty" do
76
+ let(:pre) do
77
+ server_create_folder folder
78
+ end
84
79
 
85
- it "sets the backup uid_validity to match the new folder" do
86
- updated_imap_content = imap_parsed(new_folder)
87
- expect(updated_imap_content[:uid_validity]).
88
- to eq(server_uid_validity(new_folder))
89
- end
80
+ it "sets the backup uid_validity to match the folder" do
81
+ updated_imap_content = imap_parsed(folder)
82
+ expect(updated_imap_content[:uid_validity]).
83
+ to eq(server_uid_validity(folder))
84
+ end
90
85
 
91
- it "renames the backup" do
92
- expect(mbox_content(new_folder)).to eq(messages_as_mbox)
86
+ it "uploads to the new folder" do
87
+ messages = server_messages(folder).map do |m|
88
+ server_message_to_body(m)
89
+ end
90
+ expect(messages).to eq(messages_as_server_messages)
91
+ end
93
92
  end
94
93
 
95
- it "leaves the existing folder as is" do
96
- messages = server_messages(folder).map { |m| server_message_to_body(m) }
97
- expect(messages).to eq([message_as_server_message(msg3)])
98
- end
94
+ context "when the folder has content" do
95
+ let(:new_folder) { "#{folder}.#{uid_validity}" }
96
+ let(:cleanup) do
97
+ server_delete_folder new_folder
98
+ super()
99
+ end
99
100
 
100
- it "creates the new folder" do
101
- expect(server_folders.map(&:name)).to include(new_folder)
102
- end
101
+ let(:pre) do
102
+ server_create_folder folder
103
+ email3
104
+ end
103
105
 
104
- it "uploads to the new folder" do
105
- messages = server_messages(new_folder).map do |m|
106
- server_message_to_body(m)
106
+ it "renames the backup" do
107
+ expect(mbox_content(new_folder)).to eq(messages_as_mbox)
108
+ end
109
+
110
+ it "leaves the existing folder as is" do
111
+ messages = server_messages(folder).map do |m|
112
+ server_message_to_body(m)
113
+ end
114
+ expect(messages).to eq([message_as_server_message(msg3)])
115
+ end
116
+
117
+ it "creates the new folder" do
118
+ expect(server_folders.map(&:name)).to include(new_folder)
119
+ end
120
+
121
+ it "sets the backup uid_validity to match the new folder" do
122
+ updated_imap_content = imap_parsed(new_folder)
123
+ expect(updated_imap_content[:uid_validity]).
124
+ to eq(server_uid_validity(new_folder))
125
+ end
126
+
127
+ it "uploads to the new folder" do
128
+ messages = server_messages(new_folder).map do |m|
129
+ server_message_to_body(m)
130
+ end
131
+ expect(messages).to eq(messages_as_server_messages)
107
132
  end
108
- expect(messages).to eq(messages_as_server_messages)
109
133
  end
110
134
  end
111
135
  end
136
+
137
+ context "when non-Unicode encodings are used" do
138
+ let(:server_message) do
139
+ message_as_server_message(msg_iso8859)
140
+ end
141
+ let(:messages_as_mbox) do
142
+ message_as_mbox_entry(msg_iso8859)
143
+ end
144
+ let(:message_uids) { [uid_iso8859] }
145
+ let(:uid_validity) { server_uid_validity(folder) }
146
+
147
+ let(:pre) do
148
+ server_create_folder folder
149
+ uid_validity
150
+ end
151
+
152
+ it "maintains encodings" do
153
+ message =
154
+ server_messages(folder).
155
+ first["RFC822"]
156
+
157
+ expect(message).to eq(server_message)
158
+ end
159
+ end
112
160
  end
@@ -3,12 +3,12 @@ module BackupDirectoryHelpers
3
3
  from = fixture("connection")[:username]
4
4
  subject = options[:subject]
5
5
  body = options[:body]
6
- body_and_headers = <<-EOT
7
- From: #{from}
8
- Subject: #{subject}
6
+ body_and_headers = <<~BODY
7
+ From: #{from}
8
+ Subject: #{subject}
9
9
 
10
- #{body}
11
- EOT
10
+ #{body}
11
+ BODY
12
12
 
13
13
  "From #{from}\n#{body_and_headers}\n"
14
14
  end
@@ -26,11 +26,11 @@ Subject: #{subject}
26
26
  end
27
27
 
28
28
  def mbox_path(name)
29
- File.join(local_backup_path, name + ".mbox")
29
+ File.join(local_backup_path, "#{name}.mbox")
30
30
  end
31
31
 
32
32
  def imap_path(name)
33
- File.join(local_backup_path, name + ".imap")
33
+ File.join(local_backup_path, "#{name}.imap")
34
34
  end
35
35
 
36
36
  def imap_content(name)