imap-backup 2.1.1 → 3.0.0

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 (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