imap-backup 1.3.0 → 1.4.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 (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