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.
- checksums.yaml +4 -4
- data/.rubocop.yml +5 -4
- data/.rubocop_todo.yml +29 -11
- data/.travis.yml +1 -1
- data/README.md +10 -13
- data/bin/imap-backup +5 -2
- data/docs/01-credentials-screen.png +0 -0
- data/docs/02-new-project.png +0 -0
- data/docs/03-initial-credentials-for-project.png +0 -0
- data/docs/04-credential-type-selection.png +0 -0
- data/docs/05-cant-create-without-consent-setup.png +0 -0
- data/docs/06-user-type-selection.png +0 -0
- data/docs/07-consent-screen-form.png +0 -0
- data/docs/08-app-scopes.png +0 -0
- data/docs/09-scope-selection.png +0 -0
- data/docs/10-updated-app-scopes.png +0 -0
- data/docs/11-test-users.png +0 -0
- data/docs/12-add-users.png +0 -0
- data/docs/13-create-oauth-client.png +0 -0
- data/docs/14-application-details.png +0 -0
- data/docs/16-initial-menu.png +0 -0
- data/docs/17-inputting-the-email-address.png +0 -0
- data/docs/18-choose-password.png +0 -0
- data/docs/19-supply-client-info.png +0 -0
- data/docs/20-choose-gmail-account.png +0 -0
- data/docs/21-accept-warnings.png +0 -0
- data/docs/22-grant-access.png +0 -0
- data/docs/24-confirm-choices.png +0 -0
- data/docs/25-success-code.png +0 -0
- data/docs/26-type-code-into-imap-backup.png +0 -0
- data/docs/27-success.png +0 -0
- data/docs/setting-up-gmail.md +166 -0
- data/imap-backup.gemspec +3 -9
- data/lib/email/mboxrd/message.rb +4 -3
- data/lib/email/provider.rb +3 -1
- data/lib/gmail/authenticator.rb +160 -0
- data/lib/google/auth/stores/in_memory_token_store.rb +9 -0
- data/lib/imap/backup.rb +2 -1
- data/lib/imap/backup/account/connection.rb +59 -34
- data/lib/imap/backup/account/folder.rb +10 -1
- data/lib/imap/backup/configuration/account.rb +9 -1
- data/lib/imap/backup/configuration/gmail_oauth2.rb +82 -0
- data/lib/imap/backup/configuration/setup.rb +4 -1
- data/lib/imap/backup/serializer/mbox.rb +4 -0
- data/lib/imap/backup/serializer/mbox_enumerator.rb +1 -1
- data/lib/imap/backup/serializer/mbox_store.rb +20 -4
- data/lib/imap/backup/uploader.rb +10 -2
- data/lib/imap/backup/version.rb +5 -4
- data/spec/features/backup_spec.rb +3 -3
- data/spec/features/helper.rb +1 -1
- data/spec/features/restore_spec.rb +75 -27
- data/spec/features/support/backup_directory.rb +2 -2
- data/spec/features/support/email_server.rb +1 -3
- data/spec/features/support/shared/message_fixtures.rb +8 -0
- data/spec/spec_helper.rb +1 -1
- data/spec/support/fixtures.rb +1 -1
- data/spec/unit/email/mboxrd/message_spec.rb +2 -8
- data/spec/unit/email/provider_spec.rb +2 -2
- data/spec/unit/gmail/authenticator_spec.rb +138 -0
- data/spec/unit/google/auth/stores/in_memory_token_store_spec.rb +15 -0
- data/spec/unit/imap/backup/account/connection_spec.rb +157 -79
- data/spec/unit/imap/backup/account/folder_spec.rb +30 -20
- data/spec/unit/imap/backup/configuration/account_spec.rb +65 -46
- data/spec/unit/imap/backup/configuration/asker_spec.rb +20 -17
- data/spec/unit/imap/backup/configuration/connection_tester_spec.rb +6 -10
- data/spec/unit/imap/backup/configuration/folder_chooser_spec.rb +16 -10
- data/spec/unit/imap/backup/configuration/gmail_oauth2_spec.rb +84 -0
- data/spec/unit/imap/backup/configuration/list_spec.rb +6 -3
- data/spec/unit/imap/backup/configuration/setup_spec.rb +89 -54
- data/spec/unit/imap/backup/configuration/store_spec.rb +18 -16
- data/spec/unit/imap/backup/downloader_spec.rb +14 -14
- data/spec/unit/imap/backup/serializer/mbox_enumerator_spec.rb +6 -1
- data/spec/unit/imap/backup/serializer/mbox_spec.rb +62 -40
- data/spec/unit/imap/backup/serializer/mbox_store_spec.rb +94 -35
- data/spec/unit/imap/backup/uploader_spec.rb +23 -7
- data/spec/unit/imap/backup/utils_spec.rb +10 -9
- metadata +68 -9
data/lib/email/provider.rb
CHANGED
@@ -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
|
-
|
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
|
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(
|
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
|
-
|
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
|
-
|
102
|
-
|
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
|
130
|
-
|
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 =
|
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
|