schleuder 3.4.1 → 3.6.0

Sign up to get free protection for your applications and to get access to all the features.
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