imap-backup 2.1.1 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -4
  3. data/.rubocop_todo.yml +29 -11
  4. data/.travis.yml +1 -1
  5. data/README.md +10 -13
  6. data/bin/imap-backup +5 -2
  7. data/docs/01-credentials-screen.png +0 -0
  8. data/docs/02-new-project.png +0 -0
  9. data/docs/03-initial-credentials-for-project.png +0 -0
  10. data/docs/04-credential-type-selection.png +0 -0
  11. data/docs/05-cant-create-without-consent-setup.png +0 -0
  12. data/docs/06-user-type-selection.png +0 -0
  13. data/docs/07-consent-screen-form.png +0 -0
  14. data/docs/08-app-scopes.png +0 -0
  15. data/docs/09-scope-selection.png +0 -0
  16. data/docs/10-updated-app-scopes.png +0 -0
  17. data/docs/11-test-users.png +0 -0
  18. data/docs/12-add-users.png +0 -0
  19. data/docs/13-create-oauth-client.png +0 -0
  20. data/docs/14-application-details.png +0 -0
  21. data/docs/16-initial-menu.png +0 -0
  22. data/docs/17-inputting-the-email-address.png +0 -0
  23. data/docs/18-choose-password.png +0 -0
  24. data/docs/19-supply-client-info.png +0 -0
  25. data/docs/20-choose-gmail-account.png +0 -0
  26. data/docs/21-accept-warnings.png +0 -0
  27. data/docs/22-grant-access.png +0 -0
  28. data/docs/24-confirm-choices.png +0 -0
  29. data/docs/25-success-code.png +0 -0
  30. data/docs/26-type-code-into-imap-backup.png +0 -0
  31. data/docs/27-success.png +0 -0
  32. data/docs/setting-up-gmail.md +166 -0
  33. data/imap-backup.gemspec +3 -9
  34. data/lib/email/mboxrd/message.rb +4 -3
  35. data/lib/email/provider.rb +3 -1
  36. data/lib/gmail/authenticator.rb +160 -0
  37. data/lib/google/auth/stores/in_memory_token_store.rb +9 -0
  38. data/lib/imap/backup.rb +2 -1
  39. data/lib/imap/backup/account/connection.rb +59 -34
  40. data/lib/imap/backup/account/folder.rb +10 -1
  41. data/lib/imap/backup/configuration/account.rb +9 -1
  42. data/lib/imap/backup/configuration/gmail_oauth2.rb +82 -0
  43. data/lib/imap/backup/configuration/setup.rb +4 -1
  44. data/lib/imap/backup/serializer/mbox.rb +4 -0
  45. data/lib/imap/backup/serializer/mbox_enumerator.rb +1 -1
  46. data/lib/imap/backup/serializer/mbox_store.rb +20 -4
  47. data/lib/imap/backup/uploader.rb +10 -2
  48. data/lib/imap/backup/version.rb +5 -4
  49. data/spec/features/backup_spec.rb +3 -3
  50. data/spec/features/helper.rb +1 -1
  51. data/spec/features/restore_spec.rb +75 -27
  52. data/spec/features/support/backup_directory.rb +2 -2
  53. data/spec/features/support/email_server.rb +1 -3
  54. data/spec/features/support/shared/message_fixtures.rb +8 -0
  55. data/spec/spec_helper.rb +1 -1
  56. data/spec/support/fixtures.rb +1 -1
  57. data/spec/unit/email/mboxrd/message_spec.rb +2 -8
  58. data/spec/unit/email/provider_spec.rb +2 -2
  59. data/spec/unit/gmail/authenticator_spec.rb +138 -0
  60. data/spec/unit/google/auth/stores/in_memory_token_store_spec.rb +15 -0
  61. data/spec/unit/imap/backup/account/connection_spec.rb +157 -79
  62. data/spec/unit/imap/backup/account/folder_spec.rb +30 -20
  63. data/spec/unit/imap/backup/configuration/account_spec.rb +65 -46
  64. data/spec/unit/imap/backup/configuration/asker_spec.rb +20 -17
  65. data/spec/unit/imap/backup/configuration/connection_tester_spec.rb +6 -10
  66. data/spec/unit/imap/backup/configuration/folder_chooser_spec.rb +16 -10
  67. data/spec/unit/imap/backup/configuration/gmail_oauth2_spec.rb +84 -0
  68. data/spec/unit/imap/backup/configuration/list_spec.rb +6 -3
  69. data/spec/unit/imap/backup/configuration/setup_spec.rb +89 -54
  70. data/spec/unit/imap/backup/configuration/store_spec.rb +18 -16
  71. data/spec/unit/imap/backup/downloader_spec.rb +14 -14
  72. data/spec/unit/imap/backup/serializer/mbox_enumerator_spec.rb +6 -1
  73. data/spec/unit/imap/backup/serializer/mbox_spec.rb +62 -40
  74. data/spec/unit/imap/backup/serializer/mbox_store_spec.rb +94 -35
  75. data/spec/unit/imap/backup/uploader_spec.rb +23 -7
  76. data/spec/unit/imap/backup/utils_spec.rb +10 -9
  77. metadata +68 -9
