imap-backup 2.2.2 → 3.2.1
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/.circleci/config.yml +51 -0
- data/.rubocop.yml +2 -2
- data/.rubocop_todo.yml +10 -26
- data/README.md +6 -13
- data/bin/imap-backup +4 -1
- 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 -8
- data/lib/email/mboxrd/message.rb +2 -0
- 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 +67 -35
- data/lib/imap/backup/account/folder.rb +18 -6
- data/lib/imap/backup/configuration/account.rb +9 -1
- data/lib/imap/backup/configuration/folder_chooser.rb +14 -15
- data/lib/imap/backup/configuration/gmail_oauth2.rb +82 -0
- data/lib/imap/backup/configuration/setup.rb +4 -1
- data/lib/imap/backup/configuration/store.rb +12 -12
- data/lib/imap/backup/serializer/mbox_store.rb +2 -2
- data/lib/imap/backup/version.rb +4 -3
- data/lib/retry_on_error.rb +14 -0
- data/spec/features/backup_spec.rb +1 -1
- data/spec/fixtures/connection.yml +1 -1
- data/spec/support/fixtures.rb +7 -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 +146 -53
- data/spec/unit/imap/backup/account/folder_spec.rb +42 -10
- data/spec/unit/imap/backup/configuration/account_spec.rb +37 -24
- data/spec/unit/imap/backup/configuration/folder_chooser_spec.rb +7 -13
- data/spec/unit/imap/backup/configuration/gmail_oauth2_spec.rb +84 -0
- data/spec/unit/imap/backup/configuration/setup_spec.rb +51 -31
- metadata +83 -9
- data/.travis.yml +0 -25
@@ -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
|
@@ -15,7 +15,7 @@ module Imap::Backup
|
|
15
15
|
return
|
16
16
|
end
|
17
17
|
|
18
|
-
if
|
18
|
+
if imap_folders.nil?
|
19
19
|
Imap::Backup.logger.warn "Unable to get folder list"
|
20
20
|
highline.ask "Press a key "
|
21
21
|
return
|
@@ -44,29 +44,28 @@ module Imap::Backup
|
|
44
44
|
end
|
45
45
|
|
46
46
|
def add_folders(menu)
|
47
|
-
|
48
|
-
|
49
|
-
mark
|
50
|
-
|
51
|
-
toggle_selection name
|
47
|
+
imap_folders.each do |folder|
|
48
|
+
mark = selected?(folder) ? "+" : "-"
|
49
|
+
menu.choice("#{mark} #{folder}") do
|
50
|
+
toggle_selection folder
|
52
51
|
end
|
53
52
|
end
|
54
53
|
end
|
55
54
|
|
56
55
|
def selected?(folder_name)
|
57
|
-
|
58
|
-
return false if
|
56
|
+
config_folders = account[:folders]
|
57
|
+
return false if config_folders.nil?
|
59
58
|
|
60
|
-
|
59
|
+
config_folders.find { |f| f[:name] == folder_name }
|
61
60
|
end
|
62
61
|
|
63
62
|
def remove_missing
|
64
63
|
removed = []
|
65
|
-
|
64
|
+
config_folders = []
|
66
65
|
account[:folders].each do |f|
|
67
|
-
found =
|
66
|
+
found = imap_folders.find { |folder| folder == f[:name] }
|
68
67
|
if found
|
69
|
-
|
68
|
+
config_folders << f
|
70
69
|
else
|
71
70
|
removed << f[:name]
|
72
71
|
end
|
@@ -74,7 +73,7 @@ module Imap::Backup
|
|
74
73
|
|
75
74
|
return if removed.empty?
|
76
75
|
|
77
|
-
account[:folders] =
|
76
|
+
account[:folders] = config_folders
|
78
77
|
account[:modified] = true
|
79
78
|
|
80
79
|
Kernel.puts <<~MESSAGE
|
@@ -101,8 +100,8 @@ module Imap::Backup
|
|
101
100
|
nil
|
102
101
|
end
|
103
102
|
|
104
|
-
def
|
105
|
-
@
|
103
|
+
def imap_folders
|
104
|
+
@imap_folders ||= connection.folders
|
106
105
|
end
|
107
106
|
|
108
107
|
def highline
|
@@ -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/blob/main/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
|
@@ -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)
|
@@ -51,18 +51,18 @@ module Imap::Backup
|
|
51
51
|
private
|
52
52
|
|
53
53
|
def data
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
54
|
+
@data ||=
|
55
|
+
begin
|
56
|
+
if File.exist?(pathname)
|
57
|
+
Utils.check_permissions pathname, 0o600
|
58
|
+
contents = File.read(pathname)
|
59
|
+
data = JSON.parse(contents, symbolize_names: true)
|
60
|
+
else
|
61
|
+
data = {accounts: []}
|
62
|
+
end
|
63
|
+
data[:debug] = data.key?(:debug) ? data[:debug] == true : false
|
64
|
+
data
|
65
|
+
end
|
66
66
|
end
|
67
67
|
|
68
68
|
def remove_modified_flags
|
@@ -161,7 +161,7 @@ module Imap::Backup
|
|
161
161
|
def load_nth(index)
|
162
162
|
enumerator = Serializer::MboxEnumerator.new(mbox_pathname)
|
163
163
|
enumerator.each.with_index do |raw, i|
|
164
|
-
next
|
164
|
+
next if i != index
|
165
165
|
|
166
166
|
return Email::Mboxrd::Message.from_serialized(raw)
|
167
167
|
end
|
@@ -169,7 +169,7 @@ module Imap::Backup
|
|
169
169
|
end
|
170
170
|
|
171
171
|
def imap_looks_like_json?
|
172
|
-
return false
|
172
|
+
return false if !imap_exist?
|
173
173
|
|
174
174
|
content = File.read(imap_pathname)
|
175
175
|
content.start_with?("{")
|
data/lib/imap/backup/version.rb
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
module Imap; end
|
2
2
|
|
3
3
|
module Imap::Backup
|
4
|
-
MAJOR =
|
4
|
+
MAJOR = 3
|
5
5
|
MINOR = 2
|
6
|
-
REVISION =
|
7
|
-
|
6
|
+
REVISION = 1
|
7
|
+
PRE = nil
|
8
|
+
VERSION = [MAJOR, MINOR, REVISION, PRE].compact.map(&:to_s).join(".")
|
8
9
|
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module RetryOnError
|
2
|
+
def retry_on_error(errors:, limit: 10)
|
3
|
+
tries ||= 1
|
4
|
+
yield
|
5
|
+
rescue *errors => e
|
6
|
+
if tries < limit
|
7
|
+
message = "#{e}, attempt #{tries} of #{limit}"
|
8
|
+
Imap::Backup.logger.debug message
|
9
|
+
tries += 1
|
10
|
+
retry
|
11
|
+
end
|
12
|
+
raise e
|
13
|
+
end
|
14
|
+
end
|
data/spec/support/fixtures.rb
CHANGED
@@ -1,6 +1,11 @@
|
|
1
|
+
require "erb"
|
2
|
+
require "yaml"
|
3
|
+
|
1
4
|
def fixture(name)
|
2
5
|
spec_root = File.expand_path("..", File.dirname(__FILE__))
|
3
6
|
fixture_path = File.join(spec_root, "fixtures", "#{name}.yml")
|
4
|
-
|
5
|
-
|
7
|
+
content = File.read(fixture_path)
|
8
|
+
template = ERB.new(content)
|
9
|
+
yaml = template.result(binding)
|
10
|
+
YAML.safe_load(yaml, [Symbol])
|
6
11
|
end
|
@@ -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
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require "google/auth/stores/in_memory_token_store"
|
2
|
+
|
3
|
+
describe Google::Auth::Stores::InMemoryTokenStore do
|
4
|
+
KEY = "key".freeze
|
5
|
+
VALUE = "value".freeze
|
6
|
+
|
7
|
+
subject { described_class.new }
|
8
|
+
|
9
|
+
describe "#load" do
|
10
|
+
it "returns an item's value" do
|
11
|
+
subject[KEY] = VALUE
|
12
|
+
expect(subject.load(KEY)).to eq(VALUE)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|