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:
parent
6b32d0c960
commit
4e35e2d58e
11 changed files with 135 additions and 11 deletions
|
@ -15,7 +15,9 @@ PATH
|
|||
remote: plugins/messages
|
||||
specs:
|
||||
foodsoft_messages (0.0.1)
|
||||
base32
|
||||
deface (~> 1.0.0)
|
||||
mail
|
||||
rails
|
||||
|
||||
PATH
|
||||
|
@ -71,6 +73,7 @@ GEM
|
|||
addressable (2.4.0)
|
||||
arel (6.0.3)
|
||||
attribute_normalizer (1.2.0)
|
||||
base32 (0.3.2)
|
||||
better_errors (2.1.1)
|
||||
coderay (>= 1.0.0)
|
||||
erubis (>= 2.6.6)
|
||||
|
@ -534,3 +537,6 @@ DEPENDENCIES
|
|||
uglifier (>= 1.0.3)
|
||||
web-console (~> 2.0)
|
||||
whenever
|
||||
|
||||
BUNDLED WITH
|
||||
1.10.6
|
||||
|
|
|
@ -116,6 +116,9 @@ default: &defaults
|
|||
# email address to be used as sender
|
||||
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
|
||||
#mailing_list: list@example.org
|
||||
#mailing_list_subscribe: list-subscribe@example.org
|
||||
|
|
|
@ -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
|
|
@ -188,6 +188,8 @@ ActiveRecord::Schema.define(version: 20160217194036) do
|
|||
t.datetime "created_at"
|
||||
t.integer "reply_to", limit: 4
|
||||
t.integer "group_id", limit: 4
|
||||
t.string "salt"
|
||||
t.binary "received_email"
|
||||
end
|
||||
|
||||
create_table "order_articles", force: :cascade do |t|
|
||||
|
|
|
@ -80,6 +80,10 @@ class FoodsoftConfig
|
|||
setup_database
|
||||
end
|
||||
|
||||
def select_multifoodcoop(foodcoop)
|
||||
select_foodcoop foodcoop if config[:multi_coop_install]
|
||||
end
|
||||
|
||||
# Return configuration value for the currently selected foodcoop.
|
||||
#
|
||||
# First tries to read configuration from the database (cached),
|
||||
|
@ -134,15 +138,20 @@ class FoodsoftConfig
|
|||
keys.map(&:to_s).uniq
|
||||
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
|
||||
def each_coop
|
||||
if config[:multi_coop_install]
|
||||
APP_CONFIG.keys.reject { |coop| coop =~ /^(default|development|test|production)$/ }.each do |coop|
|
||||
select_foodcoop coop
|
||||
yield coop
|
||||
end
|
||||
else
|
||||
yield config[:default_scope]
|
||||
foodcoops.each do |coop|
|
||||
select_multifoodcoop coop
|
||||
yield coop
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -14,5 +14,9 @@ gem 'foodsoft_messages', path: 'lib/foodsoft_messages'
|
|||
This plugin introduces the foodcoop config option `use_messages`, which can be
|
||||
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
|
||||
foodsoft's LICENSE for the full license text).
|
||||
|
|
|
@ -4,9 +4,17 @@ class MessagesMailer < Mailer
|
|||
set_foodcoop_scope
|
||||
@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,
|
||||
to: recipient.email,
|
||||
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
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
require "base32"
|
||||
|
||||
class Message < ActiveRecord::Base
|
||||
belongs_to :sender, :class_name => "User", :foreign_key => "sender_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_inclusion_of :email_state, :in => EMAIL_STATE.values
|
||||
|
||||
before_create :create_salt
|
||||
before_validation :clean_up_recipient_ids, :on => :create
|
||||
|
||||
def self.deliver(message_id)
|
||||
|
@ -61,6 +64,16 @@ class Message < ActiveRecord::Base
|
|||
add_recipients([user])
|
||||
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.
|
||||
def system_message?
|
||||
self.sender_id.nil?
|
||||
|
@ -94,6 +107,10 @@ class Message < ActiveRecord::Base
|
|||
def is_readable_for?(user)
|
||||
!private || sender == user || recipients_ids.include?(user.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_salt
|
||||
self.salt = [Array.new(6){rand(256).chr}.join].pack("m").chomp
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
|
|
@ -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
|
|
@ -17,7 +17,9 @@ Gem::Specification.new do |s|
|
|||
s.test_files = Dir["test/**/*"]
|
||||
|
||||
s.add_dependency "rails"
|
||||
s.add_dependency "base32"
|
||||
s.add_dependency "deface", "~> 1.0.0"
|
||||
s.add_dependency "mail"
|
||||
|
||||
s.add_development_dependency "sqlite3"
|
||||
end
|
||||
|
|
60
plugins/messages/lib/tasks/foodsoft.rake
Normal file
60
plugins/messages/lib/tasks/foodsoft.rake
Normal 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
|
Loading…
Reference in a new issue