@@ -75,7 +75,10 @@ module Imap::Backup
75
75
  password: "",
76
76
  local_path: File.join(config.path, username.tr("@", "_")),
77
77
  folders: []
78
- }
78
+ }.tap do |c|
79
+ server = Email::Provider.for_address(username)
80
+ c[:server] = server.host if server.host
81
+ end
79
82
  end
80
83
 
81
84
  def edit_account(username)
@@ -35,6 +35,10 @@ module Imap::Backup
35
35
  store.load(uid)
36
36
  end
37
37
 
38
+ def each_message(uids)
39
+ store.each_message(uids)
40
+ end
41
+
38
42
  def save(uid, message)
39
43
  store.add(uid, message)
40
44
  end
@@ -9,7 +9,7 @@ module Imap::Backup
9
9
  def each
10
10
  return enum_for(:each) if !block_given?
11
11
 
12
- File.open(mbox_pathname) do |f|
12
+ File.open(mbox_pathname, "rb") do |f|
13
13
  lines = []
14
14
 
15
15
  loop do
@@ -81,6 +81,22 @@ module Imap::Backup
81
81
  load_nth(message_index)
82
82
  end
83
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
+
84
100
  def update_uid(old, new)
85
101
  index = uids.find_index(old.to_i)
86
102
  return if index.nil?
@@ -98,8 +114,8 @@ module Imap::Backup
98
114
  end
99
115
 
100
116
  def rename(new_name)
101
- new_mbox_pathname = absolute_path(new_name + ".mbox")
102
- 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")
103
119
  File.rename(mbox_pathname, new_mbox_pathname)
104
120
  File.rename(imap_pathname, new_imap_pathname)
105
121
  @folder = new_name
@@ -191,11 +207,11 @@ module Imap::Backup
191
207
  end
192
208
 
193
209
  def mbox_pathname
194
- absolute_path(folder + ".mbox")
210
+ absolute_path("#{folder}.mbox")
195
211
  end
196
212
 
197
213
  def imap_pathname
198
- absolute_path(folder + ".imap")
214
+ absolute_path("#{folder}.imap")
199
215
  end
200
216
  end
201
217
  end
@@ -9,10 +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?
15
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
+
16
24
  new_uid = folder.append(message)
17
25
  serializer.update_uid(uid, new_uid)
18
26
  end
@@ -1,8 +1,9 @@
1
1
  module Imap; end
2
2
 
3
3
  module Imap::Backup
4
- MAJOR = 2
5
- MINOR = 1
6
- REVISION = 1
7
- VERSION = [MAJOR, MINOR, REVISION].compact.map(&:to_s).join(".")
4
+ MAJOR = 3
5
+ MINOR = 0
6
+ REVISION = 0
7
+ PRE = nil
8
+ VERSION = [MAJOR, MINOR, REVISION, PRE].compact.map(&:to_s).join(".")
8
9
  end
@@ -38,7 +38,7 @@ RSpec.describe "backup", type: :feature, docker: true do
38
38
  end
39
39
 
40
40
  it "saves a file version" do
41
- expect(imap_metadata[:version].to_s).to match(/^[0-9\.]$/)
41
+ expect(imap_metadata[:version].to_s).to match(/^[0-9.]$/)
42
42
  end
43
43
 
44
44
  it "records IMAP ids" do
@@ -60,7 +60,7 @@ RSpec.describe "backup", type: :feature, docker: true do
60
60
  connection.run_backup
61
61
  server_rename_folder folder, new_name
62
62
  end
63
- let(:renamed_folder) { folder + "." + original_folder_uid_validity.to_s }
63
+ let(:renamed_folder) { "#{folder}.#{original_folder_uid_validity}" }
64
64
 
65
65
  after do
66
66
  server_delete_folder new_name
@@ -82,7 +82,7 @@ RSpec.describe "backup", type: :feature, docker: true do
82
82
  end
83
83
 
84
84
  it "moves the old backup to a uniquely named directory" do
85
- renamed = folder + "." + original_folder_uid_validity.to_s + ".1"
85
+ renamed = "#{folder}.#{original_folder_uid_validity}.1"
86
86
  expect(mbox_content(renamed)).to eq(message_as_mbox_entry(msg3))
87
87
  end
