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
@@ -1,6 +1,8 @@
1
1
  module Email; end
2
2
 
3
3
  class Email::Provider
4
+ GMAIL_IMAP_SERVER = "imap.gmail.com".freeze
5
+
4
6
  def self.for_address(address)
5
7
  case
6
8
  when address.end_with?("@fastmail.com")
@@ -27,7 +29,7 @@ class Email::Provider
27
29
  def host
28
30
  case provider
29
31
  when :gmail
30
- "imap.gmail.com"
32
+ GMAIL_IMAP_SERVER
31
33
  when :fastmail
32
34
  "imap.fastmail.com"
33
35
  end
@@ -0,0 +1,160 @@
1
+ require "googleauth"
2
+ require "google/auth/stores/in_memory_token_store"
3
+
4
+ module Gmail; end
5
+
6
+ class Gmail::Authenticator
7
+ class MalformedImapBackupToken < StandardError; end
8
+
9
+ class ImapBackupToken
10
+ attr_reader :token
11
+
12
+ def self.from(
13
+ access_token:,
14
+ client_id:,
15
+ client_secret:,
16
+ expiration_time_millis:,
17
+ refresh_token:
18
+ )
19
+ {
20
+ access_token: access_token,
21
+ client_id: client_id,
22
+ client_secret: client_secret,
23
+ expiration_time_millis: expiration_time_millis,
24
+ refresh_token: refresh_token
25
+ }.to_json
26
+ end
27
+
28
+ def initialize(token)
29
+ @token = token
30
+ end
31
+
32
+ def valid?
33
+ return false if !body
34
+ return false if !access_token
35
+ return false if !client_id
36
+ return false if !client_secret
37
+ return false if !expiration_time_millis
38
+ return false if !refresh_token
39
+
40
+ true
41
+ end
42
+
43
+ def access_token
44
+ body["access_token"]
45
+ end
46
+
47
+ def client_id
48
+ body["client_id"]
49
+ end
50
+
51
+ def client_secret
52
+ body["client_secret"]
53
+ end
54
+
55
+ def expiration_time_millis
56
+ body["expiration_time_millis"]
57
+ end
58
+
59
+ def refresh_token
60
+ body["refresh_token"]
61
+ end
62
+
63
+ private
64
+
65
+ def body
66
+ @body ||= JSON.parse(token)
67
+ rescue JSON::ParserError
68
+ nil
69
+ end
70
+ end
71
+
72
+ GMAIL_READ_SCOPE = "https://mail.google.com/".freeze
73
+ OOB_URI = "urn:ietf:wg:oauth:2.0:oob".freeze
74
+
75
+ attr_reader :email
76
+ attr_reader :token
77
+
78
+ def self.refresh_token?(text)
79
+ ImapBackupToken.new(text).valid?
80
+ end
81
+
82
+ def initialize(email:, token:)
83
+ @email = email
84
+ @token = token
85
+ end
86
+
87
+ def authorization_url
88
+ authorizer.get_authorization_url(base_url: OOB_URI)
89
+ end
90
+
91
+ def credentials
92
+ authorizer.get_credentials(email).tap do |c|
93
+ c.refresh! if c.expired?
94
+ end
95
+ end
96
+
97
+ def credentials_from_code(code)
98
+ authorizer.get_credentials_from_code(
99
+ user_id: email,
100
+ code: code,
101
+ base_url: OOB_URI
102
+ )
103
+ end
104
+
105
+ private
106
+
107
+ def auth_client_id
108
+ @auth_client_id = Google::Auth::ClientId.new(client_id, client_secret)
109
+ end
110
+
111
+ def authorizer
112
+ @authorizer ||= Google::Auth::UserAuthorizer.new(
113
+ auth_client_id, GMAIL_READ_SCOPE, token_store
114
+ )
115
+ end
116
+
117
+ def access_token
118
+ imap_backup_token.access_token
119
+ end
120
+
121
+ def client_id
122
+ imap_backup_token.client_id
123
+ end
124
+
125
+ def client_secret
126
+ imap_backup_token.client_secret
127
+ end
128
+
129
+ def expiration_time_millis
130
+ imap_backup_token.expiration_time_millis
131
+ end
132
+
133
+ def refresh_token
134
+ imap_backup_token.refresh_token
135
+ end
136
+
137
+ def imap_backup_token
138
+ @imap_backup_token ||=
139
+ ImapBackupToken.new(token).tap do |t|
140
+ raise MalformedImapBackupToken if !t.valid?
141
+ end
142
+ end
143
+
144
+ def store_token
145
+ {
146
+ "client_id" => client_id,
147
+ "access_token" => access_token,
148
+ "refresh_token" => refresh_token,
149
+ "scope": [GMAIL_READ_SCOPE],
150
+ "expiration_time_millis": expiration_time_millis
151
+ }.to_json
152
+ end
153
+
154
+ def token_store
155
+ @token_store ||=
156
+ Google::Auth::Stores::InMemoryTokenStore.new.tap do |t|
157
+ t.store(email, store_token)
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,9 @@
1
+ module Google; end
2
+ module Google::Auth; end
3
+ module Google::Auth::Stores; end
4
+
5
+ class Google::Auth::Stores::InMemoryTokenStore < Hash
6
+ def load(id)
7
+ self[id]
8
+ end
9
+ end
data/lib/imap/backup.rb CHANGED
@@ -7,6 +7,7 @@ require "imap/backup/configuration/account"
7
7
  require "imap/backup/configuration/asker"
