imap-backup 1.3.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +2 -1
  5. data/.travis.yml +1 -1
  6. data/README.md +12 -0
  7. data/bin/imap-backup +11 -6
  8. data/docker-compose.yml +15 -0
  9. data/imap-backup.gemspec +3 -2
  10. data/lib/email/mboxrd/message.rb +30 -4
  11. data/lib/email/provider.rb +2 -2
  12. data/lib/imap/backup.rb +0 -1
  13. data/lib/imap/backup/account/connection.rb +29 -15
  14. data/lib/imap/backup/account/folder.rb +3 -3
  15. data/lib/imap/backup/configuration/account.rb +15 -12
  16. data/lib/imap/backup/configuration/asker.rb +3 -1
  17. data/lib/imap/backup/configuration/folder_chooser.rb +5 -2
  18. data/lib/imap/backup/configuration/list.rb +12 -9
  19. data/lib/imap/backup/configuration/setup.rb +5 -3
  20. data/lib/imap/backup/configuration/store.rb +4 -4
  21. data/lib/imap/backup/downloader.rb +6 -2
  22. data/lib/imap/backup/serializer/base.rb +2 -2
  23. data/lib/imap/backup/serializer/mbox.rb +16 -7
  24. data/lib/imap/backup/utils.rb +8 -3
  25. data/lib/imap/backup/version.rb +1 -1
  26. data/spec/features/backup_spec.rb +27 -0
  27. data/spec/features/helper.rb +2 -0
  28. data/spec/features/support/backup_directory.rb +27 -0
  29. data/spec/features/support/email_server.rb +40 -0
  30. data/spec/features/support/shared/connection_context.rb +12 -0
  31. data/spec/features/support/shared/message_fixtures.rb +6 -0
  32. data/spec/fixtures/connection.yml +7 -0
  33. data/spec/support/fixtures.rb +6 -0
  34. data/spec/unit/account/connection_spec.rb +19 -7
  35. data/spec/unit/account/folder_spec.rb +15 -5
  36. data/spec/unit/configuration/account_spec.rb +18 -8
  37. data/spec/unit/configuration/asker_spec.rb +6 -3
  38. data/spec/unit/configuration/connection_tester_spec.rb +1 -1
  39. data/spec/unit/configuration/folder_chooser_spec.rb +21 -11
  40. data/spec/unit/configuration/list_spec.rb +13 -6
  41. data/spec/unit/configuration/setup_spec.rb +5 -3
  42. data/spec/unit/configuration/store_spec.rb +13 -8
  43. data/spec/unit/downloader_spec.rb +3 -1
  44. data/spec/unit/email/mboxrd/message_spec.rb +32 -13
  45. data/spec/unit/email/provider_spec.rb +7 -3
  46. data/spec/unit/serializer/base_spec.rb +3 -2
  47. data/spec/unit/serializer/mbox_spec.rb +9 -6
  48. data/spec/unit/utils_spec.rb +15 -13
  49. metadata +35 -5
  50. data/lib/imap/backup/serializer/directory.rb +0 -43
  51. data/spec/unit/serializer/directory_spec.rb +0 -69
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8155050ded00ceecc4a76cf608612f7ba1ef32a51c3985441ad00a247e170140
4
- data.tar.gz: f91b55a88b6dc3eeddccd3bfa515e7056896dd166f2760d4c337256fbbd50d64
3
+ metadata.gz: 4665c4dcce3e9de07be72563f7f52045789d1da30120462e290ae5c3207598db
4
+ data.tar.gz: 3f455617d7b7a0d392856b74adb0aa75e528037e8716469a72e3a8b9aa405841
5
5
  SHA512:
6
- metadata.gz: 24d3ee8534b607abbb10e1345205ca8142e3701190ef8d8ef79128f0bfe6d620c2f275dc58b4e3dc39a0ebae3c1ea48c634ddff48d2d8dbed3ef85f2ad736abc
7
- data.tar.gz: 8ff7e5f6c757e4866b93ba2621941d38bbaa809cb4be7a08c7307dfdcc4d47b6a59bacd30dfd62c6f294a3fd6b9237cdc91c752a8c1cba3e077a62cbaee013e6
6
+ metadata.gz: 57ef0a8b879f915630ba3d3ecbf339973ecf11a0eb0ae30b1e520bd346a9daaaab501fc90c509e0f1ad5d4b0fbee6cf6a4424da3b10caeb72bc52b96663bc9cf
7
+ data.tar.gz: f834d433145dcdc60ece0b3e03e973d36c0bb884060e13378922a886310e9362f92db901a4a9882e7a31a300594253377c7450085a5af66097646c6480cd2ed7
data/.gitignore CHANGED
@@ -5,3 +5,4 @@ coverage
5
5
  pkg
