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
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.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|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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).
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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.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
|
||||||
|
|
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