8
8
  require "imap/backup/configuration/connection_tester"
9
9
  require "imap/backup/configuration/folder_chooser"
10
+ require "imap/backup/configuration/gmail_oauth2"
10
11
  require "imap/backup/configuration/list"
11
12
  require "imap/backup/configuration/setup"
12
13
  require "imap/backup/configuration/store"
@@ -28,7 +29,7 @@ module Imap::Backup
28
29
  attr_reader :logger
29
30
 
30
31
  def initialize
31
- @logger = ::Logger.new(STDOUT)
32
+ @logger = ::Logger.new($stdout)
32
33
  end
33
34
  end
34
35
 
@@ -1,9 +1,14 @@
1
1
  require "net/imap"
2
+ require "gmail_xoauth"
3
+
4
+ require "gmail/authenticator"
2
5
 
3
6
  module Imap::Backup
4
7
  module Account; end
5
8
 
6
9
  class Account::Connection
10
+ class InvalidGmailOauth2RefreshToken < StandardError; end
11
+
7
12
  attr_reader :connection_options
8
13
  attr_reader :local_path
9
14
  attr_reader :password
@@ -57,27 +62,7 @@ module Imap::Backup
57
62
 
58
63
  def restore
59
64
  local_folders do |serializer, folder|
60
- exists = folder.exist?
61
- if exists
62
- new_name = serializer.apply_uid_validity(folder.uid_validity)
63
- old_name = serializer.folder
64
- if new_name
65
- Imap::Backup.logger.debug(
66
- "Backup '#{old_name}' renamed and restored to '#{new_name}'"
67
- )
68
- new_serializer = Serializer::Mbox.new(local_path, new_name)
69
- new_folder = Account::Folder.new(self, new_name)
70
- new_folder.create
71
- new_serializer.force_uid_validity(new_folder.uid_validity)
72
- Uploader.new(new_folder, new_serializer).run
73
- else
74
- Uploader.new(folder, serializer).run
75
- end
76
- else
77
- folder.create
78
- serializer.force_uid_validity(folder.uid_validity)
79
- Uploader.new(folder, serializer).run
80
- end
65
+ restore_folder serializer, folder
81
66
  end
82
67
  end
83
68
 
@@ -98,12 +83,34 @@ module Imap::Backup
98
83
  "Creating IMAP instance: #{server}, options: #{options.inspect}"
99
84
  )
100
85
  @imap = Net::IMAP.new(server, options)
