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