6
6
  .rbenv-version
7
7
  vendor
8
+ /tmp
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --require spec_helper
3
+ --tag ~docker
@@ -1,6 +1,7 @@
1
- inherit_from: https://gist.githubusercontent.com/joeyates/4763b79425cf903fc70df3bc8fccda36/raw/356fa04037feedb212735d4e7002cbd8778a33e8/.rubocop.yaml
1
+ inherit_from: https://gitlab.com/snippets/1744945/raw
2
2
 
3
3
  AllCops:
4
+ TargetRubyVersion: 2.1
4
5
  Exclude:
5
6
  - "bin/stubs/*"
6
7
  DisplayCopNames:
@@ -11,4 +11,4 @@ branches:
11
11
  before_install:
12
12
  - gem update --system
13
13
  - gem update bundler
14
- script: "bundle exec rake spec"
14
+ script: "bundle exec rspec --tag ~docker"
data/README.md CHANGED
@@ -186,6 +186,18 @@ $ imap-backup status
186
186
  * https://github.com/rgrove/larch - copies between IMAP servers
187
187
  * https://github.com/OfflineIMAP/offlineimap
188
188
 
189
+ # Testing
190
+
191
+ ## Integration Tests
192
+
193
+ Integration tests are run against a Docker image
194
+ (antespi/docker-imap-devel:latest).
195
+
196
+ Currently, the integration tests with Docker are excluded from the CI run.
197
+
198
+ The image has a pre-existing user:
199
+ `address@example.org` with password `pass`
200
+
189
201
  ## Contributing
190
202
 
191
203
  1. Fork it
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  require "optparse"
3
3
 
4
- $LOAD_PATH.unshift(File.expand_path("../../lib/", __FILE__))
4
+ $LOAD_PATH.unshift(File.expand_path("../lib/", __dir__))
5
5
  require "imap/backup"
6
6
 
7
7
  KNOWN_COMMANDS = [
@@ -10,21 +10,26 @@ KNOWN_COMMANDS = [
10
10
  {name: "folders", help: "List folders for all (or selected) accounts"},
11
11
  {name: "status", help: "List count of non backed-up emails per folder"},
12
12
  {name: "help", help: "Show usage"}
13
- ]
13
+ ].freeze
14
14
 
15
15
  options = {command: "backup"}
16
- opts = OptionParser.new do |opts|
16
+ parser = OptionParser.new do |opts|
17
17
  opts.banner = "Usage: #{$PROGRAM_NAME} [options] COMMAND"
18
18
 
19
19
  opts.separator ""
20
20
  opts.separator "Commands:"
21
21
  KNOWN_COMMANDS.each do |command|
22
- opts.separator "\t%- 20s %s" % [command[:name], command[:help]]
22
+ opts.separator format("\t%- 20<name>s %<help>s", command)
23
23
  end
24
24
  opts.separator ""
25
25
  opts.separator "Common options:"
26
26
 
27
- opts.on("-a", "--accounts ACCOUNT1[,ACCOUNT2,...]", Array, "only these accounts") do |account|
27
+ opts.on(
28
+ "-a",
29
+ "--accounts ACCOUNT1[,ACCOUNT2,...]",
30
+ Array,
31
+ "only these accounts"
32
+ ) do |account|
28
33
  options[:accounts] = account
29
34
  end
30
35
 
@@ -38,7 +43,7 @@ opts = OptionParser.new do |opts|
38
43
  exit
39
44
  end
40
45
  end
41
- opts.parse!
46
+ parser.parse!
42
47
 
43
48
  if ARGV.size > 0
44
49
  options[:command] = ARGV.shift
@@ -0,0 +1,15 @@
1
+ # This file adapted from github.com/antespi/docker-imap-devel
2
+ version: "3"
3
+
4
+ services:
5
+ imap:
6
+ image: antespi/docker-imap-devel:latest
7
+ container_name: imap
8
+ ports:
9
+ - "8025:25"
10
+ - "8143:143"
11
+ - "8993:993"
12
+ environment:
13
+ - MAILNAME=example.org
14
+ - MAIL_ADDRESS=address@example.org
15
+ - MAIL_PASS=pass
@@ -3,8 +3,8 @@ require "imap/backup/version"
3
3
 
