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