schleuder 2.2.4 → 3.2.2

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 (141) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +138 -0
  3. data/Rakefile +136 -0
  4. data/bin/pinentry-clearpassphrase +72 -0
  5. data/bin/schleuder +9 -89
  6. data/bin/schleuder-api-daemon +4 -0
  7. data/db/migrate/20140501103532_create_lists.rb +39 -0
  8. data/db/migrate/20140501112859_create_subscriptions.rb +21 -0
  9. data/db/migrate/201508092100_add_language_to_lists.rb +11 -0
  10. data/db/migrate/20150812165700_change_keywords_admin_only_defaults.rb +8 -0
  11. data/db/migrate/20150813235800_add_forward_all_incoming_to_admins.rb +11 -0
  12. data/db/migrate/201508141727_change_send_encrypted_only_default.rb +8 -0
  13. data/db/migrate/201508222143_add_logfiles_to_keep_to_lists.rb +11 -0
  14. data/db/migrate/201508261723_rename_delivery_disabled_to_delivery_enabled_and_change_default.rb +14 -0
  15. data/db/migrate/201508261815_strip_gpg_passphrase.rb +11 -0
  16. data/db/migrate/201508261827_remove_default_mime.rb +9 -0
  17. data/db/migrate/20160501172700_fix_headers_to_meta_defaults.rb +8 -0
  18. data/db/migrate/20170713215059_add_internal_footer_to_list.rb +11 -0
  19. data/db/schema.rb +62 -0
  20. data/etc/init.d/schleuder-api-daemon +87 -0
  21. data/etc/list-defaults.yml +123 -0
  22. data/etc/postfix/schleuder_sqlite.cf +28 -0
  23. data/etc/schleuder-api-daemon.service +10 -0
  24. data/etc/schleuder.cron.weekly +6 -0
  25. data/etc/schleuder.yml +61 -0
  26. data/lib/schleuder-api-daemon.rb +420 -0
  27. data/lib/schleuder.rb +81 -47
  28. data/lib/schleuder/cli.rb +334 -0
  29. data/lib/schleuder/cli/cert.rb +24 -0
  30. data/lib/schleuder/cli/schleuder_cert_manager.rb +84 -0
  31. data/lib/schleuder/cli/subcommand_fix.rb +11 -0
  32. data/lib/schleuder/conf.rb +131 -0
  33. data/lib/schleuder/errors/active_model_error.rb +15 -0
  34. data/lib/schleuder/errors/base.rb +17 -0
  35. data/lib/schleuder/errors/decryption_failed.rb +16 -0
  36. data/lib/schleuder/errors/fatal_error.rb +13 -0
  37. data/lib/schleuder/errors/file_not_found.rb +14 -0
  38. data/lib/schleuder/errors/invalid_listname.rb +13 -0
  39. data/lib/schleuder/errors/key_adduid_failed.rb +13 -0
  40. data/lib/schleuder/errors/key_generation_failed.rb +16 -0
  41. data/lib/schleuder/errors/keyword_admin_only.rb +13 -0
  42. data/lib/schleuder/errors/list_exists.rb +13 -0
  43. data/lib/schleuder/errors/list_not_found.rb +14 -0
  44. data/lib/schleuder/errors/list_property_missing.rb +14 -0
  45. data/lib/schleuder/errors/listdir_problem.rb +16 -0
  46. data/lib/schleuder/errors/loading_list_settings_failed.rb +14 -0
  47. data/lib/schleuder/errors/message_empty.rb +14 -0
  48. data/lib/schleuder/errors/message_not_from_admin.rb +13 -0
  49. data/lib/schleuder/errors/message_sender_not_subscribed.rb +13 -0
  50. data/lib/schleuder/errors/message_too_big.rb +14 -0
  51. data/lib/schleuder/errors/message_unauthenticated.rb +13 -0
  52. data/lib/schleuder/errors/message_unencrypted.rb +13 -0
  53. data/lib/schleuder/errors/message_unsigned.rb +13 -0
  54. data/lib/schleuder/errors/standard_error.rb +5 -0
  55. data/lib/schleuder/errors/too_many_keys.rb +17 -0
  56. data/lib/schleuder/errors/unknown_list_option.rb +14 -0
  57. data/lib/schleuder/filters/auth_filter.rb +39 -0
  58. data/lib/schleuder/filters/bounces_filter.rb +12 -0
  59. data/lib/schleuder/filters/forward_filter.rb +17 -0
  60. data/lib/schleuder/filters/forward_incoming.rb +13 -0
  61. data/lib/schleuder/filters/hotmail_message_filter.rb +25 -0
  62. data/lib/schleuder/filters/max_message_size.rb +14 -0
  63. data/lib/schleuder/filters/request_filter.rb +26 -0
  64. data/lib/schleuder/filters/send_key_filter.rb +20 -0
  65. data/lib/schleuder/filters/strip_alternative_filter.rb +21 -0
  66. data/lib/schleuder/filters_runner.rb +83 -0
  67. data/lib/schleuder/gpgme/ctx.rb +274 -0
  68. data/lib/schleuder/gpgme/import_status.rb +27 -0
  69. data/lib/schleuder/gpgme/key.rb +212 -0
  70. data/lib/schleuder/gpgme/sub_key.rb +13 -0
  71. data/lib/schleuder/gpgme/user_id.rb +22 -0
  72. data/lib/schleuder/list.rb +318 -127
  73. data/lib/schleuder/list_builder.rb +139 -0
  74. data/lib/schleuder/listlogger.rb +31 -0
  75. data/lib/schleuder/logger.rb +23 -0
  76. data/lib/schleuder/logger_notifications.rb +69 -0
  77. data/lib/schleuder/mail/message.rb +482 -0
  78. data/lib/schleuder/mail/parts_list.rb +9 -0
  79. data/lib/schleuder/plugin_runners/base.rb +91 -0
  80. data/lib/schleuder/plugin_runners/list_plugins_runner.rb +24 -0
  81. data/lib/schleuder/plugin_runners/request_plugins_runner.rb +27 -0
  82. data/lib/schleuder/plugins/attach_listkey.rb +17 -0
  83. data/lib/schleuder/plugins/get_version.rb +7 -0
  84. data/lib/schleuder/plugins/key_management.rb +113 -0
  85. data/lib/schleuder/plugins/list_management.rb +15 -0
  86. data/lib/schleuder/plugins/resend.rb +196 -0
  87. data/lib/schleuder/plugins/sign_this.rb +46 -0
  88. data/lib/schleuder/plugins/subscription_management.rb +140 -0
  89. data/lib/schleuder/runner.rb +130 -0
  90. data/lib/schleuder/subscription.rb +98 -0
  91. data/lib/schleuder/validators/boolean_validator.rb +7 -0
  92. data/lib/schleuder/validators/email_validator.rb +7 -0
  93. data/lib/schleuder/validators/fingerprint_validator.rb +7 -0
  94. data/lib/schleuder/validators/greater_than_zero_validator.rb +7 -0
  95. data/lib/schleuder/validators/no_line_breaks_validator.rb +7 -0
  96. data/lib/schleuder/version.rb +1 -1
  97. data/locales/de.yml +179 -0
  98. data/locales/en.yml +179 -0
  99. metadata +305 -108
  100. checksums.yaml.gz.sig +0 -3
  101. data.tar.gz.sig +0 -2
  102. data/LICENSE +0 -339
  103. data/README +0 -32
  104. data/bin/schleuder-fix-gem-dependencies +0 -37
  105. data/bin/schleuder-init-setup +0 -37
  106. data/bin/schleuder-migrate-v2.1-to-v2.2 +0 -225
  107. data/bin/schleuder-newlist +0 -413
  108. data/contrib/check-expired-keys.rb +0 -60
  109. data/contrib/mutt-schleuder-colors.rc +0 -10
  110. data/contrib/mutt-schleuder-resend.vim +0 -24
  111. data/contrib/smtpserver.rb +0 -76
  112. data/ext/default-list.conf +0 -149
  113. data/ext/default-members.conf +0 -7
  114. data/ext/list.conf.example +0 -14
  115. data/ext/schleuder.conf +0 -64
  116. data/lib/schleuder/archiver.rb +0 -46
  117. data/lib/schleuder/crypt.rb +0 -210
  118. data/lib/schleuder/errors.rb +0 -5
  119. data/lib/schleuder/list_config.rb +0 -146
  120. data/lib/schleuder/log/listlogger.rb +0 -57
  121. data/lib/schleuder/log/outputter/emailoutputter.rb +0 -120
  122. data/lib/schleuder/log/outputter/metaemailoutputter.rb +0 -50
  123. data/lib/schleuder/log/schleuderlogger.rb +0 -34
  124. data/lib/schleuder/mail.rb +0 -873
  125. data/lib/schleuder/mailer.rb +0 -26
  126. data/lib/schleuder/member.rb +0 -69
  127. data/lib/schleuder/plugin.rb +0 -54
  128. data/lib/schleuder/processor.rb +0 -363
  129. data/lib/schleuder/schleuder_config.rb +0 -75
  130. data/lib/schleuder/storage.rb +0 -84
  131. data/lib/schleuder/utils.rb +0 -80
  132. data/man/schleuder-newlist.8 +0 -174
  133. data/man/schleuder.8 +0 -416
  134. data/plugins/README +0 -20
  135. data/plugins/manage_keys_plugin.rb +0 -113
  136. data/plugins/manage_members_plugin.rb +0 -156
  137. data/plugins/manage_self_plugin.rb +0 -26
  138. data/plugins/resend_plugin.rb +0 -35
  139. data/plugins/sign_this_plugin.rb +0 -14
  140. data/plugins/version_plugin.rb +0 -12
  141. metadata.gz.sig +0 -0