88
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
@@ -26,11 +26,11 @@ module BackupDirectoryHelpers
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)
@@ -38,9 +38,7 @@ module EmailServerHelpers
38
38
  return nil if fetch_data_items.nil?
39
39
 
40
40
  fetch_data_item = fetch_data_items[0]
41
- attributes = fetch_data_item.attr
42
- attributes["RFC822"].force_encoding("utf-8")
43
- attributes
41
+ fetch_data_item.attr
44
42
  end
45
43
 
46
44
  def delete_emails(folder)
@@ -2,7 +2,15 @@ shared_context "message-fixtures" do
2
2
  let(:uid1) { 123 }
3
3
  let(:uid2) { 345 }
4
4
  let(:uid3) { 567 }
5
+ let(:uid_iso8859) { 890 }
5
6
  let(:msg1) { {uid: uid1, subject: "Test 1", body: "body 1\nHi"} }
6
7
  let(:msg2) { {uid: uid2, subject: "Test 2", body: "body 2"} }
7
8
  let(:msg3) { {uid: uid3, subject: "Test 3", body: "body 3"} }
9
+ let(:msg_iso8859) do
10
+ {
11
+ uid: uid_iso8859,
12
+ subject: "iso8859 Body",
13
+ body: "Ma, perchè?".encode(Encoding::ISO_8859_1).force_encoding("binary")
14
+ }
15
+ end
8
16
  end
data/spec/spec_helper.rb CHANGED
@@ -7,7 +7,7 @@ spec_path = File.dirname(__FILE__)
7
7
  $LOAD_PATH << File.expand_path("../lib", spec_path)
8
8
 
9
9
  support_glob = File.join(spec_path, "support", "**", "*.rb")
10
- Dir[support_glob].each { |f| require f }
10
+ Dir[support_glob].sort.each { |f| require f }
11
11
 
12
12
  require "simplecov"
13
13
  SimpleCov.start do
@@ -1,6 +1,6 @@
1
1
  def fixture(name)
2
2
  spec_root = File.expand_path("..", File.dirname(__FILE__))
3
- fixture_path = File.join(spec_root, "fixtures", name + ".yml")
3
+ fixture_path = File.join(spec_root, "fixtures", "#{name}.yml")
4
4
  fixture = File.read(fixture_path)
5
5
  YAML.safe_load(fixture, [Symbol])
6
6
  end
@@ -79,12 +79,6 @@ describe Email::Mboxrd::Message do
79
79
  allow(Mail).to receive(:new).with(cloned_message_body) { mail }
80
80
  end
81
81
 
82
- it "does not modify the message" do
83
- subject.to_serialized
84
-
85
- expect(message_body).to_not have_received(:force_encoding).with("binary")
86
- end
87
-
88
82
  it "adds a 'From ' line at the start" do
89
83
  expected = "From #{from} #{date.asctime}\n"
90
84
  expect(subject.to_serialized).to start_with(expected)
@@ -135,7 +129,7 @@ describe Email::Mboxrd::Message do
135
129
  let(:message_body) { msg_no_from_but_return_path }
136
130
 
137
131
  it "'return path' is used as 'from'" do
138
- expect(subject.to_serialized).to start_with("From " + from + "\n")
132
+ expect(subject.to_serialized).to start_with("From #{from}\n")
139
133
  end
140
134
  end
141
135
 
@@ -143,7 +137,7 @@ describe Email::Mboxrd::Message do
143
137
  let(:message_body) { msg_no_from_but_sender }
144
138
 
145
139
  it "Sender is used as 'from'" do
146
- expect(subject.to_serialized).to start_with("From " + from + "\n")
140
+ expect(subject.to_serialized).to start_with("From #{from}\n")
147
141
  end
148
142
  end
149
143
  end
@@ -1,4 +1,6 @@
1
1
  describe Email::Provider do
2
+ subject { described_class.new(:gmail) }
3
+
2
4
  describe ".for_address" do
3
5
  context "with known providers" do