4
4
  Gem::Specification.new do |gem|
5
5
  gem.name = "imap-backup"
6
- gem.description = %q{Backup GMail, or any other IMAP email service, to disk.}
7
- gem.summary = %q{Backup GMail (or other IMAP) accounts to disk}
6
+ gem.description = "Backup GMail, or any other IMAP email service, to disk."
7
+ gem.summary = "Backup GMail (or other IMAP) accounts to disk"
8
8
  gem.authors = ["Joe Yates"]
9
9
  gem.email = ["joe.g.yates@gmail.com"]
10
10
  gem.homepage = "https://github.com/joeyates/imap-backup"
@@ -20,6 +20,7 @@ Gem::Specification.new do |gem|
20
20
  gem.add_runtime_dependency "mail"
21
21
 
22
22
  gem.add_development_dependency "codeclimate-test-reporter", "~> 0.4.8"
23
+ gem.add_development_dependency "pry-byebug"
23
24
  gem.add_development_dependency "rspec", ">= 3.0.0"
24
25
  gem.add_development_dependency "rubocop-rspec"
25
26
  gem.add_development_dependency "simplecov"
@@ -6,12 +6,22 @@ module Email::Mboxrd
6
6
  class Message
7
7
  attr_reader :supplied_body
8
8
 
9
+ def self.from_serialized(serialized)
10
+ cleaned = serialized.gsub(/^>(>*From)/, "\\1")
11
+ # Serialized messages in this format *should* start with a line
12
+ # From xxx yy zz
13
+ if cleaned.start_with?("From ")
14
+ cleaned = cleaned.sub(/^From .*[\r\n]*/, "")
15
+ end
16
+ new(cleaned)
17
+ end
18
+
9
19
  def initialize(supplied_body)
10
20
  @supplied_body = supplied_body.clone
11
21
  @supplied_body.force_encoding("binary")
12
22
  end
13
23
 
14
- def to_s
24
+ def to_serialized
15
25
  "From " + from + "\n" + mboxrd_body + "\n"
16
26
  end
17
27
 
@@ -36,24 +46,40 @@ module Email::Mboxrd
36
46
  end
37
47
 
38
48
  def from
39
- best_from + " " + asctime
49
+ @from ||=
50
+ begin
51
+ from = best_from
52
+ from << " " + asctime if asctime != ""
53
+ from
54
+ end
40
55
  end
41
56
 
42
57
  def mboxrd_body
43
58
  @mboxrd_body ||=
44
59
  begin
45
- @mboxrd_body = supplied_body.gsub(/\n(>*From)/, "\n>\\1")
60
+ @mboxrd_body = add_extra_quote(supplied_body)
46
61
  @mboxrd_body += "\n" if !@mboxrd_body.end_with?("\n")
47
62
  @mboxrd_body
48
63
  end
49
64
  end
50
65
 
66
+ def add_extra_quote(body)
67
+ # The mboxrd format requires that lines starting with 'From'
68
+ # be prefixed with a '>' so that any remaining lines which start with
69
+ # 'From ' can be taken as the beginning of messages.
70
+ # http://www.digitalpreservation.gov/formats/fdd/fdd000385.shtml
71
+ # Here we add an extra '>' before any "From" or ">From".
72
+ body.gsub(/\n(>*From)/, "\n>\\1")
73
+ end
74
+
51
75
  def asctime
52
- date ? date.asctime : ""
76
+ @asctime ||= date ? date.asctime : ""
53
77
  end
54
78
 
55
79
  def date
56
80
  parsed.date
81
+ rescue
82
+ nil
57
83
  end
58
84
  end
59
85
  end
@@ -2,7 +2,7 @@ module Email; end
2
2
 
3
3
  class Email::Provider
4
4
  def self.for_address(address)
5
- case
5
+ case
6
6
  when address.end_with?("@gmail.com")
7
7
  new(:gmail)
8
8
  when address.end_with?("@fastmail.fm")
@@ -21,7 +21,7 @@ class Email::Provider
21
21
  def options
22
22
  case provider
23
23
  when :gmail
24
- {port: 993, ssl: true}
24
+ {port: 993, ssl: {ssl_version: :TLSv1_2}}
25
25
  when :fastmail
26
26
  {port: 993, ssl: true}
