schleuder 3.4.1 → 3.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3127723694c9320dbc25abc2bd7d5de2a0ca22d587c794bb36be5ae09fdf63f0
4
- data.tar.gz: dfdcd502ca1498883cd931004034c7c4f8874271c06499bc39591cba407130a8
3
+ metadata.gz: 648f81aa424a3214a9d03fb66e5b3e216c8990e092d1b5b18ab41a1f0a1e5fd3
4
+ data.tar.gz: 62a5a77189860b4228a2168690f3990d02e53ee4dfb6c458a0273b5407e8a2e7
5
5
  SHA512:
6
- metadata.gz: 31e3a5ed4c50043c7f0324f60595023cfc51766eca36aae32d326cffdcb81f09a3ad4798d4a96e43e99d67b768ab620dfe5b686ce95aeb5132f02e674cbc483a
7
- data.tar.gz: bf93269f6737cfa57ce71474e15275e2379ea9c45cfc680698912d68674d0bd39a0d855ebbf09b59bdc00165948c81bc037b109012fd1b6b4a86f1664a9fcbb7
6
+ metadata.gz: 97a79b18f6655bd6f9c56beded7527e061fd955433ac2e436a28176eef9d8903916b16a1b0ee22a51f600fbd56d4639b5fadd5a53f3df08d4ca156fc805c9cbb
7
+ data.tar.gz: 6e6b4ea2847974d64a3b38a0d99ec0bfa5bb824a36d566fa731889a711b88f127ad6314f9f44b8dd99937977dc0f9d9f2530419762fae527bb24e37b10517343
data/README.md CHANGED
@@ -15,14 +15,15 @@ Requirements
15
15
  * gpgme
16
16
  * sqlite3
17
17
  * openssl
18
+ * icu
18
19
 
