class Mail::Message
TODO: Test if subclassing breaks integration of mail-gpg.
Attributes
Public Class Methods
# File lib/schleuder/mail/message.rb, line 192 def self.all_to_message_part(input) Array(input).map do |thing| case thing when Mail::Part thing when String, StandardError Mail::Part.new do body thing.to_s end else raise "Don't know how to handle input: #{thing.inspect}" end end end
Public Instance Methods
# File lib/schleuder/mail/message.rb, line 347 def add_list_headers(list) if list.include_autocrypt_header # Inject whitespaces, to let Mail break the string at these points # leading to correct wrapping. keydata = list.key_minimal_base64_encoded.gsub(/(.{78})/, '\1 ') self['Autocrypt'] = "addr=#{list.email}; prefer-encrypt=mutual; keydata=#{keydata}" end if list.include_list_headers self['List-Id'] = "<#{list.email.gsub('@', '.')}>" self['List-Owner'] = "<mailto:#{list.owner_address}> (Use list's public key)" self['List-Help'] = '<https://schleuder.org/>' postmsg = if list.receive_admin_only 'NO (Admins only)' elsif list.receive_authenticated_only "<mailto:#{list.email}> (Subscribers only)" else "<mailto:#{list.email}>" end self['List-Post'] = postmsg end end
# File lib/schleuder/mail/message.rb, line 336 def add_msgids(list, orig) if list.keep_msgid # Don't use `orig['in-reply-to']` here, because that sometimes fails to # parse the original value and then returns it without the # angle-brackets. self.message_id = clutch_anglebrackets(orig.message_id) self.in_reply_to = clutch_anglebrackets(orig.in_reply_to) self.references = clutch_anglebrackets(orig.references) end end
# File lib/schleuder/mail/message.rb, line 373 def add_openpgp_headers(list) if list.include_openpgp_header if list.openpgp_header_preference == 'none' pref = '' else pref = "preference=#{list.openpgp_header_preference}" # TODO: simplify. pref << ' (' if list.receive_admin_only pref << 'Only encrypted and signed emails by list-admins are accepted' elsif ! list.receive_authenticated_only if list.receive_encrypted_only && list.receive_signed_only pref << 'Only encrypted and signed emails are accepted' elsif list.receive_encrypted_only && ! list.receive_signed_only pref << 'Only encrypted emails are accepted' elsif ! list.receive_encrypted_only && list.receive_signed_only pref << 'Only signed emails are accepted' else pref << 'All kind of emails are accepted' end elsif list.receive_authenticated_only if list.receive_encrypted_only pref << 'Only encrypted and signed emails by subscribers are accepted' else pref << 'Only signed emails by subscribers are accepted' end else pref << 'All kind of emails are accepted' end pref << ')' end fingerprint = list.fingerprint comment = "(Send an email to #{list.sendkey_address} to receive the public-key)" self['OpenPGP'] = "id=0x#{fingerprint} #{comment}; #{pref}" end end
# File lib/schleuder/mail/message.rb, line 270 def add_pseudoheader(string_or_key, value=nil) dynamic_pseudoheaders << make_pseudoheader(string_or_key, value) end
# File lib/schleuder/mail/message.rb, line 258 def add_subject_prefix! _add_subject_prefix(nil) end
# File lib/schleuder/mail/message.rb, line 262 def add_subject_prefix_in! _add_subject_prefix(:in) end
# File lib/schleuder/mail/message.rb, line 266 def add_subject_prefix_out! _add_subject_prefix(:out) end
# File lib/schleuder/mail/message.rb, line 439 def attach_list_key!(list) filename = "#{list.email}.asc" self.add_file({ filename: filename, content: list.export_key }) self.attachments[filename].content_type = 'application/pgp-keys' self.attachments[filename].content_description = 'OpenPGP public key' true end
# File lib/schleuder/mail/message.rb, line 219 def automated_message? @recipient.match(/-bounce@/).present? || # Empty Return-Path self.return_path.to_s == '<>' || bounced? end
# File lib/schleuder/mail/message.rb, line 226 def bounced? @bounced ||= bounce_detected? || (error_status != 'unknown') end
# File lib/schleuder/mail/message.rb, line 73 def clean_copy(with_pseudoheaders=false) clean = Mail.new clean.list = self.list clean.gpg self.list.gpg_sign_options clean.from = list.email clean.subject = self.subject clean.protected_headers_subject = self.protected_headers_subject clean.add_msgids(list, self) clean.add_list_headers(list) clean.add_openpgp_headers(list) if with_pseudoheaders new_part = Mail::Part.new new_part.body = self.pseudoheaders(list) clean.add_part new_part end if self.protected_headers_subject.present? new_part = Mail::Part.new new_part.content_type = 'text/rfc822-headers; protected-headers=v1' new_part.body = "Subject: #{self.subject}\n" clean.add_part new_part end # Attach body or mime-parts in a new wrapper-part, to preserve the # original mime-structure. # We can't use self.to_s here — that includes all the headers we *don't* # want to copy. wrapper_part = Mail::Part.new # Copy headers to are relevant for the mime-structure. wrapper_part.content_type = self.content_type wrapper_part.content_transfer_encoding = self.content_transfer_encoding if self.content_transfer_encoding wrapper_part.content_disposition = self.content_disposition if self.content_disposition wrapper_part.content_description = self.content_description if self.content_description # Copy contents. if self.multipart? self.parts.each do |part| wrapper_part.add_part(part) end else # We copied the content-headers, so we need to copy the body encoded. # Otherwise the content might become unlegible. wrapper_part.body = self.body.encoded end clean.add_part(wrapper_part) clean end
returns the decrypted mail object.
pass verify: true to verify signatures as well. The gpgme verification result will be available via decrypted_mail.verify_result
# File lib/schleuder/mail/message.rb, line 499 def decrypt(options = {}) Mail::Gpg.decrypt(self, options) end
# File lib/schleuder/mail/message.rb, line 280 def dynamic_pseudoheaders @dynamic_pseudoheaders ||= [] end
# File lib/schleuder/mail/message.rb, line 414 def empty? if self.multipart? if self.parts.empty? return true else # Test parts recursively. E.g. Thunderbird with activated # memoryhole-headers send nested parts that might still be empty. return parts.inject(true) { |result, part| result && part.empty? } end else return self.body.empty? end end
true if this mail is encrypted
# File lib/schleuder/mail/message.rb, line 491 def encrypted? Mail::Gpg.encrypted?(self) end
# File lib/schleuder/mail/message.rb, line 302 def encryption_state if was_encrypted? encryption_state = I18n.t('encryption_states.encrypted') else encryption_state = I18n.t('encryption_states.unencrypted') end encryption_state end
# File lib/schleuder/mail/message.rb, line 230 def error_status @error_status ||= detect_error_code end
# File lib/schleuder/mail/message.rb, line 428 def first_plaintext_part(part=nil) part ||= self if part.multipart? first_plaintext_part(part.parts.first) elsif part.mime_type == 'text/plain' part else nil end end
turn on gpg encryption / set gpg options.
options are:
encrypt: encrypt the message. defaults to true sign: also sign the message. false by default sign_as: UIDs to sign the message with
See Mail::Gpg
methods encrypt and sign for more possible options
mail.gpg encrypt: true mail.gpg encrypt: true, sign: true mail.gpg encrypt: true, sign_as: “other_address@host.com”
sign-only mode is also supported: mail.gpg sign: true mail.gpg sign_as: ‘jane@doe.com’
To turn off gpg encryption use: mail.gpg false
# File lib/schleuder/mail/message.rb, line 472 def gpg(options = nil) case options when nil @gpg when false @gpg = nil if Mail::Gpg::DeliveryHandler == delivery_handler self.delivery_handler = nil end nil else self.raise_encryption_errors = true if raise_encryption_errors.nil? @gpg = options self.delivery_handler ||= Mail::Gpg::DeliveryHandler nil end end
# File lib/schleuder/mail/message.rb, line 234 def keywords return @keywords if @keywords part = first_plaintext_part if part.blank? return [] end @keywords, lines = extract_keywords(part.decoded.lines) new_body = lines.join # Work around problems with re-encoding the body. If we delete the # content-transfer-encoding prior to re-assigning the body, and let Mail # decide itself how to encode, it works. If we don't, some # character-sequences are not properly re-encoded. part.content_transfer_encoding = nil # Set the right charset on the now parsed body part.charset = new_body.encoding.to_s part.body = new_body @keywords end
# File lib/schleuder/mail/message.rb, line 274 def make_pseudoheader(key, value) output = "#{key.to_s.camelize}: #{value.to_s}" # wrap lines after 76 with 2 indents output.gsub(/(.{1,76})( +|$)\n?/, " \\1\n").chomp.lstrip end
# File lib/schleuder/mail/message.rb, line 123 def prepend_part(part) self.add_part(part) self.parts.unshift(parts.delete_at(parts.size-1)) end
# File lib/schleuder/mail/message.rb, line 331 def pseudoheaders(list) separator = '------------------------------------------------------------------------------' (standard_pseudoheaders(list) + dynamic_pseudoheaders).flatten.join("\n") + "\n" + separator + "\n" end
# File lib/schleuder/mail/message.rb, line 521 def repeat_validation! new = self.original_message.dup.setup self.verify_result = new.verify_result @signatures = new.signatures dynamic_pseudoheaders << new.dynamic_pseudoheaders end
# File lib/schleuder/mail/message.rb, line 184 def reply_to_signer(output) reply = self.reply self.class.all_to_message_part(output).each do |part| reply.add_part(part) end self.signer.send_mail(reply) end
# File lib/schleuder/mail/message.rb, line 215 def request? @recipient.match(/-request@/) end
# File lib/schleuder/mail/message.rb, line 207 def sendkey_request? @recipient.match(/-sendkey@/) end
TODO: This should be in initialize(), but I couldn’t understand the strange errors about wrong number of arguments when overriding Message#initialize.
# File lib/schleuder/mail/message.rb, line 31 def setup if self.encrypted? # Specify 'loopback'-pinentry-mode to ensure that gnupg never-ever # tries to interactively ask for a passphrase. new = self.decrypt(verify: true, pinentry_mode: GPGME::PINENTRY_MODE_LOOPBACK) # Test if there's a signed multipart inside the ciphertext # ("encapsulated" format of pgp/mime). if encapsulated_signed?(new) new = new.verify end elsif self.signed? new = self.verify else new = self end new.list = self.list new.recipient = self.recipient new.gpg list.gpg_sign_options new.original_message = self.dup.freeze # Trigger method early to save the information. Later some information # might be gone (e.g. request-keywords that delete subscriptions or # keys). new.signer new.dynamic_pseudoheaders = self.dynamic_pseudoheaders.dup # Store previously protected subject for later access. # mail-gpg pulls headers from the decrypted mime parts "up" into the main # headers, which reveals protected subjects. if self.subject != new.subject new.protected_headers_subject = self.subject.dup end # Delete the protected headers which might leak information. if new.parts.first && new.parts.first.content_type == 'text/rfc822-headers; protected-headers=v1' new.parts.shift end new end
# File lib/schleuder/mail/message.rb, line 141 def signature case signatures.size when 0 if multipart? signature_multipart_inline else nil end when 1 signatures.first else raise 'Multiple signatures found! Cannot handle!' end end
# File lib/schleuder/mail/message.rb, line 284 def signature_state # Careful to add information about the incoming signature. GPGME # throws exceptions if it doesn't know the key. if self.signature.present? # Some versions of gpgme return nil if the key is unknown, so we check # for that manually and provide our own fallback. (Calling # `signature.key` results in an EOFError in that case.) if signing_key.present? signature_state = signature.to_s else signature_state = I18n.t('signature_states.unknown', fingerprint: self.signature.fingerprint) end else signature_state = I18n.t('signature_states.unsigned') end signature_state end
true if this mail is signed (but not encrypted)
# File lib/schleuder/mail/message.rb, line 504 def signed? Mail::Gpg.signed?(self) end
# File lib/schleuder/mail/message.rb, line 160 def signer @signer ||= begin if signing_key.present? # Look for a subscription that matches the sending address, in case # there're multiple subscriptions for the same key. As a fallback use # the first subscription found. sender_email = self.from.to_s.downcase subscriptions = list.subscriptions.where(fingerprint: signing_key.fingerprint) subscriptions.where(email: sender_email).first || subscriptions.first end end end
The fingerprint of the signature might be the one of a sub-key, but the subscription-assigned fingerprints are (should be) the ones of the primary keys, so we need to look up the key.
# File lib/schleuder/mail/message.rb, line 176 def signing_key @signing_key ||= begin if signature.present? list.keys(signature.fpr).first end end end
# File lib/schleuder/mail/message.rb, line 311 def standard_pseudoheaders(list) if @standard_pseudoheaders.present? return @standard_pseudoheaders else @standard_pseudoheaders = [] end Array(list.headers_to_meta).each do |field| value = case field.to_s when 'sig' then signature_state when 'enc' then encryption_state else self.header[field.to_s] end @standard_pseudoheaders << make_pseudoheader(field.to_s, value) end @standard_pseudoheaders end
# File lib/schleuder/mail/message.rb, line 211 def to_owner? @recipient.match(/-owner@/) end
verify signatures. returns a new mail object with signatures removed and populated verify_result.
verified = signed_mail.verify() verified.signature_valid? signers = mail.signatures.map{|sig| sig.from}
use import_missing_keys: true in order to try to fetch and import unknown keys for signature validation
# File lib/schleuder/mail/message.rb, line 517 def verify(options = {}) Mail::Gpg.verify(self, options) end
# File lib/schleuder/mail/message.rb, line 137 def was_encrypted? Mail::Gpg.encrypted?(original_message) end
# File lib/schleuder/mail/message.rb, line 156 def was_validly_signed? signature.present? && signature.valid? && signer.present? end
Private Instance Methods
# File lib/schleuder/mail/message.rb, line 587 def _add_subject_prefix(suffix) attrib = 'subject_prefix' if suffix attrib << "_#{suffix}" end if ! self.list.respond_to?(attrib) return false end string = self.list.send(attrib).to_s.strip if ! string.empty? prefix = "#{string} " # Only insert prefix if it's not present already. if self.subject.nil? self.subject = string elsif ! self.subject.include?(prefix) self.subject = "#{prefix}#{self.subject}" end end end
# File lib/schleuder/mail/message.rb, line 665 def bounce_detected? # Detects bounces from different parts of the email without error status codes # from: https://github.com/mailtop/bounce_email 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) return true if self.subject.to_s.match(/auto.*reply|vacation|vocation|(out|away).*office|on holiday|abwesenheits|autorespond|Automatische|eingangsbestätigung/i) return true if self['precedence'].to_s.match(/auto.*(reply|responder|antwort)/i) return true if self.from.to_s.match(/^(MAILER-DAEMON|POSTMASTER)@/i) false end
# File lib/schleuder/mail/message.rb, line 625 def clutch_anglebrackets(input) Array(input).map do |string| if string.first == '<' string else "<#{string}>" end end.join(' ') end
# File lib/schleuder/mail/message.rb, line 675 def detect_bounce_status_code_from_text(text) # Parses a text and uses pattern matching to determines its error status (RFC 3463) # from: https://github.com/mailtop/bounce_email return '5.0.0' if text.match(/Status: 5\.0\.0/i) 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) 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) 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) return '5.2.2' if text.match(/This is a permanent error||(Status: |)5\.2\.2/i) return '4.2.2' end return '5.1.0' if text.match(/Address rejected/) return '4.1.2' if text.match(/I couldn't find any host by that name/) return '4.2.0' if text.match(/not yet been delivered/i) return '5.1.1' if text.match(/mailbox unavailable|No such mailbox|RecipientNotFound|not found by SMTP address lookup|Status: 5\.1\.1/i) return '5.2.3' if text.match(/Status: 5\.2\.3/i) # Too messages in folder return '5.4.0' if text.match(/Status: 5\.4\.0/i) # too many hops return '5.4.4' if text.match(/Unrouteable address/i) return '4.4.7' if text.match(/retry timeout exceeded/i) 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) return '5.5.4' if text.match(/554 TRANSACTION FAILED/i) return '4.4.1' if text.match(/Status: 4.4.1|delivery temporarily suspended|wasn't able to establish an SMTP connection/i) return '5.5.0' if text.match(/550 OU-002|Mail rejected by Windows Live Hotmail for policy reasons/i) return '5.1.2' if text.match(/PERM_FAILURE: DNS Error: Domain name not found/i) return '4.2.0' if text.match(/Delivery attempts will continue to be made for/i) return '5.5.4' if text.match(/554 delivery error:/i) return '5.1.1' if text.match(/550-5.1.1|This Gmail user does not exist/i) return '5.7.1' if text.match(/5.7.1 Your message.*?was blocked by ROTA DNSBL/i) # AA added return '5.7.2' if text.match(/not have permission to post messages to the group/i) 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 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 return '5.0.0' if text.match(/Delivery to the following recipient failed permanently/i) # AA added 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) end
# File lib/schleuder/mail/message.rb, line 635 def detect_error_code # Detects the error code of an email with different heuristics # from: https://github.com/mailtop/bounce_email # Custom status codes unicode_subject = self.subject.to_s unicode_subject = unicode_subject.encode('utf-8') if unicode_subject.respond_to?(:encode) return '97' if unicode_subject.match(/delayed/i) return '98' if unicode_subject.match(/(unzulässiger|unerlaubter) anhang/i) 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) # Feedback-Type: abuse return '96' if self.to_s.match(/Feedback-Type: abuse/i) if self.parts[1] match_parts = self.parts[1].body.match(/(Status:.|550 |#)([245]\.[0-9]{1,3}\.[0-9]{1,3})/) code = match_parts[2] if match_parts return code if code end # Now try getting it from correct part of tmail code = detect_bounce_status_code_from_text(self.body) return code if code # OK getting desperate so try getting code from entire email code = detect_bounce_status_code_from_text(self.to_s) code || 'unknown' end
mail.signed? throws an error if it finds pgp boundaries, so we must use the Mail::Gpg
methods.
# File lib/schleuder/mail/message.rb, line 562 def encapsulated_signed?(mail) (mail.verify_result.nil? || mail.verify_result.signatures.empty?) && \ (Mail::Gpg.signed_mime?(mail) || Mail::Gpg.signed_inline?(mail)) end
# File lib/schleuder/mail/message.rb, line 531 def extract_keywords(content_lines) keywords = [] in_keyword_block = false content_lines.each_with_index do |line, i| if line.blank? # Swallow the line: before the actual content or keywords block begins we want to drop blank lines. content_lines[i] = nil # Stop interpreting the following line as argument to the previous keyword. in_keyword_block = false elsif match = line.match(/^x-([^:\s]*)[:\s]*(.*)/i) keyword = match[1].strip.downcase arguments = match[2].to_s.strip.downcase.split(/[,; ]{1,}/) keywords << [keyword, arguments] in_keyword_block = true # Set this line to nil to have it stripped from the message. content_lines[i] = nil elsif in_keyword_block == true # Interpret line as arguments to the previous keyword. keywords[-1][-1] += line.downcase.strip.split(/[,; ]{1,}/) content_lines[i] = nil else # Any other line stops the keyword parsing. break end end [keywords, content_lines.compact] end
Looking for signatures in each part. They are not aggregated into the main part. We only return the signature if all parts are validly signed by the same key.
# File lib/schleuder/mail/message.rb, line 610 def signature_multipart_inline fingerprints = parts.map do |part| if part.signature_valid? part.signature.fpr else nil end end if fingerprints.uniq.size == 1 parts.first.signature else nil end end
# File lib/schleuder/mail/message.rb, line 580 def wrapped_single_text_part? parts.size == 1 && parts.first.mime_type == 'multipart/mixed' && parts.first.parts.size == 1 && parts.first.parts.first.mime_type == 'text/plain' end