Add handling for message reply via email

If the reply_email_domain configuration is set the messages plugin will
use unique Reply-To addresses for every email. They contain enough
information to reconstruct the message context and a hash to avoid
user forgery and spam.
A mail transfer agent must be configured to call the new rake task
foodsoft:parse_reply_email for incoming mails. The rake task requires
the receipt of the mail in the RECIPIENT variable and the raw message
via standard input. An example invocation would look like:
rake foodsoft:parse_reply_email RECIPIENT=f.1.1.HASH < test.eml
This commit is contained in:
Patrick Gansterer 2016-02-26 14:42:16 +01:00 committed by wvengen
parent 6b32d0c960
commit 4e35e2d58e
11 changed files with 135 additions and 11 deletions

View file

@ -15,7 +15,9 @@ PATH
remote: plugins/messages remote: plugins/messages
specs: specs:
foodsoft_messages (0.0.1) foodsoft_messages (0.0.1)
base32
deface (~> 1.0.0) deface (~> 1.0.0)
mail
rails rails
PATH PATH
@ -71,6 +73,7 @@ GEM
addressable (2.4.0) addressable (2.4.0)
arel (6.0.3) arel (6.0.3)
attribute_normalizer (1.2.0) attribute_normalizer (1.2.0)
base32 (0.3.2)
better_errors (2.1.1) better_errors (2.1.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
erubis (>= 2.6.6) erubis (>= 2.6.6)
@ -534,3 +537,6 @@ DEPENDENCIES
uglifier (>= 1.0.3) uglifier (>= 1.0.3)
web-console (~> 2.0) web-console (~> 2.0)
whenever whenever
BUNDLED WITH
1.10.6

View file

@ -116,6 +116,9 @@ default: &defaults
# email address to be used as sender # email address to be used as sender
email_sender: foodsoft@foodcoop.test email_sender: foodsoft@foodcoop.test
# domain to be used for reply emails
#reply_email_domain: reply.foodcoop.test
# If your foodcoop uses a mailing list instead of internal messaging system # If your foodcoop uses a mailing list instead of internal messaging system
#mailing_list: list@example.org #mailing_list: list@example.org
#mailing_list_subscribe: list-subscribe@example.org #mailing_list_subscribe: list-subscribe@example.org

View file

@ -0,0 +1,7 @@
# This migration comes from foodsoft_messages_engine (originally 20160226000000)
class AddEmailToMessage < ActiveRecord::Migration
def change
add_column :messages, :salt, :string
add_column :messages, :received_email, :binary, :limit => 1.megabyte
end
end

View file

@ -188,6 +188,8 @@ ActiveRecord::Schema.define(version: 20160217194036) do
t.datetime "created_at" t.datetime "created_at"
t.integer "reply_to", limit: 4 t.integer "reply_to", limit: 4
t.integer "group_id", limit: 4 t.integer "group_id", limit: 4
t.string "salt"
t.binary "received_email"
end end
create_table "order_articles", force: :cascade do |t| create_table "order_articles", force: :cascade do |t|

View file

@ -80,6 +80,10 @@ class FoodsoftConfig
setup_database setup_database
end end
def select_multifoodcoop(foodcoop)
select_foodcoop foodcoop if config[:multi_coop_install]
end
# Return configuration value for the currently selected foodcoop. # Return configuration value for the currently selected foodcoop.
# #
# First tries to read configuration from the database (cached), # First tries to read configuration from the database (cached),
@ -134,15 +138,20 @@ class FoodsoftConfig
keys.map(&:to_s).uniq keys.map(&:to_s).uniq
end end
# @return [Array<String>] Valid names of foodcoops.
def foodcoops
if config[:multi_coop_install]
APP_CONFIG.keys.reject { |coop| coop =~ /^(default|development|test|production)$/ }
else
config[:default_scope]
end
end
# Loop through each foodcoop and executes the given block after setup config and database # Loop through each foodcoop and executes the given block after setup config and database
def each_coop def each_coop
if config[:multi_coop_install] foodcoops.each do |coop|
APP_CONFIG.keys.reject { |coop| coop =~ /^(default|development|test|production)$/ }.each do |coop| select_multifoodcoop coop
select_foodcoop coop yield coop
yield coop
end
else
yield config[:default_scope]
end end
end end

View file

@ -14,5 +14,9 @@ gem 'foodsoft_messages', path: 'lib/foodsoft_messages'
This plugin introduces the foodcoop config option `use_messages`, which can be This plugin introduces the foodcoop config option `use_messages`, which can be
set to `false` to disable messages. May be useful in multicoop deployments. set to `false` to disable messages. May be useful in multicoop deployments.
To allow members to respond to messages via email, see the config option
`reply_email_domain` and the rake task `foodsoft:parse_reply_email`. We need to
add some documentation on setting it up, though.
This plugin is part of the foodsoft package and uses the GPL-3 license (see This plugin is part of the foodsoft package and uses the GPL-3 license (see
foodsoft's LICENSE for the full license text). foodsoft's LICENSE for the full license text).

View file

@ -4,9 +4,17 @@ class MessagesMailer < Mailer
set_foodcoop_scope set_foodcoop_scope
@message = message @message = message
reply_email_domain = FoodsoftConfig[:reply_email_domain]
if reply_email_domain
hash = message.mail_hash_for_user recipient
reply_to = "Foodsoft <#{FoodsoftConfig.scope}.#{message.id}.#{recipient.id}.#{hash}@#{reply_email_domain}>"
else
reply_to = "#{show_user(message.sender)} <#{message.sender.email}>"
end
mail subject: "[#{FoodsoftConfig[:name]}] " + message.subject, mail subject: "[#{FoodsoftConfig[:name]}] " + message.subject,
to: recipient.email, to: recipient.email,
from: "#{show_user(message.sender)} via Foodsoft <#{FoodsoftConfig[:email_sender]}>", from: "#{show_user(message.sender)} via Foodsoft <#{FoodsoftConfig[:email_sender]}>",
reply_to: "#{show_user(message.sender)} <#{message.sender.email}>" reply_to: reply_to
end end
end end

View file

@ -1,3 +1,5 @@
require "base32"
class Message < ActiveRecord::Base class Message < ActiveRecord::Base
belongs_to :sender, :class_name => "User", :foreign_key => "sender_id" belongs_to :sender, :class_name => "User", :foreign_key => "sender_id"
belongs_to :group, :class_name => "Group", :foreign_key => "group_id" belongs_to :group, :class_name => "Group", :foreign_key => "group_id"
@ -23,6 +25,7 @@ class Message < ActiveRecord::Base
validates_length_of :subject, :in => 1..255 validates_length_of :subject, :in => 1..255
validates_inclusion_of :email_state, :in => EMAIL_STATE.values validates_inclusion_of :email_state, :in => EMAIL_STATE.values
before_create :create_salt
before_validation :clean_up_recipient_ids, :on => :create before_validation :clean_up_recipient_ids, :on => :create
def self.deliver(message_id) def self.deliver(message_id)
@ -61,8 +64,18 @@ class Message < ActiveRecord::Base
add_recipients([user]) add_recipients([user])
end end
def mail_hash_for_user(user)
digest = Digest::SHA1.new
digest.update self.id.to_s
digest.update ":"
digest.update salt
digest.update ":"
digest.update user.id.to_s
Base32.encode digest.digest
end
# Returns true if this message is a system message, i.e. was sent automatically by Foodsoft itself. # Returns true if this message is a system message, i.e. was sent automatically by Foodsoft itself.
def system_message? def system_message?
self.sender_id.nil? self.sender_id.nil?
end end
@ -94,6 +107,10 @@ class Message < ActiveRecord::Base
def is_readable_for?(user) def is_readable_for?(user)
!private || sender == user || recipients_ids.include?(user.id) !private || sender == user || recipients_ids.include?(user.id)
end end
private
def create_salt
self.salt = [Array.new(6){rand(256).chr}.join].pack("m").chomp
end
end end

View file

@ -0,0 +1,6 @@
class AddEmailToMessage < ActiveRecord::Migration
def change
add_column :messages, :salt, :string
add_column :messages, :received_email, :binary, :limit => 1.megabyte
end
end

View file

@ -17,7 +17,9 @@ Gem::Specification.new do |s|
s.test_files = Dir["test/**/*"] s.test_files = Dir["test/**/*"]
s.add_dependency "rails" s.add_dependency "rails"
s.add_dependency "base32"
s.add_dependency "deface", "~> 1.0.0" s.add_dependency "deface", "~> 1.0.0"
s.add_dependency "mail"
s.add_development_dependency "sqlite3" s.add_development_dependency "sqlite3"
end end

View file

@ -0,0 +1,60 @@
require "mail"
namespace :foodsoft do
desc "Parse incoming email on stdin (options: RECIPIENT=f.1.2.a1b2c3d3e5)"
task :parse_reply_email => :environment do
m = /(?<foodcoop>[^@]*)\.(?<message_id>\d+)\.(?<user_id>\d+)\.(?<hash>\w+)(@(?<hostname>.*))?/.match(ENV['RECIPIENT'])
raise "RECIPIENT is missing or has an invalid format" if m.nil?
raise "Foodcoop '#{m[:foodcoop]}' could not be found" unless FoodsoftConfig.foodcoops.include? m[:foodcoop]
FoodsoftConfig.select_multifoodcoop m[:foodcoop]
original_message = Message.find_by_id(m[:message_id])
user = User.find_by_id(m[:user_id])
raise "Message could not be found" if original_message.nil?
raise "User could not be found" if user.nil?
hash = original_message.mail_hash_for_user user
raise "Hash does not match expectations" unless hash.casecmp(m[:hash]) == 0
received_email = STDIN.read
mail = Mail.new received_email
mail_part = nil
if mail.multipart?
for part in mail.parts
mail_part = part if MIME::Type.simplified(part.content_type) == "text/plain"
end
else
mail_part = mail
end
body = mail_part.body.decoded
unless mail_part.content_type_parameters.nil?
body = body.force_encoding mail_part.content_type_parameters[:charset]
end
message = user.send_messages.new body: body,
group: original_message.group,
private: original_message.private,
received_email: received_email,
subject: mail.subject
if original_message.reply_to
message.reply_to_message = original_message.reply_to_message
else
message.reply_to_message = original_message
end
message.add_recipients original_message.recipients
message.add_recipients [original_message.sender]
message.save!
Resque.enqueue(MessageNotifier, FoodsoftConfig.scope, "message_deliver", message.id)
rake_say "Handled reply email from #{user.display}."
end
end
# Helper
def rake_say(message)
puts message unless Rake.application.options.silent
end