27
27
  else
@@ -12,7 +12,6 @@ require "imap/backup/configuration/setup"
12
12
  require "imap/backup/configuration/store"
13
13
  require "imap/backup/downloader"
14
14
  require "imap/backup/serializer/base"
15
- require "imap/backup/serializer/directory"
16
15
  require "imap/backup/serializer/mbox"
17
16
  require "imap/backup/version"
18
17
  require "email/provider"
@@ -4,9 +4,10 @@ module Imap::Backup
4
4
  module Account; end
5
5
 
6
6
  class Account::Connection
7
- attr_reader :username
8
- attr_reader :local_path
9
7
  attr_reader :connection_options
8
+ attr_reader :local_path
9
+ attr_reader :password
10
+ attr_reader :username
10
11
 
11
12
  def initialize(options)
12
13
  @username, @password = options[:username], options[:password]
@@ -19,18 +20,23 @@ module Imap::Backup
19
20
  end
20
21
 
21
22
  def folders
22
- return @folders if @folders
23
- @folders = imap.list("", "*")
24
- if @folders.nil?
25
- Imap::Backup.logger.warn "Unable to get folder list for account #{username}"
26
- end
27
- @folders
23
+ @folders ||=
24
+ begin
25
+ root = provider_root
26
+ @folders = imap.list(root, "*")
27
+ if @folders.nil?
28
+ Imap::Backup.logger.warn(
29
+ "Unable to get folder list for account #{username}"
30
+ )
31
+ end
32
+ @folders
33
+ end
28
34
  end
29
35
 
30
36
  def status
31
37
  backup_folders.map do |folder|
32
38
  f = Account::Folder.new(self, folder[:name])
33
- s = Serializer::Directory.new(local_path, folder[:name])
39
+ s = Serializer::Mbox.new(local_path, folder[:name])
34
40
  {name: folder[:name], local: s.uids, remote: f.uids}
35
41
  end
36
42
  end
@@ -52,7 +58,9 @@ module Imap::Backup
52
58
  def imap
53
59
  return @imap unless @imap.nil?
54
60
  options = provider_options
55
- Imap::Backup.logger.debug "Creating IMAP instance: #{server}, options: #{options.inspect}"
61
+ Imap::Backup.logger.debug(
62
+ "Creating IMAP instance: #{server}, options: #{options.inspect}"
63
+ )
56
64
  @imap = Net::IMAP.new(server, options)
57
65
  Imap::Backup.logger.debug "Logging in: #{username}/#{masked_password}"
58
66
  @imap.login(username, password)
@@ -78,14 +86,10 @@ module Imap::Backup
78
86
  )
79
87
  end
80
88
 
81
- def password
82
- @password
83
- end
84
-
85
89
  def masked_password
86
90
  password.gsub(/./, "x")
87
91
  end
88
-
92
+
89
93
  def backup_folders
90
94
  return @backup_folders if @backup_folders && (@backup_folders.size > 0)
91
95
  (folders || []).map { |f| {name: f.name} }
@@ -104,5 +108,15 @@ module Imap::Backup
104
108
  def provider_options
105
109
  provider.options.merge(connection_options)
106
110
  end
111
+
112
+ # 6.3.8. LIST Command
113
+ # An empty ("" string) mailbox name argument is a special request to
114
+ # return the hierarchy delimiter and the root name of the name given
115
+ # in the reference.
116
+ def provider_root
117
+ return @provider_root if @provider_root
118
+ root_info = imap.list("", "")[0]
119
+ @provider_root = root_info.name
120
+ end
107
121
  end
108
122
  end
@@ -6,7 +6,7 @@ module Imap::Backup
6
6
  class Account::Folder
7
7
  extend Forwardable
8
8
 
9
- REQUESTED_ATTRIBUTES = ["RFC822", "FLAGS", "INTERNALDATE"]
9
+ REQUESTED_ATTRIBUTES = ["RFC822", "FLAGS", "INTERNALDATE"].freeze
10
10
 
11
11
  attr_reader :connection
12
12
  attr_reader :name
@@ -25,7 +25,7 @@ module Imap::Backup
25
25
  def uids
26
26
  imap.examine(name)
27
27
  imap.uid_search(["ALL"]).sort
28
- rescue Net::IMAP::NoResponseError => e
28
+ rescue Net::IMAP::NoResponseError
29
29
  Imap::Backup.logger.warn "Folder '#{name}' does not exist"