19
- *If you use Debian stretch or CentOS 7, please have a look at the [installation docs](https://schleuder.org/docs/#installation). We do provide packages for those platforms, which simplify the installation a lot.*
20
+ *If you use Debian buster or CentOS 7, please have a look at the [installation docs](https://schleuder.org/schleuder/docs/server-admins.html#installation). We do provide packages for those platforms, which simplify the installation a lot.*
20
21
 
21
22
  *🛈 A note regarding Ubuntu: All Ubuntu versions up to and including 17.10 don't meet the requirements with their packaged versions of gnupg! To run Schleuder on Ubuntu you currently have to install a more recent version of gnupg manually. Only Ubuntu 18.04 ("bionic") provides modern enough versions of Schleuder's requirements.*
22
23
 
23
- On systems that base on Debian 9 ("stretch"), install the dependencies via
24
+ On systems that base on Debian 10 ("buster"), install the dependencies via
24
25
 
25
- apt-get install ruby-dev gnupg2 libgpgme-dev libsqlite3-dev libssl-dev build-essential
26
+ apt-get install ruby-dev gnupg2 libgpgme-dev libsqlite3-dev libssl-dev build-essential libicu-dev
26
27
 
27
28
 
28
29
  We **recommend** to also run a random number generator like [haveged](http://www.issihosts.com/haveged/). This ensures Schleuder won't be blocked by lacking entropy, which otherwise might happen especially during key generation.
@@ -47,15 +48,15 @@ Additionally these **rubygems** are required (will be installed automatically un
47
48
  Installing Schleuder
48
49
  ------------
49
50
 
50
- 1. Download [the gem](https://schleuder.org/download/schleuder-3.4.1.gem) and [the OpenPGP-signature](https://schleuder.org/download/schleuder-3.4.1.gem.sig) and verify:
51
+ 1. Download [the gem](https://schleuder.org/download/schleuder-3.6.0.gem) and [the OpenPGP-signature](https://schleuder.org/download/schleuder-3.6.0.gem.sig) and verify:
51
52
  ```
52
53
  gpg --recv-key 0xB3D190D5235C74E1907EACFE898F2C91E2E6E1F3
53
- gpg --verify schleuder-3.4.1.gem.sig
54
+ gpg --verify schleuder-3.6.0.gem.sig
54
55
  ```
55
56
 
56
57
  2. If all went well install the gem:
57
58
  ```
58
- gem install schleuder-3.4.1.gem
59
+ gem install schleuder-3.6.0.gem
59
60
  ```
60
61
 
61
62
  3. Set up schleuder:
@@ -65,7 +66,7 @@ Installing Schleuder
65
66
  This creates necessary directories, copies example configs, etc. If you see errors about missing write permissions please follow the advice given.
66
67
 
67
68
 
68
- For further information on setup and configuration please read <https://schleuder.org/docs/#setup>.
69
+ For further information on setup and configuration please read <https://schleuder.org/schleuder/docs/server-admins.html>.
69
70
 
70
71
 
71
72
  Command line usage
@@ -112,7 +113,7 @@ To execute the test suite run:
112
113
 
113
114
  bundle exec rspec
114
115
 
115
- Please note: Some of the specs use 'pgrep'. On systems that base on Debian 9 ("stretch") install it via
116
+ Please note: Some of the specs use 'pgrep'. On systems that base on Debian 10 ("buster") install it via
116
117
 
117
118
  apt-get install procps
118
119
 
@@ -145,4 +146,4 @@ GNU GPL 3.0. Please see [LICENSE.txt](LICENSE.txt).
145
146
  Alternative Download
146
147
  --------------------
147
148
 
148
- Alternatively to the gem-files you can download the latest release as [a tarball](https://schleuder.org/download/schleuder-3.4.1.tar.gz) and [its OpenPGP-signature](https://schleuder.org/download/schleuder-3.4.1.tar.gz.sig).
149
+ Alternatively to the gem-files you can download the latest release as [a tarball](https://schleuder.org/download/schleuder-3.6.0.tar.gz) and [its OpenPGP-signature](https://schleuder.org/download/schleuder-3.6.0.tar.gz.sig).
data/Rakefile CHANGED
@@ -3,12 +3,14 @@ require_relative "lib/#{project}.rb"
3
3
 
4
4
  @version = Schleuder::VERSION
5
5
  @tagname = "#{project}-#{@version}"
6
- @gpguid = 'team@schleuder.org'
6
+ @gpguid = 'B3D190D5235C74E1907EACFE898F2C91E2E6E1F3'
7
7
  @filename_gem = "#{@tagname}.gem"
8
8
  @filename_tarball = "#{@tagname}.tar.gz"
9
9
 
10
10
  load "active_record/railties/databases.rake"
11
11
 
12
+ puts @filename_gem
13
+
12
14
  # Configure ActiveRecord
13
15
  ActiveRecord::Tasks::DatabaseTasks.tap do |config|
14
16
  config.root = File.dirname(__FILE__)
@@ -89,7 +91,7 @@ end
89
91
 
90
92
  desc 'OpenPGP-sign gem and tarball'
91
93
  task :sign_tarball do
92
- `gpg -u #{@gpguid} -b #{@filename_tarball}`
94
+ `gpg -v -u #{@gpguid} -b #{@filename_tarball}`
93
95
  end
94
96
 
95
97
  desc 'OpenPGP-sign gem'
@@ -110,7 +112,7 @@ end
110
112
  desc 'Publish gem-file to rubygems.org'
111
113
  task :publish_gem do
112
114
  puts "Really push #{@filename_gem} to rubygems.org? [yN]"
113
- if gets.match(/^y/i)
115
+ if $stdin.gets.match(/^y/i)
114
116
  puts "Pushing..."
115
117
  `gem push #{@filename_gem}`
116
118
  else
@@ -132,7 +134,7 @@ desc 'Check if version-tag already exists'
132
134
  task :check_version do
133
135
  # Check if Schleuder::VERSION has been updated since last release
134
136
  if `git tag`.match?(/^#{@tagname}$/)
135
- $stderr.puts "Warning: Tag '#{@tagname}' already exists. Did you forget to update #{project}/version.rb?"
137
+ $stderr.puts "Warning: Tag '#{@tagname}' already exists. Did you forget to update lib/#{project}/version.rb?"
136
138
  $stderr.print "Delete tag to continue? [yN] "
137
139
  if $stdin.gets.match(/^y/i)
138
140
  `git tag -d #{@tagname}`
@@ -0,0 +1,11 @@
1
+ class AddDeliverSelfsentToList < ActiveRecord::Migration
2
+ def up
3
+ if ! column_exists?(:lists, :deliver_selfsent)
4
+ add_column :lists, :deliver_selfsent, :boolean, default: true
5
+ end
6
+ end
7
+
8
+ def down
9
+ remove_column(:lists, :deliver_selfsent)
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ class AddAutocryptHeaderToList < ActiveRecord::Migration
2
+ def up
3
+ if ! column_exists?(:lists, :include_autocrypt_header)
4
+ add_column :lists, :include_autocrypt_header, :boolean, default: true
5
+ end
6
+ end
7
+
8
+ def down
9
+ remove_column(:lists, :include_autocrypt_header)
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ class AddSetReplyToToSenderAndMungeFrom < ActiveRecord::Migration
2
+ def up
3
+ if ! column_exists?(:lists, :set_reply_to_to_sender)
4
+ add_column :lists, :set_reply_to_to_sender, :boolean, default: false
5
+ end
6
+ if ! column_exists?(:lists, :munge_from)
7
+ add_column :lists, :munge_from, :boolean, default: false
8
+ end
9
+ end
10
+
11
+ def down
12
+ remove_column(:lists, :set_reply_to_to_sender)
13
+ remove_column(:lists, :munge_from)
14
+ end
15
+ end
data/db/schema.rb CHANGED
@@ -11,7 +11,7 @@
11
11
  #
12
12
  # It's strongly recommended that you check this file into your version control system.
13
13
 
14
- ActiveRecord::Schema.define(version: 20180110203100) do
14
+ ActiveRecord::Schema.define(version: 20200118170110) do
15
15
 
16
16
  create_table "lists", force: :cascade do |t|
17
17
  t.datetime "created_at"
@@ -37,6 +37,7 @@ ActiveRecord::Schema.define(version: 20180110203100) do
37
37
  t.boolean "keep_msgid", default: true
38
38
  t.boolean "bounces_drop_all", default: false
39
39
  t.boolean "bounces_notify_admins", default: true
40
+ t.boolean "deliver_selfsent", default: true
40
41
  t.boolean "include_list_headers", default: true
41
42
  t.boolean "include_openpgp_header", default: true
42
43
  t.integer "max_message_size_kb", default: 10240
@@ -44,6 +45,9 @@ ActiveRecord::Schema.define(version: 20180110203100) do
44
45
  t.boolean "forward_all_incoming_to_admins", default: false
45
46
  t.integer "logfiles_to_keep", default: 2
46
47
  t.text "internal_footer", default: ""
48
+ t.boolean "include_autocrypt_header", default: true
49
+ t.boolean "set_reply_to_to_sender", default: false
50
+ t.boolean "munge_from", default: false
47
51
  end
48
52
 
49
53
  create_table "subscriptions", force: :cascade do |t|
@@ -93,6 +93,9 @@ bounces_drop_on_headers:
93
93
  # Send a notice to the list-admins whenever an email is bounced or dropped?
94
94
  bounces_notify_admins: true
95
95
 
96
+ # Include Autocrypt header into emails?
97
+ include_autocrypt_header: true
98
+
96
99
  # Include RFC-compliant List-* Headers into emails?
97
100
  include_list_headers: true
98
101
 
@@ -123,3 +126,26 @@ language: en
123
126
  # Forward a raw copy of all incoming emails to the list-admins?
124
127
  # Mainly useful for debugging.
125
128
  forward_all_incoming_to_admins: false
129
+
130
+ # Should e-mails be delivered to the original subscribed sender?
131
+ # Disabling this only works for signed e-mails; any e-mail that is unsigned
132
+ # sent to the list is treated as coming from an unknown source
133
+ deliver_selfsent: true
134
+
135
+ # Set reply-to header to original sender's reply-to?
136
+ # Enabling this will set the reply-to-header of emails sent by schleuder
137
+ # to the original sender's reply-to-header. If the original sender
138
+ # did not supply a reply-to-header, the original from-header will be used.
139
+ # This option can enabled for improved usability since this affect
140
+ # mail client's reply-to button to reply to the original sender instead of
141
+ # the whole list.
142
+ set_reply_to_to_sender: false
143
+
144
+ # Munge from-header?
145
+ # Enabling this option will add the original sender to the from-header.
146
+ # To avoid DMARC issues, we still use the list's address as from-address.
147
+ # However the sender's address will be included as displayed name.
148
+ # For example: "sender@sender.org via list@list.org" <list@list.org>
149
+ # This option can enabled for improved usability since this affect
150
+ # mail client's displayed name.
151
+ munge_from: false
data/lib/schleuder.rb CHANGED
@@ -1,3 +1,10 @@
1
+ # default to UTF-8 encoding as early as possible for external
2
+ # data.
3
+ #
4
+ # this should ensure we are able to parse most incoming
5
+ # plain text mails in different charsets.
6
+ Encoding.default_external = Encoding::UTF_8
7
+
1
8
  # Stdlib
2
9
  require 'fileutils'
3
10
  require 'singleton'
@@ -61,6 +68,9 @@ ENV["SCHLEUDER_CONFIG"] ||= '/etc/schleuder/schleuder.yml'
61
68
  ENV["SCHLEUDER_LIST_DEFAULTS"] ||= '/etc/schleuder/list-defaults.yml'
62
69
  ENV["SCHLEUDER_ENV"] ||= 'production'
63
70
  ENV["SCHLEUDER_ROOT"] = rootdir.to_s
71
+ # Ensure that gnupg never-ever tries to ask for a passphrase.
72
+ ENV["GPG_TTY"] = "/nonexistant-#{rand}"
73
+ ENV["DISPLAY"] = nil
64
74
 
65
75
  GPGME::Ctx.set_gpg_path_from_env
66
76
  GPGME::Ctx.check_gpg_version
data/lib/schleuder/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require 'thor'
2
2
  require 'yaml'
3
3
  require 'gpgme'
4
+ require 'charlock_holmes'
4
5
 
5
6
  require_relative '../schleuder'
6
7
  require 'schleuder/cli/subcommand_fix'
@@ -4,7 +4,13 @@ module Schleuder
4
4
  class Conf
5
5
  include Singleton
6
6
 
7
- EMAIL_REGEXP = /\A.+@[[:alnum:]_.-]+\z/i
7
+ # since the regexp got only included into stdlib 2.2
8
+ # TODO: remove once 2.1 support dropped
9
+ if RUBY_VERSION < '2.2'
10
+ EMAIL_REGEXP = /\A[a-zA-Z0-9.!\#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z/
11
+ else
12
+ EMAIL_REGEXP = URI::MailTo::EMAIL_REGEXP
13
+ end
8
14
  # TODO: drop v3 keys and only accept length of 40
9
15
  FINGERPRINT_REGEXP = /\A(0x)?[a-f0-9]{32}([a-f0-9]{8})?\z/i
10
16
 
@@ -57,6 +57,10 @@ module GPGME
57
57
  "#{self.to_s}\n\n#{export(armor: true).read}"
58
58
  end
59
59
 
60
+ def minimal
61
+ export(minimal: true).to_s
62
+ end
63
+
60
64
  # Force encoding, some databases save "ASCII-8BIT" as binary data.
61
65
  alias_method :orig_fingerprint, :fingerprint
62
66
  def fingerprint
@@ -19,6 +19,7 @@ module Schleuder
19
19
  :receive_admin_only,
20
20
  :keep_msgid,
21
21
  :bounces_drop_all,
22
+ :deliver_selfsent,
22
23
  :bounces_notify_admins,
23
24
  :include_list_headers,
24
25
  :include_openpgp_header,
@@ -66,6 +67,22 @@ module Schleuder
66
67
  with: /\A[[:graph:]\s]*\z/i,
67
68
  }
68
69
 
70
+ # Some users find it quite confusing when they click "reply-to" and the mail client
71
+ # doesn't reply to the sender of the mail but the whole mailing list. For those lists it can be
72
+ # considered to set this value to true. The recipients will then receive e-mails
73
+ # where the "reply-to" header will contain the reply-to address
74
+ # of the sender and thus reply to the sender when clicking "reply-to" in a client.
75
+ # If no "reply-to" is set, the "from"-header of the original sender will be used.
76
+ # The default is off.
77
+ validates :set_reply_to_to_sender, boolean: true
78
+
79
+ # Some users find it confusing when the "from" does not contain the original sender
80
+ # but the list address. For those lists it can be considered to set the munged header.
81
+ # This will result in a "from"-header like this: "originalsender@original.com via list@list.com"
82
+ # The default is off.
83
+ validates :munge_from, boolean: true
84
+
85
+
69
86
  default_scope { order(:email) }
70
87
 
71
88
  def self.configurable_attributes
@@ -146,6 +163,16 @@ module Schleuder
146
163
  key.armored
147
164
  end
148
165
 
166
+ def key_minimal_base64_encoded(fingerprint=self.fingerprint)
167
+ key = keys(fingerprint).first
168
+
169
+ if key.blank?
170
+ return false
171
+ end
172
+
173
+ Base64.strict_encode64(key.minimal)
174
+ end
175
+
149
176
  def check_keys
150
177
  now = Time.now
151
178
  checkdate = now + (60 * 60 * 24 * 14) # two weeks
@@ -340,16 +367,24 @@ module Schleuder
340
367
  true
341
368
  end
342
369
 
343
- def send_to_subscriptions(mail)
370
+ def send_to_subscriptions(mail, incoming_mail=nil)
344
371
  logger.debug "Sending to subscriptions."
345
372
  mail.add_internal_footer!
346
373
  self.subscriptions.each do |subscription|
347
374
  begin
348
- if subscription.delivery_enabled
349
- subscription.send_mail(mail)
350
- else
375
+
376
+ if ! subscription.delivery_enabled
351
377
  logger.info "Not sending to #{subscription.email}: delivery is disabled."
378
+ next
379
+ end
380
+
381
+ if ! self.deliver_selfsent && incoming_mail.was_validly_signed? && ( subscription == incoming_mail.signer )
382
+ logger.info "Not sending to #{subscription.email}: delivery of self sent is disabled."
383
+ next
352
384
  end
385
+
386
+ subscription.send_mail(mail, incoming_mail)
387
+
353
388
  rescue => exc
354
389
  msg = I18n.t('errors.delivery_error',
355
390
  { email: subscription.email, error: exc.to_s })
@@ -42,6 +42,8 @@ module Schleuder
42
42
  gpg_opts.merge!(encrypt: true, keys: { address => key.fingerprint })
43
43
  end
44
44
  mail.gpg gpg_opts
45
+
46
+ mail.header['List-Id'] = "<#{@list.email.gsub('@', '.')}>"
45
47
  end
46
48
  mail.deliver
47
49
  end
@@ -17,6 +17,7 @@ module Mail
17
17
  attr_accessor :original_message
18
18
  attr_accessor :list
19
19
  attr_accessor :protected_headers_subject
20
+ attr_writer :dynamic_pseudoheaders
20
21
 
21
22
  # TODO: This should be in initialize(), but I couldn't understand the
22
23
  # strange errors about wrong number of arguments when overriding
@@ -44,9 +45,7 @@ module Mail
44
45
  # might be gone (e.g. request-keywords that delete subscriptions or
45
46
  # keys).
46
47
  new.signer
47
- self.dynamic_pseudoheaders.each do |str|
48
- new.add_pseudoheader(str)
49
- end
48
+ new.dynamic_pseudoheaders = self.dynamic_pseudoheaders.dup
50
49
 
51
50
  # Store previously protected subject for later access.
52
51
  # mail-gpg pulls headers from the decrypted mime parts "up" into the main
@@ -206,13 +205,17 @@ module Mail
206
205
  @recipient.match(/-bounce@/).present? ||
207
206
  # Empty Return-Path
208
207
  self.return_path.to_s == '<>' ||
209
- # Auto-Submitted exists and does not equal 'no' and no cron header
210
- # present, as cron emails have the auto-submitted header.
211
- ( self['Auto-Submitted'].present? && \
212
- self['Auto-Submitted'].to_s.downcase != 'no' && \
213
- !self['X-Cron-Env'].present?)
208
+ bounced?
209
+ end
210
+
211
+ def bounced?
212
+ @bounced ||= bounce_detected? || (error_status != "unknown")
214
213
  end
215
214
 
215
+ def error_status
216
+ @error_status ||= detect_error_code
217
+ end
218
+
216
219
  def keywords
217
220
  return @keywords if @keywords
218
221
 
@@ -244,11 +247,11 @@ module Mail
244
247
  # decide itself how to encode, it works. If we don't, some
245
248
  # character-sequences are not properly re-encoded.
246
249
  part.content_transfer_encoding = nil
247
- # Make the converted strings (now UTF-8) match what mime-part's headers say,
248
- # fall back to US-ASCII if none is set.
249
- # https://tools.ietf.org/html/rfc2046#section-4.1.2
250
- # -> Default charset is US-ASCII
251
- part.body = lines.compact.join.encode(part.charset||'US-ASCII')
250
+
251
+ # Set the right charset on the now parsed body
252
+ new_body = lines.compact.join
253
+ part.charset = new_body.encoding.to_s
254
+ part.body = new_body
252
255
 
253
256
  @keywords
254
257
  end
@@ -266,20 +269,17 @@ module Mail
266
269
  end
267
270
 
268
271
  def add_pseudoheader(string_or_key, value=nil)
269
- @dynamic_pseudoheaders ||= []
270
- if value.present?
271
- @dynamic_pseudoheaders << make_pseudoheader(string_or_key, value)
272
- else
273
- @dynamic_pseudoheaders << string_or_key.to_s
274
- end
272
+ dynamic_pseudoheaders << make_pseudoheader(string_or_key, value)
275
273
  end
276
274
 
277
275
  def make_pseudoheader(key, value)
278
- "#{key.to_s.camelize}: #{value.to_s}"
276
+ output = "#{key.to_s.camelize}: #{value.to_s}"
277
+ # wrap lines after 76 with 2 indents
278
+ output.gsub(/(.{1,76})( +|$)\n?/, " \\1\n").chomp.lstrip
279
279
  end
280
280
 
281
281
  def dynamic_pseudoheaders
282
- @dynamic_pseudoheaders || []
282
+ @dynamic_pseudoheaders ||= []
283
283
  end
284
284
 
285
285
  def signature_state
@@ -330,7 +330,8 @@ module Mail
330
330
  end
331
331
 
332
332
  def pseudoheaders(list)
333
- (standard_pseudoheaders(list) + dynamic_pseudoheaders).flatten.join("\n") + "\n"
333
+ separator = '------------------------------------------------------------------------------'
334
+ (standard_pseudoheaders(list) + dynamic_pseudoheaders).flatten.join("\n") + "\n" + separator + "\n"
334
335
  end
335
336
 
336
337
  def add_msgids(list, orig)
@@ -345,6 +346,14 @@ module Mail
345
346
  end
346
347
 
347
348
  def add_list_headers(list)
349
+ if list.include_autocrypt_header
350
+ # Inject whitespaces, to let Mail break the string at these points
351
+ # leading to correct wrapping.
352
+ keydata = list.key_minimal_base64_encoded.gsub(/(.{78})/, '\1 ')
353
+
354
+ self['Autocrypt'] = "addr=#{list.email}; prefer-encrypt=mutual; keydata=#{keydata}"
355
+ end
356
+
348
357
  if list.include_list_headers
349
358
  self['List-Id'] = "<#{list.email.gsub('@', '.')}>"
350
359
  self['List-Owner'] = "<mailto:#{list.owner_address}> (Use list's public key)"
@@ -517,5 +526,79 @@ module Mail
517
526
  end
518
527
  end.join(' ')
519
528
  end
529
+
530
+ def detect_error_code
531
+ # Detects the error code of an email with different heuristics
532
+ # from: https://github.com/mailtop/bounce_email
533
+
534
+ # Custom status codes
535
+ unicode_subject = self.subject.to_s
536
+ unicode_subject = unicode_subject.encode('utf-8') if unicode_subject.respond_to?(:encode)
537
+
538
+ return '97' if unicode_subject.match(/delayed/i)
539
+ return '98' if unicode_subject.match(/(unzulässiger|unerlaubter) anhang/i)
540
+ return '99' if unicode_subject.match(/auto.*reply|férias|ferias|Estarei ausente|estou ausente|vacation|vocation|(out|away).*office|on holiday|abwesenheits|autorespond|Automatische|eingangsbestätigung/i)
541
+
542
+ # Feedback-Type: abuse
543
+ return '96' if self.to_s.match(/Feedback-Type\: abuse/i)
544
+
545
+ if self.parts[1]
546
+ match_parts = self.parts[1].body.match(/(Status:.|550 |#)([245]\.[0-9]{1,3}\.[0-9]{1,3})/)
547
+ code = match_parts[2] if match_parts
548
+ return code if code
549
+ end
550
+
551
+ # Now try getting it from correct part of tmail
552
+ code = detect_bounce_status_code_from_text(self.body)
553
+ return code if code
554
+
555
+ # OK getting desperate so try getting code from entire email
556
+ code = detect_bounce_status_code_from_text(self.to_s)
557
+ code || 'unknown'
558
+ end
559
+
560
+ def bounce_detected?
561
+ # Detects bounces from different parts of the email without error status codes
562
+ # from: https://github.com/mailtop/bounce_email
563
+ return true if self.subject.to_s.match(/(returned|undelivered) mail|mail delivery( failed)?|(delivery )(status notification|failure)|failure notice|undeliver(able|ed)( mail)?|return(ing message|ed) to sender/i)
564
+ return true if self.subject.to_s.match(/auto.*reply|vacation|vocation|(out|away).*office|on holiday|abwesenheits|autorespond|Automatische|eingangsbestätigung/i)
565
+ return true if self['precedence'].to_s.match(/auto.*(reply|responder|antwort)/i)
566
+ return true if self.from.to_s.match(/^(MAILER-DAEMON|POSTMASTER)\@/i)
567
+ false
568
+ end
569
+
570
+ def detect_bounce_status_code_from_text(text)
571
+ # Parses a text and uses pattern matching to determines its error status (RFC 3463)
572
+ # from: https://github.com/mailtop/bounce_email
573
+ return "5.0.0" if text.match(/Status: 5\.0\.0/i)
574
+ return "5.1.1" if text.match(/no such (address|user)|Recipient address rejected|User unknown|does not like recipient|The recipient was unavailable to take delivery of the message|Sorry, no mailbox here by that name|invalid address|unknown user|unknown local part|user not found|invalid recipient|failed after I sent the message|did not reach the following recipient|nicht zugestellt werden|o pode ser entregue para um ou mais/i)
575
+ return "5.1.2" if text.match(/unrouteable mail domain|Esta casilla ha expirado por falta de uso|I couldn't find any host named/i)
576
+ if text.match(/mailbox is full|Mailbox quota (usage|disk) exceeded|quota exceeded|Over quota|User mailbox exceeds allowed size|Message rejected\. Not enough storage space|user has exhausted allowed storage space|too many messages on the server|mailbox is over quota|mailbox exceeds allowed size|excedeu a quota/i)
577
+ return "5.2.2" if text.match(/This is a permanent error||(Status: |)5\.2\.2/i)
578
+ return "4.2.2"
579
+ end
580
+ return "5.1.0" if text.match(/Address rejected/)
581
+ return "4.1.2" if text.match(/I couldn't find any host by that name/)
582
+ return "4.2.0" if text.match(/not yet been delivered/i)
583
+ return "5.1.1" if text.match(/mailbox unavailable|No such mailbox|RecipientNotFound|not found by SMTP address lookup|Status: 5\.1\.1/i)
584
+ return "5.2.3" if text.match(/Status: 5\.2\.3/i) # Too messages in folder
585
+ return "5.4.0" if text.match(/Status: 5\.4\.0/i) # too many hops
586
+ return "5.4.4" if text.match(/Unrouteable address/i)
587
+ return "4.4.7" if text.match(/retry timeout exceeded/i)
588
+ return "5.2.0" if text.match(/The account or domain may not exist, they may be blacklisted, or missing the proper dns entries./i)
589
+ return "5.5.4" if text.match(/554 TRANSACTION FAILED/i)
590
+ return "4.4.1" if text.match(/Status: 4.4.1|delivery temporarily suspended|wasn't able to establish an SMTP connection/i)
591
+ return "5.5.0" if text.match(/550 OU\-002|Mail rejected by Windows Live Hotmail for policy reasons/i)
592
+ return "5.1.2" if text.match(/PERM_FAILURE: DNS Error: Domain name not found/i)
593
+ return "4.2.0" if text.match(/Delivery attempts will continue to be made for/i)
594
+ return "5.5.4" if text.match(/554 delivery error:/i)
595
+ return "5.1.1" if text.match(/550-5.1.1|This Gmail user does not exist/i)
596
+ return "5.7.1" if text.match(/5.7.1 Your message.*?was blocked by ROTA DNSBL/i) # AA added
597
+ return "5.7.2" if text.match(/not have permission to post messages to the group/i)
598
+ return "5.3.2" if text.match(/Technical details of permanent failure|Too many bad recipients/i) && (text.match(/The recipient server did not accept our requests to connect/i) || text.match(/Connection was dropped by remote host/i) || text.match(/Could not initiate SMTP conversation/i)) # AA added
599
+ return "4.3.2" if text.match(/Technical details of temporary failure/i) && (text.match(/The recipient server did not accept our requests to connect/i) || text.match(/Connection was dropped by remote host/i) || text.match(/Could not initiate SMTP conversation/i)) # AA added
600
+ return "5.0.0" if text.match(/Delivery to the following recipient failed permanently/i) # AA added
601
+ return '5.2.3' if text.match(/account closed|account has been disabled or discontinued|mailbox not found|prohibited by administrator|access denied|account does not exist/i)
602
+ end
520
603
  end
521
604
  end
@@ -1,16 +1,12 @@
1
1
  module Schleuder
2
2
  module ListPlugins
3
3
  def self.attach_listkey(arguments, list, mail)
4
- filename = "#{list.fingerprint}.pgpkey"
5
- # "Mail" only really converts to multipart if the content-type is blank.
6
- mail.content_type = nil
7
- mail.add_file({
8
- filename: filename,
9
- content: list.export_key
10
- })
11
- mail.attachments[filename].content_type = 'application/pgp-keys'
12
- mail.attachments[filename].content_description = "OpenPGP public key of #{list.email}"
13
- mail.attachments[filename].content_disposition = "attachment; filename=#{filename}"
4
+ new_part = Mail::Part.new
5
+ new_part.body = list.export_key
6
+ new_part.content_type = 'application/pgp-keys'
7
+ new_part.content_description = "OpenPGP public key of #{list.email}"
8
+ new_part.content_disposition = "attachment; filename=#{list.fingerprint}.pgpkey"
9
+ mail.add_part new_part
14
10
  nil
15
11
  end
16
12
  end
@@ -104,35 +104,18 @@ module Schleuder
104
104
  # helper methods
105
105
  private
106
106
 
107
- def self.is_armored_key?(material)
108
- return false unless /^-----BEGIN PGP PUBLIC KEY BLOCK-----$/ =~ material
109
- return false unless /^-----END PGP PUBLIC KEY BLOCK-----$/ =~ material
110
-
111
- lines = material.split("\n").reject(&:empty?)
112
- # remove header
113
- lines.shift
114
- # remove tail
115
- lines.pop
116
- # verify the rest
117
- # TODO: verify length except for lasts lines?
118
- # headers according to https://tools.ietf.org/html/rfc4880#section-6.2
119
- lines.map do |line|
120
- /\A((comment|version|messageid|hash|charset):.*|[0-9a-z\/=+]+)\Z/i =~ line
121
- end.all?
122
- end
123
-
124
107
  def self.import_keys_from_attachments(list, mail)
125
108
  mail.attachments.map do |attachment|
126
109
  material = attachment.body.to_s
127
110
 
128
- list.import_key(material) if self.is_armored_key?(material)
111
+ list.import_key(material)
129
112
  end
130
113
  end
131
114
 
132
115
  def self.import_key_from_body(list, mail)
133
116
  key_material = mail.first_plaintext_part.body.to_s
134
117
 
135
- list.import_key(key_material) if self.is_armored_key?(key_material)
118
+ list.import_key(key_material)
136
119
  end
137
120
  end
138
121
  end
@@ -56,6 +56,9 @@ module Schleuder
56
56
 
57
57
  # Only continue if all recipients are still here.
58
58
  if recip_map.size < arguments.size
59
+ recip_map.keys.each do |aborted_sender|
60
+ mail.add_pseudoheader(:error, I18n.t("plugins.resend.aborted", email: aborted_sender))
61
+ end
59
62
  return
60
63
  end
61
64
 
@@ -117,22 +120,22 @@ module Schleuder
117
120
  Array(recipients).inject({}) do |hash, email|
118
121
  keys = mail.list.keys(email)
119
122
  # Exclude unusable keys.
120
- keys.select! { |key| key.usable_for?(:encrypt) }
121
- case keys.size
123
+ usable_keys = keys.select { |key| key.usable_for?(:encrypt) }
124
+ case usable_keys.size
122
125
  when 1
123
- hash[email] = keys.first
126
+ hash[email] = usable_keys.first
124
127
  when 0
125
128
  if encrypted_only
126
129
  # Don't add the email to the result to exclude it from the
127
130
  # recipients.
128
- add_keys_error(mail, email, keys.size)
131
+ add_resend_msg(mail, email, :error, 'not_resent_no_keys', usable_keys.size, keys.size)
129
132
  else
130
133
  hash[email] = ''
131
134
  end
132
135
  else
133
136
  # Always report this situation, regardless of sending or not. It's
134
137
  # bad and should be fixed.
135
- add_keys_error(mail, email, keys.size)
138
+ add_resend_msg(mail, email, :notice, 'not_resent_encrypted_no_keys', usable_keys.size, keys.size)
136
139
  if ! encrypted_only
137
140
  hash[email] = ''
138
141
  end
@@ -152,8 +155,8 @@ module Schleuder
152
155
  gpg_opts
153
156
  end
154
157
 
155
- def self.add_keys_error(mail, email, keys_size)
156
- mail.add_pseudoheader(:error, I18n.t("plugins.resend.not_resent_no_keys", email: email, num_keys: keys_size))
158
+ def self.add_resend_msg(mail, email, severity, msg, usable_keys_size, all_keys_size)
159
+ mail.add_pseudoheader(severity, I18n.t("plugins.resend.#{msg}", email: email, usable_keys: usable_keys_size, all_keys: all_keys_size))
157
160
  end
158
161
 
159
162
  def self.add_error_header(mail, recipients_map)
@@ -163,15 +166,15 @@ module Schleuder
163
166
  def self.add_resent_headers(mail, recipients_map, to_or_cc, sent_encrypted)
164
167
  if sent_encrypted
165
168
  prefix = I18n.t('plugins.resend.encrypted_to')
166
- str = recipients_map.map do |email, key|
169
+ str = "\n" + recipients_map.map do |email, key|
167
170
  "#{email} (#{key.fingerprint})"
168
- end.join(', ')
171
+ end.join(",\n")
169
172
  else
170
173
  prefix = I18n.t('plugins.resend.unencrypted_to')
171
- str = recipients_map.keys.join(', ')
174
+ str = ' ' + recipients_map.keys.join(", ")
172
175
  end
173
176
  headername = resent_header_name(to_or_cc)
174
- mail.add_pseudoheader(headername, "#{prefix} #{str}")
177
+ mail.add_pseudoheader(headername, "#{prefix}#{str}")
175
178
  end
176
179
 
177
180
  def self.resent_header_name(to_or_cc)
@@ -5,15 +5,43 @@ module Schleuder
5
5
  return error if error
6
6
 
7
7
  logger.info "Parsing incoming email."
8
+
9
+ # is it valid utf-8?
10
+ msg_scrubbed = false
11
+ unless msg.valid_encoding?
12
+ logger.warn "Converting message due to invalid characters"
13
+ detection = CharlockHolmes::EncodingDetector.detect(msg)
14
+ begin
15
+ msg = CharlockHolmes::Converter.convert(msg, detection[:encoding], 'UTF-8')
16
+ rescue ArgumentError
17
+ # it looks like even icu wasn't able to convert
18
+ # so we scrub the invalid characters to be able to
19
+ # at least parse the message somehow. Though this might
20
+ # result in data loss.
21
+ logger.warn "Scrubbing message due to invalid characters"
22
+ msg = msg.scrub
23
+ msg_scrubbed = true
24
+ end
25
+ end
26
+
8
27
  @mail = Mail.create_message_to_list(msg, recipient, list)
9
28
 
29
+ if msg_scrubbed
30
+ @mail.add_pseudoheader(:note, I18n.t("pseudoheaders.scrubbed_message"))
31
+ end
32
+
10
33
  error = run_filters('pre')
11
34
  return error if error
12
35
 
13
36
  begin
14
37
  # This decrypts, verifies, etc.
15
38
  @mail = @mail.setup
16
- rescue GPGME::Error::DecryptFailed
39
+
40
+ rescue GPGME::Error::BadPassphrase,
41
+ GPGME::Error::DecryptFailed,
42
+ GPGME::Error::NoData,
43
+ GPGME::Error::NoSecretKey
44
+
17
45
  logger.warn "Decryption of incoming message failed."
18
46
  return Errors::DecryptionFailed.new(list)
19
47
  end
@@ -46,7 +74,7 @@ module Schleuder
46
74
  # Subscriptions
47
75
  logger.debug "Creating clean copy of message"
48
76
  copy = @mail.clean_copy(list.headers_to_meta.any?)
49
- list.send_to_subscriptions(copy)
77
+ list.send_to_subscriptions(copy, @mail)
50
78
  nil
51
79
  end
52
80
 
@@ -10,6 +10,10 @@ module Schleuder
10
10
  validates :fingerprint, allow_blank: true, fingerprint: true
11
11
  validates :delivery_enabled, :admin, boolean: true
12
12
 
13
+ before_validation {
14
+ self.email = Mail::Address.new(self.email).address
15
+ }
16
+
13
17
  default_scope { order(:email) }
14
18
 
15
19
  scope :without_fingerprint, -> { where(fingerprint: [nil,'']) }
@@ -37,10 +41,10 @@ module Schleuder
37
41
  list.keys("0x#{self.fingerprint}").first
38
42
  end
39
43
 
40
- def send_mail(mail)
44
+ def send_mail(mail, incoming_mail=nil)
41
45
  list.logger.debug "Preparing sending to #{self.inspect}"
42
46
 
43
- mail = ensure_headers(mail)
47
+ mail = ensure_headers(mail, incoming_mail)
44
48
  gpg_opts = self.list.gpg_sign_options
45
49
 
46
50
  if self.key.blank?
@@ -66,9 +70,28 @@ module Schleuder
66
70
  mail.deliver
67
71
  end
68
72
 
69
- def ensure_headers(mail)
73
+ def ensure_headers(mail, incoming_mail=nil)
70
74
  mail.to = self.email
71
- mail.from = self.list.email
75
+
76
+ if self.list.set_reply_to_to_sender? && ! incoming_mail.nil?
77
+ # If the option "set_reply_to_to_sender" is set to true, we will set the reply-to header
78
+ # to the reply-to header given by the original email. If no reply-to header exists in the original email,
79
+ # the original senders email will be used as reply-to.
80
+ if ! incoming_mail.reply_to.nil?
81
+ mail.reply_to = incoming_mail.reply_to
82
+ else
83
+ mail.reply_to = incoming_mail.from
84
+ end
85
+ end
86
+
87
+ if self.list.munge_from? && ! incoming_mail.nil?
88
+ # If the option "munge_from" is set to true, we will add the original senders' from-header to ours.
89
+ # We munge the from-header to avoid issues with DMARC.
90
+ mail.from = I18n.t("header_munging", from: incoming_mail.from.first, list: self.list.email, list_address: self.list.email)
91
+ else
92
+ mail.from = self.list.email
93
+ end
94
+
72
95
  mail.sender = self.list.bounce_address
73
96
  mail
74
97
  end
@@ -1,3 +1,3 @@
1
1
  module Schleuder
2
- VERSION = '3.4.1'
2
+ VERSION = '3.6.0'
3
3
  end
data/locales/de.yml CHANGED
@@ -121,7 +121,9 @@ de:
121
121
  Oder, um einen Schlüssel per HTTP von einem Server zu laden:
122
122
  X-FETCH-KEY: https://example.org/keys/mykey.asc
123
123
  resend:
124
- not_resent_no_keys: Resending an <%{email}> fehlgeschlagen (%{num_keys} Schlüssel gefunden und unverschlüsseltes Senden verboten).
124
+ not_resent_no_keys: Resending an <%{email}> fehlgeschlagen (%{all_keys} Schlüssel gefunden, davon %{usable_keys} nutzbar. Unverschlüsseltes Senden verboten).
125
+ not_resent_encrypted_no_keys: Verschlüsseltes Resending an <%{email}> fehlgeschlagen (%{all_keys} Schlüssel gefunden, davon %{usable_keys} nutzbar).
126
+ aborted: Resending an <%{email}> abgebrochen aufgrund anderer Probleme.
125
127
  encrypted_to: Verschlüsselt an
126
128
  unencrypted_to: Unverschlüsselt an
127
129
  invalid_recipient: "Ungültige Emailadresse für resend: %{address}"
@@ -250,6 +252,7 @@ de:
250
252
  fetch_key:
251
253
  invalid_input: "Ungültige Angabe. Gültig sind: URLs, OpenPGP-Fingerabdrücke, oder Emailadressen."
252
254
  pseudoheaders:
255
+ scrubbed_message: Diese Email enthielt ungültige Zeichen, die aus Verarbeitungsgründen möglicherweise entfernt wurden.
253
256
  stripped_html_from_multialt: Diese Email enthielt einen alternativen HTML-Teil, der PGP-Daten beinhaltete. Der HTML-Teil wurde entfernt, um die Email sauberer analysieren zu können.
254
257
  stripped_html_from_multialt_with_keywords: Diese Email enthielt Schlüsselwörter und einen alternativen HTML-Teil. Der HTML-Teil wurde entfernt, um zu verhindern dass diese Schlüsselwörter Aussenstehenden bekannt werden.
255
258
  signature_states:
@@ -258,6 +261,7 @@ de:
258
261
  encryption_states:
259
262
  encrypted: "Verschlüsselt"
260
263
  unencrypted: "Unverschlüsselt"
264
+ header_munging: "%{from} über %{list} <%{list_address}>"
261
265
 
262
266
  activerecord:
263
267
  errors:
data/locales/en.yml CHANGED
@@ -125,7 +125,9 @@ en:
125
125
  Or, to fetch a key keys by URL:
126
126
  X-FETCH-KEY: https://example.org/keys/mykey.asc
127
127
  resend:
128
- not_resent_no_keys: Resending to <%{email}> failed (%{num_keys} keys found and unencrypted sending disallowed).
128
+ not_resent_no_keys: Resending to <%{email}> failed (%{all_keys} keys found, of which %{usable_keys} can be used. Unencrypted sending not allowed).
129
+ not_resent_encrypted_no_keys: Resending as encrypted email to <%{email}> failed (%{all_keys} keys found, of which %{usable_keys} can be used).
130
+ aborted: Resending to <%{email}> aborted due to other errors.
129
131
  encrypted_to: Encrypted to
130
132
  unencrypted_to: Unencrypted to
131
133
  invalid_recipient: "Invalid email-address for resending: %{address}"
@@ -254,6 +256,7 @@ en:
254
256
  fetch_key:
255
257
  invalid_input: "Invalid input. Allowed are: URLs, OpenPGP-fingerprints, or email-addresses."
256
258
  pseudoheaders:
259
+ scrubbed_message: This message included invalid characters, which might have been removed to be able to process the message properly.
257
260
  stripped_html_from_multialt: This message included an alternating HTML-part that contained PGP-data. The HTML-part was removed to enable parsing the message more properly.
258
261
  stripped_html_from_multialt_with_keywords: This message included keywords and an alternating HTML-part. The HTML-part was removed to prevent the disclosure of these keywords to third parties.
259
262
  signature_states:
@@ -262,6 +265,7 @@ en:
262
265
  encryption_states:
263
266
  encrypted: "Encrypted"
264
267
  unencrypted: "Unencrypted"
268
+ header_munging: "%{from} via %{list} <%{list_address}>"
265
269
 
266
270
  activerecord:
267
271
  errors:
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schleuder
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.4.1
4
+ version: 3.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - schleuder dev team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-09-16 00:00:00.000000000 Z
11
+ date: 2021-02-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: gpgme
@@ -19,7 +19,7 @@ dependencies:
19
19
  version: '2.0'
20
20
  - - ">="
21
21
  - !ruby/object:Gem::Version
22
- version: 2.0.13
22
+ version: 2.0.19
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -29,7 +29,7 @@ dependencies:
29
29
  version: '2.0'
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 2.0.13
32
+ version: 2.0.19
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: mail
35
35
  requirement: !ruby/object:Gem::Requirement
@@ -51,9 +51,9 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.3'
54
- - - ">="
54
+ - - "<"
55
55
  - !ruby/object:Gem::Version
56
- version: 0.3.3
56
+ version: 0.4.3
57
57
  type: :runtime
58
58
  prerelease: false
59
59
  version_requirements: !ruby/object:Gem::Requirement
@@ -61,9 +61,9 @@ dependencies:
61
61
  - - "~>"
62
62
  - !ruby/object:Gem::Version
63
63
  version: '0.3'
64
- - - ">="
64
+ - - "<"
65
65
  - !ruby/object:Gem::Version
66
- version: 0.3.3
66
+ version: 0.4.3
67
67
  - !ruby/object:Gem::Dependency
68
68
  name: activerecord
69
69
  requirement: !ruby/object:Gem::Requirement
@@ -176,6 +176,20 @@ dependencies:
176
176
  - - "~>"
177
177
  - !ruby/object:Gem::Version
178
178
  version: '1'
179
+ - !ruby/object:Gem::Dependency
180
+ name: charlock_holmes
181
+ requirement: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - "~>"
184
+ - !ruby/object:Gem::Version
185
+ version: 0.7.6
186
+ type: :runtime
187
+ prerelease: false
188
+ version_requirements: !ruby/object:Gem::Requirement
189
+ requirements:
190
+ - - "~>"
191
+ - !ruby/object:Gem::Version
192
+ version: 0.7.6
179
193
  - !ruby/object:Gem::Dependency
180
194
  name: rspec
181
195
  requirement: !ruby/object:Gem::Requirement
@@ -249,7 +263,7 @@ dependencies:
249
263
  description: |-
250
264
  Schleuder is a group's email-gateway: subscribers can exchange encrypted emails among themselves, receive emails from non-subscribers and send emails to non-subscribers via the list.
251
265
 
252
- (Please note: For some platforms there's a better way of installing Schleuder than `gem install`. See <https://schleuder.org/docs/#installation> for details.)
266
+ (Please note: For some platforms there's a better way of installing Schleuder than `gem install`. See <https://schleuder.org/schleuder/docs/server-admins.html#installation> for details.)
253
267
  email: team@schleuder.org
254
268
  executables:
255
269
  - schleuder
@@ -275,6 +289,9 @@ files:
275
289
  - db/migrate/20160501172700_fix_headers_to_meta_defaults.rb
276
290
  - db/migrate/20170713215059_add_internal_footer_to_list.rb
277
291
  - db/migrate/20180110203100_add_sig_enc_to_headers_to_meta_defaults.rb
292
+ - db/migrate/20180723173900_add_deliver_selfsent_to_list.rb
293
+ - db/migrate/20190906194820_add_autocrypt_header_to_list.rb
294
+ - db/migrate/20200118170110_add_set_reply_to_to_sender_and_munge_from.rb
278
295
  - db/schema.rb
279
296
  - etc/init.d/schleuder-api-daemon
280
297
  - etc/list-defaults.yml
@@ -391,7 +408,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
391
408
  - !ruby/object:Gem::Version
392
409
  version: '0'
393
410
  requirements: []
394
- rubyforge_project: "[none]"
411
+ rubyforge_project:
395
412
  rubygems_version: 2.7.6.2
396
413
  signing_key:
397
414
  specification_version: 4