101
- Imap::Backup.logger.debug "Logging in: #{username}/#{masked_password}"
102
- @imap.login(username, password)
86
+ if gmail? && Gmail::Authenticator.refresh_token?(password)
87
+ authenticator = Gmail::Authenticator.new(email: username, token: password)
88
+ credentials = authenticator.credentials
89
+ raise InvalidGmailOauth2RefreshToken if !credentials
90
+
91
+ Imap::Backup.logger.debug "Logging in with OAuth2 token: #{username}"
92
+ @imap.authenticate("XOAUTH2", username, credentials.access_token)
93
+ else
94
+ Imap::Backup.logger.debug "Logging in: #{username}/#{masked_password}"
95
+ @imap.login(username, password)
96
+ end
103
97
  Imap::Backup.logger.debug "Login complete"
104
98
  @imap
105
99
  end
106
100
 
101
+ def server
102
+ return @server if @server
103
+ return nil if provider.nil?
104
+
105
+ @server = provider.host
106
+ end
107
+
108
+ def backup_folders
109
+ return @backup_folders if @backup_folders && !@backup_folders.empty?
110
+
111
+ (folders || []).map { |f| {name: f.name} }
112
+ end
113
+
107
114
  private
108
115
 
109
116
  def each_folder
@@ -114,6 +121,33 @@ module Imap::Backup
114
121
  end
115
122
  end
116
123
 
124
+ def restore_folder(serializer, folder)
125
+ existing_uids = folder.uids
126
+ if existing_uids.any?
127
+ Imap::Backup.logger.debug(
128
+ "There's already a '#{folder.name}' folder with emails"
129
+ )
130
+ new_name = serializer.apply_uid_validity(folder.uid_validity)
131
+ old_name = serializer.folder
132
+ if new_name
133
+ Imap::Backup.logger.debug(
134
+ "Backup '#{old_name}' renamed and restored to '#{new_name}'"
135
+ )
136
+ new_serializer = Serializer::Mbox.new(local_path, new_name)
137
+ new_folder = Account::Folder.new(self, new_name)
138
+ new_folder.create
139
+ new_serializer.force_uid_validity(new_folder.uid_validity)
140
+ Uploader.new(new_folder, new_serializer).run
141
+ else
142
+ Uploader.new(folder, serializer).run
143
+ end
144
+ else
145
+ folder.create
146
+ serializer.force_uid_validity(folder.uid_validity)
147
+ Uploader.new(folder, serializer).run
148
+ end
149
+ end
150
+
117
151
  def create_account_folder