@@ -1,50 +0,0 @@
1
- module Schleuder
2
- class MetaEmailOutputter < EmailOutputter
3
- # TODO: refactor (merge?) with EmailOutputter
4
- def send_mail
5
- if @send_mail_lock
6
- self.level = Log4r::OFF # it's possible that the loglevel haven't yet been changed
7
- Schleuder.log.warn 'This is a loop in sending a mail in the MetaEmailOutputter, breaking!'
8
- return false
9
- end
10
- @send_mail_lock = true
11
- Schleuder.log.info { 'notifying superadmin' }
12
- begin
13
- mail = makeemail
14
- mail.to = Schleuder.config.superadminaddr
15
- r = Member.new('email' => Schleuder.config.superadminaddr)
16
- m = mail.individualize(r)
17
- if ! Processor.send(m, r, true, Schleuder.config.superadminaddr).first
18
- m.body = @altmsg
19
- m.content_type = 'text/plain'
20
- Processor.send(m, r, false, Schleuder.config.superadminaddr)
21
- end
22
- rescue => e
23
- Schleuder.log.warn "An error occurred while reporting an error: #{e.message}\n#{e.backtrace[0..3].join("\n")}"
24
- Schleuder.log.warn "Sending emergency notice to superadmin"
25
- mail = "From: root@localhost
26
- To: #{Schleuder.config.superadminaddr}
27
- Date: #{Time.now.strftime('%a, %d %b %Y %H:%M:%S %z')}
28
- Subject: Error
29
-
30
- Serious errors happened working for list #{Schleuder.listname}.
31
- Please check the logs!
32
- "
33
- Mailer.send mail, Schleuder.config.superadminaddr, 'root@localhost', true
34
- end
35
- Schleuder.log.info { 'Notifying done' }
36
- rescue => e
37
- # switch off logging per email, else we create loops here!
38
- self.level = Log4r::OFF
39
- if e.message.frozen?
40
- Schleuder.log.warn "A problematic error happened. I'll better dump the original message to preserve it:\n\n#{Schleuder.origmsg}"
41
- else
42
- e.message << "\nThis is very problematic, I'll better dump the original message to preserve it:\n\n#{Schleuder.origmsg}"
43
- end
44
- Schleuder.log.fatal { e }
45
- ensure
46
- @send_mail_lock = false
47
- @buff.clear
48
- end
49
- end
50
- end
@@ -1,34 +0,0 @@
1
- module Schleuder
2
- class SchleuderLogger < Log4r::Logger
3
- # Instantiates a logger to be used outside of list-contexts and sets
4
- # outputters and level.
5
- def initialize
6
- # The name 'log4r' is special: it is considered as meta-logger by Log4r
7
- # and receives log_internal()-messages.
8
- super 'log4r'
9
- # The initial log_level is inherited by all outputters.
10
- @level = eval("Log4r::#{Schleuder.config.log_level.upcase}")
11
- if Schleuder.config.log_file == 'syslog'
12
- formatter = Log4r::PatternFormatter.new(:pattern => "%M")
13
- require 'log4r/outputter/syslogoutputter'
14
- add Log4r::SyslogOutputter.new('syslog',
15
- { :level => @level,
16
- :ident => 'schleuder',
17
- :facility => "LOG_MAIL",
18
- :formatter => formatter }
19
- )
20
- else
21
- pattern = "%d Schleuder %l\t%M"
22
- formatter = Log4r::PatternFormatter.new(:pattern => pattern)
23
- add Log4r::FileOutputter.new('file',
24
- { :level => @level,
25
- :filename => Schleuder.config.log_file,
26
- :formatter => formatter }
27
- )
28
- end
29
- add MetaEmailOutputter.new('email',
30
- {:to => Schleuder.config.superadminaddr,
31
- :immediate_at => 'ERROR, FATAL'})
32
- end
33
- end
34
- end
@@ -1,873 +0,0 @@
1
- module Schleuder
2
- class Mail < TMail::Mail
3
-
4
- # Schleuder::Member's the mail shall be sent to
5
- attr_accessor :recipients
6
-
7
- # Additional data that is to be stored into internally sent
8
- # mails. Must be a Hash.
9
- attr_accessor :metadata
10
-
11
- # Indicator if the incoming mail was encrypted
12
- attr_accessor :in_encrypted
13
-
14
- # Indicator if (and by whom) the incoming mail was signed. Is +false+ or a
15
- # GPGME::Signature.
16
- attr_accessor :in_signed
17
-
18
- attr_accessor :resend_to
19
-
20
- # The incoming mail in raw form (string). Needed to verify detached
21
- # signatures (see +decrypt!+)
22
- attr_accessor :original_message
23
-
24
- # Shall we consider this email to be sent by the list-admin?
25
- attr_reader :from_admin
26
-
27
- # Shall we consider this email to be sent by a list-member?
28
- attr_reader :from_member
29
-
30
- # internal message_id we'll use for all outgoing mails
31
- @@schleuder_message_id = nil
32
-
33
- def initialize(*data)
34
- super(*data)
35
- @resend_to = []
36
- @metadata = {:resent_to => [], :notice => [], :warning => [], :error => []}
37
- end
38
-
39
- # Overwrites TMail.parse to store the original incoming data. We possibly
40
- # need that to verify pgp-signatures (TMail changes headers which
41
- # invalidates the signature)
42
- def self.parse(string)
43
- foo = super(string)
44
- foo.original_message = string
45
- foo
46
- end
47
-
48
- # Desctructivly decrypt/verify +self+.
49
- def decrypt!
50
- # Note: We don't recurse into nested Mime-parts. Only the first level
51
- # will be touched
52
- if self.multipart? && !self.type_param('protocol').nil? && self.type_param('protocol').match(/^application\/pgp.*/)
53
- Schleuder.log.debug 'mail is pgp/mime-formatted'
54
- if self.sub_type == 'signed'
55
- Schleuder.log.debug 'mail is signed but not encrypted'
56
- Schleuder.log.debug 'Parsing original_message to split content from signature'
57
-
58
- # first, identify the boundary
59
- bm = self.original_message.match(/boundary="?'?([^"';]*)/)
60
- boundary = "--" + Regexp.escape(bm[1])
61
- Schleuder.log.debug "Identified boundary: #{boundary}"
62
- # next, find the signed string between the first and the second-last boundary
63
- signed_string = self.original_message.match(/.*#{boundary}\r?\n(.*?)\r?\n#{boundary}.*?#{boundary}.*?/m)[1]
64
- # Add CRs, which probably have been stripped by the MTA
65
- signed_string.gsub!(/\n/, "\r\n") unless signed_string.include?("\r")
66
- Schleuder.log.debug "Identified signed string"
67
- # third, find the pgp-signature
68
- signature = self.original_message.match(/.*(-----BEGIN PGP SIGNATURE-----.*?-----END PGP SIGNATURE-----).*/m)[1] rescue ''
69
- Schleuder.log.debug "Identified signature"
70
- # finally, verify
71
- Schleuder.log.info "Verifying"
72
- foo, self.in_signed = self.crypt.verify(signature, signed_string)
73
- plaintext = self.parts[0].to_s
74
- elsif self.sub_type == 'encrypted'
75
- Schleuder.log.debug 'mail is encrypted (and possibly signed)'
76
- plaintext, self.in_encrypted, self.in_signed = self.crypt.decrypt(self.parts[1].body)
77
- else
78
- Schleuder.log.warn "Strange: message claims to be pgp/mime but neither to be signed nor encrypted"
79
- end
80
- # replace encrypted content with cleartext content
81
- plainmsg = Mail.parse(plaintext)
82
- #Schleuder.log.debug "plainmsg: #{plainmsg.to_s.inspect}"
83
- # test for signed content within the previously encrypted content
84
- if plainmsg._content_type_stripped == 'multipart/signed'
85
- Schleuder.log.debug "Found signed message as cleartext, recurseing once"
86
- plainmsg.original_message = plaintext
87
- plainmsg.decrypt!
88
- Schleuder.log.debug "End of recursion"
89
- self.in_signed = plainmsg.in_signed
90
- end
91
- # get headers and body(parts) into self
92
- if plainmsg.multipart?
93
- Schleuder.log.debug "plainmsg.multipart? => true"
94
- self.parts.clear
95
- plainmsg.parts.each do |p|
96
- self.parts.push(Mail.parse(p.to_s))
97
- end
98
- else
99
- Schleuder.log.debug "plainmsg.multipart? => false"
100
- self.body = plainmsg.body
101
- end
102
- self.content_type = plainmsg._content_type
103
- self.disposition = plainmsg._disposition
104
- else
105
- Schleuder.log.debug 'no pgp-mime found, looking for pgp-inline'
106
- # Do we simply push everything to crypt as that should return plain
107
- # text if it is feeded plain text? Or does crypt return only an error
108
- # if there is nothing to do for it?
109
- if self.multipart?
110
- Schleuder.log.debug 'multipart found, looking for pgp content in each part'
111
- self.parts.each do |part|
112
- _decrypt_pgp_inline(part)
113
- end
114
- # If all parts are equally encrypted and signed mark the whole message as such
115
- self.in_encrypted = self.parts.map{ |p| p.in_encrypted }.uniq == [true]
116
- partsigs = self.parts.map do |p|
117
- # Can't use to_s here, gpgme bombs.
118
- p.in_signed.respond_to?(:fpr) ? p.in_signed.fpr : p.in_signed
119
- end
120
- if partsigs.uniq.size == 1
121
- self.in_signed = self.parts.first.in_signed
122
- end
123
- else
124
- Schleuder.log.debug 'single inline content found, looking for pgp content'
125
- _decrypt_pgp_inline(self)
126
- end
127
- end
128
- _parse_signature
129
- end
130
-
131
- # Destructivly encrypt and sign. +receiver+ must be a string or a Schleuder::Member
132
- def encrypt!(receiver)
133
- if receiver.kind_of?(String)
134
- receiver = Member.new({'email' => receiver})
135
- elsif ! receiver.kind_of?(Member)
136
- raise "need a Member-object as argument, got '#{receiver.inspect}'"
137
- end
138
-
139
- Schleuder.log.info "Encrypting for #{receiver.inspect}"
140
-
141
- key, msg = receiver.key
142
- if key == false
143
- Schleuder.log.warn msg.capitalize
144
- return [false, msg]
145
- elsif key.nil?
146
- Schleuder.log.warn "No public key found for '#{keystring}'"
147
- return [false, 'no public key found']
148
- end
149
-
150
- # choose pgp-variant from addresses value oder config.default_mime
151
- pgpvariant = receiver.mime || Schleuder.list.config.default_mime
152
-
153
- case pgpvariant.upcase
154
- when 'PLAIN'
155
- Schleuder.log.debug "encrypting as text/plain"
156
- if self.multipart?
157
- self.parts.each_with_index do |p,i|
158
- self.parts[i] = _encrypt_pgp_inline(p, receiver)
159
- end
160
- self.content_type = 'multipart/mixed'
161
- self.encoding = nil
162
- else
163
- foo = _encrypt_pgp_inline(self, receiver)
164
- self.body = foo.body
165
- foo.header.each do |k,v|
166
- self[k] = v.to_s rescue nil
167
- end
168
- end
169
- when 'MIME'
170
- Schleuder.log.debug "encrypting pgp-mime"
171
- gpgcontent = Mail.new
172
- if self.multipart?
173
- self.parts.each do |p|
174
- gpgcontent.parts.push(p)
175
- end
176
- else
177
- gpgcontent.body = self.body
178
- gpgcontent.content_type = self._content_type
179
- gpgcontent.charset = self.charset || 'UTF-8'
180
- end
181
-
182
- gpgcontainer = _new_part(crypt.encrypt_str(gpgcontent.to_s, receiver), 'application/octet-stream', self.charset)
183
- gpgcontainer.set_disposition('inline', {:filename => 'message.asc'})
184
-
185
- mimecontainer = _new_part("Version: 1\n", 'application/pgp-encrypted')
186
- mimecontainer.disposition = 'attachment'
187
-
188
- self.encoding = nil
189
- self.body = ""
190
- self.parts.clear
191
- self.parts.push(mimecontainer)
192
- self.parts.push(gpgcontainer)
193
- self.set_content_type('multipart', 'encrypted', {:protocol => 'application/pgp-encrypted'})
194
- self.mime_version = 1.0
195
-
196
- when 'APPL'
197
- Schleuder.log.error "mime-setting 'APPL' is deprecated, use 'MIME' instead for user '#{receiver.email}'"
198
- else
199
- Schleuder.log.error "Unknown mime-setting for user '#{receiver.email}': #{pgpvariant}"
200
- end
201
- return true
202
- end
203
-
204
- # Clearsign the message. Returns a string (raw msg, which must not be
205
- # changed anymore) or a Schleuder::Mail.
206
- def sign
207
- Schleuder.log.debug "signing message"
208
- if (self.to || []).length != 1
209
- Schleuder.log.error "I need exactly one recipient in To-header. Found this: #{self.to.inspect}"
210
- return false
211
- end
212
- member = Schleuder.list.find_member_by_email(self.to.to_s)
213
- # ugly but necessary: member might be false and member.mime might be nil
214
- pgpvariant = member.mime || Schleuder.list.config.default_mime rescue Schleuder.list.config.default_mime
215
- case pgpvariant.upcase
216
- when 'MIME'
217
- Schleuder.log.debug "signing MIME"
218
- gpgcontainer = Mail.new
219
- if self.multipart?
220
- self.parts.each do |p|
221
- if p.main_type.eql?('text')
222
- # Encode QP (there might be trailing white space
223
- p.body = p.body.to_s.split("\n").map do |line|
224
- [line].pack("M")
225
- end.join("\n")
226
- p.encoding = 'Quoted-Printable'
227
- end
228
- gpgcontainer.parts.push(p)
229
- end
230
- self.parts.clear
231
- else
232
- # Encode QP (there might be trailing white space which is illegal
233
- # according to RFC 3156).
234
- # Don't call body() as that returns the decoded string
235
- gpgcontainer.body = body.to_s.split("\n").map do |line|
236
- [line].pack("M")
237
- end.join("\n")
238
- gpgcontainer.encoding = 'Quoted-Printable'
239
- gpgcontainer.content_type = self._content_type
240
- gpgcontainer.disposition = self._disposition
241
- self.body = ''
242
- end
243
- self.encoding = ''
244
- self.parts.push gpgcontainer
245
-
246
- # This following part is quite complicated. We have to do it this way
247
- # because TMail changes the mime-boundary on every encoded() (implicit
248
- # in to_s()), which invalidates the signature
249
-
250
- # TODO: refactor with encrypt!()
251
-
252
- # First we create the signature-attachment and fill it with a dummy
253
- dummy = "dummytobereplaced-#{Time.now.to_f}"
254
- sigpart = _new_part(dummy, 'application/pgp-signature')
255
- self.parts.push(sigpart)
256
-
257
- # TODO: take care of micalg: the digest used for hashing the plaintext.
258
- # RFC 3156 requires it to be set in the content-type. ruby-gpgme
259
- # doesn't provide it to us, though.
260
- # (Don't use symbols with TMail, it expects strings.)
261
- self.set_content_type('multipart', 'signed', {'protocol' => 'application/pgp-signature', 'micalg' => 'pgp-sha1'})
262
-
263
- # Then we dump the crafted msg
264
- rawmsg = self.encoded
265
-
266
- # get mime boundary from raw mail
267
- bm = rawmsg.match(/boundary="?'?([^"';\r\n]*)/)
268
- boundary = "--" + Regexp.escape(bm[1])
269
-
270
- # Now get the to be signed string from the raw message
271
- parts = rawmsg.match(/(.*#{boundary}\r?\n)(.*\r?\n)(\r?\n#{boundary}.*?#{boundary}.*)/m)
272
-
273
- if parts
274
- # For some reason I don't manage to do this in one gsub-call... *snif*
275
- tobesigned_crlf = parts[2].gsub(/\n/, "\r\n").gsub(/\r\r/, "\r")
276
- rawmsg = "#{parts[1]}#{tobesigned_crlf}#{parts[3]}"
277
-
278
- # sign the extracted part
279
- sig = self.crypt.sign(tobesigned_crlf)
280
-
281
- # replace dummy with signature
282
- rawmsg.gsub!(/(.*)#{dummy}(.*?#{boundary}.*?)/m, "\\1#{sig}\\2")
283
- rawmsg
284
- else
285
- Schleuder.log.error "Detection of mime parts in mail for #{self.to} with boundary #{bm} failed. This should not happen. -> Cannot sign outgoing mail."
286
-
287
- false
288
- end
289
- when 'PLAIN'
290
- Schleuder.log.debug "signing PLAIN"
291
- if self.multipart?
292
- self.parts.each_with_index do |p,i|
293
- if p.disposition == 'attachment' || !p.disposition_param('filename').nil?
294
- Schleuder.log.debug "found attachment, creating detached signature"
295
- # attachment, need detached sig
296
- container = Mail.new
297
- container.parts.push Mail.parse(p.to_s)
298
-
299
- sig = crypt.sign(p.body)
300
- Schleuder.log.debug "sig: #{sig.inspect}"
301
- sigpart = _new_part(sig, 'text/plain')
302
- sigpart.set_disposition('attachment', {:filename => p.disposition_param('filename') + '.asc'})
303
- container.parts.push Mail.parse(sigpart.to_s)
304
-
305
- container.content_type = 'multipart/mixed'
306
-
307
- self.parts[i] = Mail.parse(container.to_s)
308
- else
309
- p.body = crypt.clearsign(p.body)
310
- end
311
- end
312
- else
313
- self.body = crypt.clearsign(self.body)
314
- end
315
- self
316
-
317
- else
318
- Schleuder.log.error "Strange mime-setting: #{pgpvariant}. Don't know what to do with that, ignoring it."
319
- self
320
- end
321
- end
322
-
323
- # Create a new Schleuder::Mail-instance and copy/set selected headers.
324
- def individualize(recv)
325
- Schleuder.log.debug { "Individualizing mail for #{recv.inspect}" }
326
- new = Mail.new
327
- new.crypt = crypt
328
- # add some headers we want to keep
329
- new.content_type = self['content-type'].to_s if self['content-type'] && !self['content-type'].illegal?
330
- new.disposition = self._disposition
331
- new.encoding = self.encoding.to_s
332
- new.subject = _quote_if_necessary(self.subject.to_s,'UTF-8')
333
-
334
- new.message_id = Mail._schleuder_message_id
335
-
336
- new.to = recv.email.to_s
337
- new.date = Time.now
338
- new.from = Schleuder.list.config.myaddr
339
- new = _add_openpgp_header(new) if Schleuder.list.config.include_openpgp_header
340
- if self.multipart?
341
- self.parts.each do |p|
342
- new.parts.push(Mail.parse(p.to_s))
343
- end
344
- else
345
- new.body = self.body
346
- end
347
- new
348
- end
349
-
350
- def individualize_member(recv)
351
- new = self.individualize(recv)
352
- new = _message_ids(new) if Schleuder.list.config.keep_msgid
353
- new = _list_headers(new) if Schleuder.list.config.include_list_headers
354
- new
355
- end
356
-
357
- # Strips keywords from self and stores them in @+keywords+
358
- def keywords
359
- unless @keywords
360
- @keywords = []
361
- # we're looking for keywords only in the first mime-part
362
- b = self
363
- while b.multipart?
364
- b = b.parts.first
365
- end
366
- # split to array to ease parsing
367
- a = b.body.split("\n")
368
-
369
- a.map! do |line|
370
- if line.match(/^X-.*/i)
371
- Schleuder.log.debug "found keyword-line: #{line.chomp}"
372
- key, val = $&.split(/:|: | /, 2)
373
- keyword = key.slice(2..-1).strip
374
- if !self.from_admin && Schleuder.list.config.keywords_admin_only.include?(keyword)
375
- Schleuder.log.info "Keyword '#{keyword}' is listed as admin only and mail is not from admin, skipping"
376
- self.metadata[:error] << "Keyword '#{keyword}' is configured as admin-only."
377
- # return the line to have it stay in the body
378
- line
379
- else
380
- # Split values to catch multiple values separated by comma or the like (deprecated).
381
- values = val.to_s.strip.split(/[,;]+/)
382
- values << ' ' if values.empty?
383
- values.each do |v|
384
- Schleuder.log.info "Storing keyword #{keyword} with value #{v.inspect}"
385
- @keywords << {keyword => v.to_s.strip}
386
- end
387
- nil
388
- end
389
- elsif line.chomp.empty?
390
- line
391
- else
392
- # break on the first non-empty and non-command line so we don't parse
393
- # the whole message (could be much data)
394
- break
395
- end
396
- end
397
- # delete nil's (lines formerly containing X-Commands) from array
398
- a.compact!
399
- # rejoin to string
400
- b.body = a.join("\n")
401
- # The whole procedure makes TMail parse and reformat the message, so now
402
- # it's decoded utf-8
403
- b.charset = 'UTF-8'
404
- b.encoding = nil
405
- Schleuder.log.debug "Inspecting found keywords: #{@keywords.inspect}"
406
- end
407
- @keywords
408
- end
409
-
410
- def request?
411
- @request ||= self.to.to_a.include?(Schleuder.list.request_addr)
412
- end
413
-
414
- def bounce?
415
- self.to.to_a.include?(Schleuder.list.bounce_addr) || \
416
- ( # empty Return-Path
417
- ['<>'].include?(self['Return-path'].to_s) || \
418
- ( # Auto-Submitted exists and does not equal 'no'
419
- ! ["no", ""].include?(self['Auto-Submitted'].to_s) && \
420
- self['X-Cron-Env'].nil? # cron mails are also autosubmitted
421
- )
422
- )
423
- end
424
-
425
- # +require+s all found plugins, tests SomePlugin.match and if that returns
426
- # true runs SomePlugin.process
427
- def process_plugins!
428
- if self.keywords.empty?
429
- Schleuder.log.info 'No keywords present, skipping plugins'
430
- else
431
- Schleuder.config.plugins_dir.each do |plugins_dir|
432
- if File.directory?(plugins_dir)
433
- replymsg = ""
434
- # We might want to replace the object later, which we can't do with self.
435
- mail = self
436
- plugins = {:request => [], :list => []}
437
- ptype = (self.request? ? :request : :list)
438
- used_keywords = []
439
- Plugin.signing_key(self)
440
- Dir[plugins_dir + '/*_plugin.rb'].each do |plugfile|
441
- Schleuder.log.debug "Instanciating #{plugfile} as plugin"
442
- require plugfile
443
- # interpreting class name from file name
444
- classname = File.basename(plugfile, '.rb').split('_').collect { |p| p.capitalize }.join
445
- plugin = instance_eval(classname).new
446
- Schleuder.log.debug "Storing plugin in plugin-list '#{plugin.plugin_type}'"
447
- plugins[plugin.plugin_type] << plugin
448
- end
449
- # This is neither elegant nor fast, but it's got to live only until the rewrite.
450
- Schleuder.log.debug "Processing #{ptype}-plugins"
451
- self.keywords.each do |kwhash|
452
- keyword = kwhash.keys.first
453
- value = kwhash.values.first
454
- Schleuder.log.debug "Looping for keyword #{keyword}"
455
- plugins[ptype].each do |plugin|
456
- command = keyword.downcase.gsub(/-/, '_')
457
- Schleuder.log.debug "Does #{plugin.class}.respond_to? '#{command}'"
458
- if plugin.respond_to?(command)
459
- Schleuder.log.debug "Yes it does, executing #{plugin.class}.#{command}"
460
- used_keywords << keyword
461
- foo = plugin.send(command, mail, value)
462
- case foo
463
- when String
464
- Schleuder.log.debug "Method returned a string, saving it for reply to sender."
465
- replymsg << foo << "\n\n\n"
466
- when Mail
467
- Schleuder.log.debug "Method returned a Mail-object, replacing myself by it."
468
- mail = foo
469
- else
470
- Schleuder.log.debug "Method returned '#{foo.inspect}', don't know how to handle, skipping."
471
- end
472
- next
473
- else
474
- Schleuder.log.debug "No it doesn't."
475
- end
476
- end
477
- # Generate error-message if no plugin was executed
478
- unless used_keywords.include?(keyword)
479
- msg = "No #{ptype}-plugin responded to keyword #{keyword}"
480
- Schleuder.log.debug msg
481
- if mail.request?
482
- replymsg << "Error: #{msg}.\n\n"
483
- else
484
- mail.metadata[:error] << msg
485
- end
486
- end
487
- end
488
- # Decide how to go on: reply or list?
489
- if mail.request?
490
- Schleuder.log.debug "Message is a request, sending plugins-output back to sender."
491
- if replymsg.empty?
492
- replymsg = 'The keywords you sent did not produce any output. If you feel this is an error please contact the adminisrators of this list.'.fmt
493
- end
494
- # This exits.
495
- Plugin.reply(mail, replymsg, used_keywords)
496
- end
497
- else
498
- Schleuder.log.error "#{plugins_dir} does not exist or is not readable!"
499
- end
500
- end
501
- end
502
- end
503
-
504
- def add_prefix!
505
- # only add if it's not already present
506
- unless self.subject.index(Schleuder.list.config.prefix)
507
- Schleuder.log.debug "adding prefix"
508
- self.subject = Schleuder.list.config.prefix + " " + self.subject.to_s
509
- end
510
- end
511
-
512
- def add_prefix_in!
513
- # only add if it's not already present
514
- unless self.subject.index(Schleuder.list.config.prefix_in)
515
- Schleuder.log.debug "adding prefix_in"
516
- self.subject = Schleuder.list.config.prefix_in + " " + self.subject.to_s
517
- end
518
- end
519
-
520
- def add_prefix_out!
521
- # only add if it's not already present
522
- unless self.subject.index(Schleuder.list.config.prefix_out)
523
- Schleuder.log.debug "adding prefix_out"
524
- self.subject = Schleuder.list.config.prefix_out + " " + self.subject.to_s
525
- end
526
- end
527
-
528
- def add_metadata!
529
- Schleuder.log.info "Adding meta-information on old mail to new mail"
530
- Schleuder.log.debug 'Generating meta-information'
531
- meta = ''
532
- Schleuder.list.config.headers_to_meta.each do |h|
533
- h = h.to_s.capitalize
534
- val = self.header_string(h) rescue '(not parsable)'
535
- next if val.nil?
536
- val = TMail::Unquoter.unquote_and_convert_to(val,'utf-8')
537
- meta << "#{h}: #{val}\n"
538
- end
539
- meta << "Enc: #{_enc_str @in_encrypted}\n"
540
- meta << "Sig: #{_sig_str @in_signed}\n"
541
- if self.multipart?
542
- self.parts.each_with_index do |p,i|
543
- unless p.in_encrypted.nil? && p.in_signed.nil?
544
- meta << "part #{i+1}:\n"
545
- meta << " enc: #{_enc_str p.in_encrypted}\n"
546
- meta << " sig: #{_sig_str p.in_signed}\n"
547
- end
548
- end
549
- end
550
-
551
- Schleuder.log.debug 'Adding extra meta data'
552
- @metadata.each do |name, content|
553
- meta << "#{name.to_s.gsub(/_/, '-').capitalize}: #{(content.join('; ') || content.to_s)}\n" unless content.empty?
554
- end
555
- meta << "\n"
556
-
557
- # insert oder prepend to the message
558
- if self.first_part._content_type_stripped == 'text/plain'
559
- Schleuder.log.debug "Glueing meta data into first part of message"
560
- self.first_part.body = meta + self.first_part.body
561
- # body is now utf-8 and decoded!
562
- self.first_part.charset = 'UTF-8'
563
- self.first_part.encoding = ''
564
- else
565
- # make the message multipart and prepend the meta-part
566
- self.to_multipart! unless self.multipart?
567
- Schleuder.log.debug "Prepending meta data as own mime part to message"
568
- self.parts.unshift _new_part(meta, 'text/plain', 'UTF-8')
569
- end
570
-
571
- end
572
-
573
- # Adds Schleuder::ListConfig.public_footer to the end of the body of self
574
- # or the body of the first mimepart (if one of those is text/plain) or
575
- # appends it as a new mimepart.
576
- def add_public_footer!
577
- if Schleuder.list.config.public_footer.strip.empty?
578
- return false
579
- end
580
- Schleuder.log.debug "appending public footer"
581
- footer = "\n\n-- \n#{Schleuder.list.config.public_footer}\n"
582
-
583
- if self.first_part._content_type_stripped == 'text/plain'
584
- self.first_part.body = self.first_part.body.to_s + footer
585
- else
586
- self.to_multipart! unless self.multipart?
587
- self.parts.push _new_part(footer, 'text/plain', 'UTF-8')
588
- end
589
- end
590
-
591
-
592
- def to_multipart!
593
- # skip if already multipart
594
- return false if self.multipart?
595
- Schleuder.log.debug "Making message multipart"
596
- # else move the body into a mime-part
597
- p = _new_part(self.body, self['content-type'].to_s, self.charset, self.encoding)
598
- p.disposition = self._disposition
599
- self.parts.push p
600
- self.body = ''
601
- self.set_content_type 'multipart', 'mixed', {:boundary => TMail.new_boundary}
602
- self.disposition = ''
603
- end
604
-
605
- def first_part
606
- if self.multipart?
607
- self.parts.first
608
- else
609
- self
610
- end
611
- end
612
-
613
- def _content_type(default = 'text/plain')
614
- (self['content-type'] || default).to_s rescue default
615
- end
616
-
617
- def _content_type_stripped(default = nil)
618
- ( default && _content_type(default) || _content_type() ).to_s.split(';').first
619
- end
620
-
621
- def _disposition
622
- (self['content-disposition'] || '').to_s rescue ''
623
- end
624
-
625
- def from_member_address?
626
- from.all? { |a| Schleuder.list.find_member_by_address(a) }
627
- end
628
-
629
- def from_admin_address?
630
- from.all? { |a| Schleuder.list.find_admin_by_address(a) }
631
- end
632
-
633
- # An instance of Schleuder::Crypt
634
- attr_writer :crypt
635
- def crypt
636
- @crypt ||= Crypt.new(Schleuder.list.config.gpg_password)
637
- end
638
-
639
- private
640
-
641
- def _decrypt_pgp_inline(msg)
642
- if msg._content_type_stripped == 'text/html'
643
- Schleuder.log.debug "Content-type is text/html, can't handle that, skipping"
644
- elsif msg.body =~ /^-----BEGIN PGP.*/ || ['pgp', 'gpg'].include?(msg.disposition_param('filename').to_s.split('.').last.to_s.downcase)
645
- Schleuder.log.debug 'found pgp-inline in input'
646
- # We need to do this in three steps:
647
- # 1. get the decoded body from TMail
648
- # 2. delete the encoding-header
649
- # 3. put the plaintext back into TMail
650
- # Else TMail either can't decode the body correctly or 'over-decodes' it
651
- # on next output
652
- # TMail decodes QP if body() is called, Umlauts if to_s() is called.
653
- # Life with TMail is hard...
654
- if msg.encoding.to_s.downcase.eql?('quoted-printable') || !msg.main_type.eql?('text')
655
- str = msg.body
656
- else
657
- str = msg.to_s
658
- end
659
- ptxt, enc, sig = crypt.decrypt(str)
660
- if ptxt == str
661
- Schleuder.log.debug "Output equals input, content was obviously not suitable for gpg. Passing it untouched."
662
- else
663
- #Schleuder.log.debug "ptxt.mime_encoding: #{FileMagic.fm(:mime_encoding).buffer(ptxt).inspect}"
664
- Schleuder.log.debug "Processing plaincontent: #{ptxt.mime}"
665
- msg.in_encrypted = enc
666
- msg.in_signed = sig
667
- if ! msg.disposition_param('filename').nil?
668
- msg['content-disposition'].params['filename'] = msg.disposition_param('filename').gsub(/\.(gpg|pgp|asc)$/, '')
669
- end
670
- if ptxt.content_type.split('/').first == 'text'
671
- msg['content-type'] = ptxt.mime
672
- msg.encoding = nil
673
- msg.body = ptxt
674
- else
675
- # TMail handles binary data in an "interesting" way, so we manuall encode before handing Tmail the data.
676
- msg['content-type'] = ptxt.content_type
677
- msg.encoding = 'Base64'
678
- msg.body = [ptxt].pack('m')
679
- end
680
- end
681
- msg
682
- else
683
- Schleuder.log.debug 'no pgp-inline-data found, doing nothing'
684
- end
685
- end
686
-
687
- def _encrypt_pgp_inline(msg, receiver)
688
- msg.body = crypt.encrypt_str(msg.body.to_s, receiver)
689
- # reset encoding, TMail has converted the content
690
- msg.encoding = nil
691
- # if attachment add '.gpg'-suffix to attachments file names
692
- # (The query for 'filename' is a work around against buggy client
693
- # formatting that doesn't disposition attachments as attachments. This
694
- # variant works ok.)
695
- unless msg.disposition_param('filename').nil?
696
- msg.set_content_type('application', 'octet-stream', {'x-action' => 'pgp-encrypted'})
697
- msg.set_disposition('attachment', {:filename => msg.disposition_param('filename') + '.gpg'})
698
- else
699
- msg.set_content_type('text', 'plain', {'x-action' => 'pgp-encrypted'})
700
- msg.charset = 'UTF-8'
701
- end
702
- msg
703
- end
704
-
705
- def _enc_str(arg)
706
- arg ? 'encrypted' : 'unencrypted'
707
- end
708
-
709
- def _sig_str(arg)
710
- # gpgme breaks if we use arg.to_s.empty? here.
711
- if arg.nil? || (arg.is_a?(String) && arg.empty?)
712
- 'No signature'
713
- else
714
- if crypt.get_key(arg.fpr).first
715
- arg.to_s
716
- else
717
- #Schleuder.log.debug arg.inspect
718
- "Unknown signature from #{arg.fpr} (public key not present)"
719
- end
720
- end
721
- end
722
-
723
- # Tests if the signature of the incoming mail (if any) belongs to a
724
- # list-member or an admin by testing all gpg-key-ids of the signing
725
- # key for matches
726
- def _parse_signature
727
- @from_admin = @from_member = false
728
-
729
- Schleuder.log.debug 'Testing for valid signature'
730
- if !in_signed
731
- Schleuder.log.info 'No signature found'
732
- elsif in_signed.status != 0
733
- Schleuder.log.info 'Invalid or unknown signature found'
734
- else
735
- Schleuder.log.info 'Valid signature found'
736
- key, msg = crypt.get_key(in_signed.fpr)
737
- if key
738
- Schleuder.log.debug "Testing key for matching some member's or admin's keys"
739
- if Schleuder.list.find_admin_by_key(key)
740
- @from_admin = true
741
- end
742
- if Schleuder.list.find_member_by_key(key)
743
- @from_member = true
744
- end
745
- else
746
- Schleuder.log.debug "Keylookup for signature failed! Reason: #{msg}"
747
- end
748
- end
749
- end
750
-
751
- # Convert the given text into quoted printable format, with an instruction
752
- # that the text be eventually interpreted in the given charset.
753
- def _quoted_printable(text, charset)
754
- text = text.gsub( /[^a-z ]/i ) { _quoted_printable_encode($&) }.
755
- gsub( / /, "_" )
756
- "=?#{charset}?Q?#{text}?="
757
- end
758
-
759
- # Convert the given character to quoted printable format, taking into
760
- # account multi-byte characters (if executing with $KCODE="u", for instance)
761
- def _quoted_printable_encode(character)
762
- result = ""
763
- character.each_byte { |b| result << "=%02X" % b }
764
- result
765
- end
766
-
767
- # A quick-and-dirty regexp for determining whether a string contains any
768
- # characters that need escaping.
769
- if !defined?(CHARS_NEEDING_QUOTING)
770
- CHARS_NEEDING_QUOTING = /[\000-\011\013\014\016-\037\177-\377]/
771
- end
772
-
773
- # Quote the given text if it contains any "illegal" characters
774
- def _quote_if_necessary(text, charset)
775
- text = text.dup.force_encoding(Encoding::ASCII_8BIT) if text.respond_to?(:force_encoding)
776
-
777
- (text =~ CHARS_NEEDING_QUOTING) ?
778
- _quoted_printable(text, charset) :
779
- text
780
- end
781
-
782
- # Helper for creating new message-parts
783
- def _new_part(body, content_type, charset='', encoding='')
784
- p = Mail.new
785
- p.body = body.to_s
786
- p.content_type = content_type
787
- p.charset = charset.to_s unless charset.to_s.empty?
788
- p.encoding = encoding.to_s unless encoding.to_s.empty?
789
- p
790
- end
791
-
792
- def _collect_message_ids(ids)
793
- return nil if ids.nil? || !ids.is_a?(Array) || ids.empty?
794
- ids.select{ |id| Utils.schleuder_id?(id, Schleuder.list.listid) }
795
- end
796
-
797
- def self._schleuder_message_id
798
- @@schleuder_message_id = Utils.generate_message_id(Schleuder.list.listid) unless @@schleuder_message_id
799
- @@schleuder_message_id
800
- end
801
-
802
- def _message_ids(mail)
803
- Schleuder.log.debug "Copying msgid to in-reply-to/references"
804
- mail.in_reply_to = _collect_message_ids(self.in_reply_to)
805
- mail.references = _collect_message_ids(self.references)
806
- mail
807
- end
808
-
809
- def _list_headers(mail)
810
- Schleuder.log.debug "Generating list-ids"
811
- mail['List-Id'] = "<#{Schleuder.list.listid}>"
812
- mail['List-Owner'] = _list_owner
813
- mail['List-Post'] = _list_post
814
- # TODO: adapt URL to version.
815
- mail['List-Help'] = '<https://schleuder2.nadir.org/documentation.html>'
816
- mail
817
- end
818
-
819
- def _list_owner
820
- "<mailto:#{Schleuder.list.owner_addr}> (Use list's public key)"
821
- end
822
-
823
- def _list_post
824
- if Schleuder.list.config.receive_admin_only
825
- "NO (Admins only)"
826
- elsif Schleuder.list.config.receive_authenticated_only
827
- "<mailto:#{Schleuder.list.config.myaddr}> (Subscribers only)"
828
- else
829
- "<mailto:#{Schleuder.list.config.myaddr}>"
830
- end
831
- end
832
-
833
- def _add_openpgp_header(mail)
834
- Schleuder.log.debug "Add OpenPGP-Headers"
835
- mail['OpenPGP'] = "id=#{Schleuder.list.key_fingerprint} "+
836
- "(Send an email to #{Schleuder.list.sendkey_addr} to receive the public-key)"+
837
- _gen_openpgp_pref_header
838
- mail
839
- end
840
-
841
- def _gen_openpgp_pref_header
842
- unless Schleuder.list.config.openpgp_header_preference == 'none'
843
- pref_str = "; preference=#{Schleuder.list.config.openpgp_header_preference} ("
844
- if Schleuder.list.config.receive_admin_only
845
- pref_str << 'Only encrypted and signed emails by list-admins are accepted'
846
- elsif !Schleuder.list.config.receive_authenticated_only
847
- if Schleuder.list.config.receive_encrypted_only \
848
- && Schleuder.list.config.receive_signed_only
849
- pref_str << 'Only encrypted and signed emails are accepted'
850
- elsif Schleuder.list.config.receive_encrypted_only \
851
- && !Schleuder.list.config.receive_signed_only
852
- pref_str << 'Only encrypted emails are accepted'
853
- elsif !Schleuder.list.config.receive_encrypted_only \
854
- && Schleuder.list.config.receive_signed_only
855
- pref_str << 'Only signed emails are accepted'
856
- else
857
- pref_str << 'All kind of emails are accepted'
858
- end
859
- elsif Schleuder.list.config.receive_authenticated_only
860
- if Schleuder.list.config.receive_encrypted_only
861
- pref_str << 'Only encrypted and signed emails by list-members are accepted'
862
- else
863
- pref_str << 'Only signed emails by list-members are accepted'
864
- end
865
- else
866
- pref_str << 'All kind of emails are accepted'
867
- end
868
- pref_str << ')'
869
- end
870
- pref_str || ''
871
- end
872
- end
873
- end