4
6
  [
@@ -20,8 +22,6 @@ describe Email::Provider do
20
22
  end
21
23
  end
22
24
 
23
- subject { described_class.new(:gmail) }
24
-
25
25
  describe "#options" do
26
26
  it "returns options" do
27
27
  expect(subject.options).to be_a(Hash)
@@ -0,0 +1,138 @@
1
+ require "gmail/authenticator"
2
+ require "googleauth"
3
+
4
+ describe Gmail::Authenticator do
5
+ ACCESS_TOKEN = "access_token".freeze
6
+ AUTHORIZATION_URL = "authorization_url".freeze
7
+ CLIENT_ID = "client_id".freeze
8
+ CLIENT_SECRET = "client_secret".freeze
9
+ CODE = "code".freeze
10
+ CREDENTIALS = "credentials".freeze
11
+ EMAIL = "email".freeze
12
+ EXPIRATION_TIME_MILLIS = "expiration_time_millis".freeze
13
+ GMAIL_READ_SCOPE = "https://mail.google.com/".freeze
14
+ IMAP_BACKUP_TOKEN = "imap_backup_token".freeze
15
+ OOB_URI = "urn:ietf:wg:oauth:2.0:oob".freeze
16
+ REFRESH_TOKEN = "refresh_token".freeze
17
+
18
+ subject { described_class.new(**params) }
19
+
20
+ let(:params) do
21
+ {
22
+ email: EMAIL,
23
+ token: IMAP_BACKUP_TOKEN
24
+ }
25
+ end
26
+
27
+ let(:authorizer) do
28
+ instance_double(Google::Auth::UserAuthorizer)
29
+ end
30
+
31
+ let(:imap_backup_token) do
32
+ instance_double(
33
+ Gmail::Authenticator::ImapBackupToken,
34
+ access_token: ACCESS_TOKEN,
35
+ client_id: CLIENT_ID,
36
+ client_secret: CLIENT_SECRET,
37
+ expiration_time_millis: EXPIRATION_TIME_MILLIS,
38
+ refresh_token: REFRESH_TOKEN,
39
+ valid?: true
40
+ )
41
+ end
42
+
43
+ let(:token_store) do
44
+ instance_double(Google::Auth::Stores::InMemoryTokenStore)
45
+ end
46
+
47
+ let(:credentials) do
48
+ instance_double(Google::Auth::UserRefreshCredentials, refresh!: true)
49
+ end
50
+
51
+ let(:expired) { false }
52
+
53
+ before do
54
+ allow(Google::Auth::UserAuthorizer).
55
+ to receive(:new).
56
+ with(
57
+ instance_of(Google::Auth::ClientId),
58
+ GMAIL_READ_SCOPE,
59
+ token_store
60
+ ) { authorizer }
61
+ allow(authorizer).to receive(:get_authorization_url).
62
+ with(base_url: OOB_URI) { AUTHORIZATION_URL }
63
+ allow(authorizer).to receive(:get_credentials).
64
+ with(EMAIL) { credentials }
65
+ allow(authorizer).to receive(:get_credentials_from_code).
66
+ with(user_id: EMAIL, code: CODE, base_url: OOB_URI) { CREDENTIALS }
67
+
68
+ allow(Google::Auth::UserRefreshCredentials).
69
+ to receive(:new) { credentials }
70
+ allow(credentials).to receive(:expired?) { expired }
71
+
72
+ allow(Google::Auth::Stores::InMemoryTokenStore).
73
+ to receive(:new) { token_store }
74
+ allow(token_store).to receive(:store).
75
+ with(EMAIL, anything) # TODO: use a JSON matcher
76
+ allow(Gmail::Authenticator::ImapBackupToken).
77
+ to receive(:new).
78
+ with(IMAP_BACKUP_TOKEN) { imap_backup_token }
79
+ end
80
+
81
+ describe "#initialize" do
82
+ [:email, :token].each do |param|
83
+ context "parameter #{param}" do
84
+ let(:params) { super().dup.reject { |k| k == param } }
85
+
86
+ it "is expected" do
87
+ expect { subject }.to raise_error(
88
+ ArgumentError, /missing keyword: :#{param}/
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ describe "#credentials" do
96
+ let!(:result) { subject.credentials }
97
+
98
+ it "attempts to get credentials" do
99
+ expect(authorizer).to have_received(:get_credentials)
100
+ end
101
+
102
+ it "returns the result" do
103
+ expect(result).to eq(credentials)
104
+ end
105
+
106
+ context "when the access_token has expired" do
107
+ let(:expired) { true }
108
+
109
+ it "refreshes it" do
110
+ expect(credentials).to have_received(:refresh!)
111
+ end
112
+ end
113
+ end
114
+
115
+ describe "#authorization_url" do
116
+ let!(:result) { subject.authorization_url }
117
+
118
+ it "requests an authorization URL" do
119
+ expect(authorizer).to have_received(:get_authorization_url)
120
+ end
121
+
122
+ it "returns the result" do
123
+ expect(result).to eq(AUTHORIZATION_URL)
124
+ end
125
+ end
126
+
127
+ describe "#credentials_from_code" do
128
+ let!(:result) { subject.credentials_from_code(CODE) }
129
+
130
+ it "requests credentials" do
131
+ expect(authorizer).to have_received(:get_credentials_from_code)
132
+ end
133
+
134
+ it "returns credentials" do
135
+ expect(result).to eq(CREDENTIALS)
136
+ end
137
+ end
138
+ end