118
152
  Utils.make_folder(
119
153
  File.dirname(local_path),
@@ -126,10 +160,8 @@ module Imap::Backup
126
160
  password.gsub(/./, "x")
127
161
  end
128
162
 
129
- def backup_folders
130
- return @backup_folders if @backup_folders && !@backup_folders.empty?
131
-
132
- (folders || []).map { |f| {name: f.name} }
163
+ def gmail?
164
+ server == Email::Provider::GMAIL_IMAP_SERVER
133
165
  end
134
166
 
135
167
  def local_folders
@@ -147,13 +179,6 @@ module Imap::Backup
147
179
  @provider ||= Email::Provider.for_address(username)
148
180
  end
149
181
 
150
- def server
151
- return @server if @server
152
- return nil if provider.nil?
153
-
154
- @server = provider.host
155
- end
156
-
157
182
  def provider_options
158
183
  provider.options.merge(connection_options)
159
184
  end
@@ -52,6 +52,16 @@ module Imap::Backup
52
52
  imap.uid_search(["ALL"]).sort
53
53
  rescue FolderNotFound
54
54
  []
55
+ rescue NoMethodError
56
+ message = <<~MESSAGE
57
+ Folder '#{name}' caused NoMethodError
58
+ probably
59
+ `undefined method `[]' for nil:NilClass (NoMethodError)`
60
+ in `search_internal` in stdlib net/imap.rb.
61
+ This is caused by `@responses["SEARCH"] being unset/undefined
62
+ MESSAGE
63
+ Imap::Backup.logger.warn message
64
+ []
55
65
  end
56
66
 
57
67
  def fetch(uid)
@@ -63,7 +73,6 @@ module Imap::Backup
63
73
  attributes = fetch_data_item.attr
64
74
  return nil if !attributes.key?("RFC822")
65
75
 
66
- attributes["RFC822"].force_encoding("utf-8")
67
76
  attributes
68
77
  rescue FolderNotFound
69
78
  nil
@@ -56,9 +56,11 @@ module Imap::Backup
56
56
  )
57
57
  else
58
58
  account[:username] = username
59
+ # rubocop:disable Style/IfUnlessModifier
59
60
  if account[:server].nil? || (account[:server] == "")
60
61
  account[:server] = default_server(username)
61
62
  end
63
+ # rubocop:enable Style/IfUnlessModifier
62
64
  account[:modified] = true
63
65
  end
64
66
  end
@@ -66,7 +68,13 @@ module Imap::Backup
66
68
 
67
69
  def modify_password(menu)
68
70
  menu.choice("modify password") do
69
- password = Configuration::Asker.password
71
+ password =
72
+ if account[:server] == Email::Provider::GMAIL_IMAP_SERVER
73
+ Configuration::GmailOauth2.new(account).run
74
+ else
75
+ Configuration::Asker.password
76
+ end
77
+
70
78
  if !password.nil?
71
79
  account[:password] = password
72
80
  account[:modified] = true
@@ -0,0 +1,82 @@
1
+ module Imap::Backup
2
+ module Configuration; end
3
+
4
+ class Configuration::GmailOauth2
5
+ BANNER = <<~BANNER.freeze
6
+ GMail OAuth2 Setup
7
+
8
+ You need to authorize imap_backup to get access to your email.
9
+ To do so, please follow the instructions here:
10
+
11
+ https://github.com/joeyates/imap-backup/docs/setting-up-gmail.md
12
+
13
+ BANNER
14
+
15
+ GMAIL_READ_SCOPE = "https://mail.google.com/".freeze
16
+ OOB_URI = "urn:ietf:wg:oauth:2.0:oob".freeze
17
+
18
+ attr_reader :account
19
+ attr_reader :client_id
20
+ attr_reader :client_secret
21
+
22
+ def initialize(account)
23
+ @account = account
24
+ end
25
+
26
+ def run
27
+ Kernel.system("clear")
28
+ Kernel.puts BANNER
29
+ @client_id = highline.ask("client_id: ")
30
+ @client_secret = highline.ask("client_secret: ")
31
+
32
+ Kernel.puts <<~MESSAGE
33
+
34
+ Open the following URL in your browser
35
+
36
+ #{authorization_url}
37
+
38
+ Then copy the success code
39
+
40
+ MESSAGE
41
+
42
+ @code = highline.ask("success code: ")
43
+ @credentials = authorizer.get_and_store_credentials_from_code(
44
+ user_id: email, code: @code, base_url: OOB_URI
45
+ )
46
+
47
+ raise "Failed" if !@credentials
48
+
49
+ token = JSON.parse(token_store.load(email))
50
+ token["client_secret"] = client_secret
51
+ token.to_json
52
+ end
53
+
54
+ private
55
+
56
+ def email
57
+ account[:username]
58
+ end
59
+
60
+ def highline
61
+ Configuration::Setup.highline
62
+ end
63
+
64
+ def auth_client_id
65
+ @auth_client_id = Google::Auth::ClientId.new(client_id, client_secret)
66
+ end
67
+
68
+ def authorizer
69
+ @authorizer ||= Google::Auth::UserAuthorizer.new(
70
+ auth_client_id, GMAIL_READ_SCOPE, token_store
71
+ )
72
+ end
73
+
74
+ def token_store
75
+ @token_store ||= Google::Auth::Stores::InMemoryTokenStore.new
76
+ end
77
+
78
+ def authorization_url
79
+ authorizer.get_authorization_url(base_url: OOB_URI)
80
+ end
81
+ end
82
+ end