30
30
  []
31
31
  end
@@ -38,7 +38,7 @@ module Imap::Backup
38
38
  attributes = fetch_data_item.attr
39
39
  attributes["RFC822"].force_encoding("utf-8")
40
40
  attributes
41
- rescue Net::IMAP::NoResponseError => e
41
+ rescue Net::IMAP::NoResponseError
42
42
  Imap::Backup.logger.warn "Folder '#{name}' does not exist"
43
43
  nil
44
44
  end
@@ -33,21 +33,22 @@ module Imap::Backup
33
33
  end
34
34
 
35
35
  def header(menu)
36
- menu.header = <<-EOT
37
- Account:
38
- email: #{account[:username]}
39
- server: #{account[:server]}
40
- path: #{account[:local_path]}
41
- folders: #{folders.map { |f| f[:name] }.join(', ')}
42
- password: #{masked_password}
43
- EOT
36
+ menu.header = <<-HEADER.gsub(/^\s{8}/m, "")
37
+ Account:
38
+ email: #{account[:username]}
39
+ server: #{account[:server]}
40
+ path: #{account[:local_path]}
41
+ folders: #{folders.map { |f| f[:name] }.join(', ')}
42
+ password: #{masked_password}
43
+ HEADER
44
44
  end
45
45
 
46
46
  def modify_email(menu)
47
47
  menu.choice("modify email") do
48
48
  username = Configuration::Asker.email(username)
49
49
  puts "username: #{username}"
50
- others = store.accounts.select { |a| a != account }.map { |a| a[:username] }
50
+ other_accounts = store.accounts.reject { |a| a == account }
51
+ others = other_accounts.map { |a| a[:username] }
51
52
  puts "others: #{others.inspect}"
52
53
  if others.include?(username)
53
54
  puts "There is already an account set up with that email address"
@@ -83,19 +84,21 @@ Account:
83
84
 
84
85
  def modify_backup_path(menu)
85
86
  menu.choice("modify backup path") do
86
- validator = lambda do |p|
87
+ validator = ->(p) do
87
88
  same = store.accounts.find do |a|
88
89
  a[:username] != account[:username] && a[:local_path] == p
89
90
  end
90
91
  if same
91
- puts "The path '#{p}' is used to backup the account '#{same[:username]}'"
92
+ puts "The path '#{p}' is used to backup " \
93
+ "the account '#{same[:username]}'"
92
94
  false
93
95
  else
94
96
  true
95
97
  end
96
98
  end
97
99
  existing = account[:local_path].clone
98
- account[:local_path] = Configuration::Asker.backup_path(account[:local_path], validator)
100
+ account[:local_path] =
101
+ Configuration::Asker.backup_path(account[:local_path], validator)
99
102
  account[:modified] = true if existing != account[:local_path]
100
103
  end
101
104
  end
@@ -21,7 +21,9 @@ module Imap::Backup
21
21
  password = highline.ask("password: ") { |q| q.echo = false }
22
22
  confirmation = highline.ask("repeat password: ") { |q| q.echo = false }
23
23
  if password != confirmation
24
- return nil unless highline.agree("the password and confirmation did not match.\nContinue? (y/n) ")
24
+ return nil if !highline.agree(
25
+ "the password and confirmation did not match.\nContinue? (y/n) "
26
+ )
25
27
  return self.password
26
28
  end
27
29
  password
@@ -52,7 +52,9 @@ module Imap::Backup
52
52
  end
53
53
 
54
54
  def is_selected?(folder_name)
55
- account[:folders].find { |f| f[:name] == folder_name }
55
+ backup_folders = account[:folders]
56
+ return false if backup_folders.nil?
57
+ backup_folders.find { |f| f[:name] == folder_name }
56
58
  end
57
59
 
58
60
  def toggle_selection(folder_name)
@@ -60,6 +62,7 @@ module Imap::Backup
60
62
  changed = account[:folders].reject! { |f| f[:name] == folder_name }
61
63
  account[:modified] = true if changed
62
64
  else
65
+ account[:folders] ||= []
63
66
  account[:folders] << {name: folder_name}
64
67
  account[:modified] = true
65
68
  end
@@ -67,7 +70,7 @@ module Imap::Backup
67
70
 
68
71
  def connection
69
72
  @connection ||= Account::Connection.new(account)
70
- rescue => e
73
+ rescue
71
74
  nil
72
75
  end
73
76