FEATURE: better email in support

FEATURE: new incoming_email model
FEATURE: infinite scrolling in emails admin
FEATURE: new 'emails:import' rake task
This commit is contained in:
Régis Hanol 2016-01-19 00:57:55 +01:00
parent d0bcea3411
commit 3083657358
119 changed files with 1061 additions and 1466 deletions

View File

@ -1,3 +0,0 @@
import AdminEmailSkippedController from "admin/controllers/admin-email-skipped";
export default AdminEmailSkippedController.extend();

View File

@ -0,0 +1,11 @@
import IncomingEmail from 'admin/models/incoming-email';
export default Ember.Controller.extend({
loadMore() {
return IncomingEmail.findAll(this.get("filter"), this.get("model.length"))
.then(incoming => {
if (incoming.length < 50) { this.get("model").set("allLoaded", true); }
this.get("model").addObjects(incoming);
});
}
});

View File

@ -0,0 +1,11 @@
import EmailLog from 'admin/models/email-log';
export default Ember.Controller.extend({
loadMore() {
return EmailLog.findAll(this.get("filter"), this.get("model.length"))
.then(logs => {
if (logs.length < 50) { this.get("model").set("allLoaded", true); }
this.get("model").addObjects(logs);
});
}
});

View File

@ -0,0 +1,9 @@
import AdminEmailIncomingsController from 'admin/controllers/admin-email-incomings';
import debounce from 'discourse/lib/debounce';
import IncomingEmail from 'admin/models/incoming-email';
export default AdminEmailIncomingsController.extend({
filterIncomingEmails: debounce(function() {
IncomingEmail.findAll(this.get("filter")).then(incomings => this.set("model", incomings));
}, 250).observes("filter.{from,to,subject}")
});

View File

@ -0,0 +1,9 @@
import AdminEmailIncomingsController from 'admin/controllers/admin-email-incomings';
import debounce from 'discourse/lib/debounce';
import IncomingEmail from 'admin/models/incoming-email';
export default AdminEmailIncomingsController.extend({
filterIncomingEmails: debounce(function() {
IncomingEmail.findAll(this.get("filter")).then(incomings => this.set("model", incomings));
}, 250).observes("filter.{from,to,subject,error}")
});

View File

@ -1,12 +1,9 @@
import AdminEmailLogsController from 'admin/controllers/admin-email-logs';
import debounce from 'discourse/lib/debounce';
import EmailLog from 'admin/models/email-log';
export default Ember.Controller.extend({
export default AdminEmailLogsController.extend({
filterEmailLogs: debounce(function() {
var self = this;
EmailLog.findAll(this.get("filter")).then(function(logs) {
self.set("model", logs);
});
}, 250).observes("filter.user", "filter.address", "filter.type", "filter.reply_key")
EmailLog.findAll(this.get("filter")).then(logs => this.set("model", logs));
}, 250).observes("filter.{user,address,type,reply_key}")
});

View File

@ -1,8 +1,9 @@
import AdminEmailLogsController from 'admin/controllers/admin-email-logs';
import debounce from 'discourse/lib/debounce';
import EmailLog from 'admin/models/email-log';
export default Ember.Controller.extend({
export default AdminEmailLogsController.extend({
filterEmailLogs: debounce(function() {
const EmailLog = require('admin/models/email-log').default;
EmailLog.findAll(this.get("filter")).then(logs => this.set("model", logs));
}, 250).observes("filter.user", "filter.address", "filter.type", "filter.skipped_reason")
}, 250).observes("filter.{user,address,type,skipped_reason}")
});

View File

@ -4,7 +4,7 @@ const EmailLog = Discourse.Model.extend({});
EmailLog.reopenClass({
create: function(attrs) {
create(attrs) {
attrs = attrs || {};
if (attrs.user) {
@ -14,16 +14,15 @@ EmailLog.reopenClass({
return this._super(attrs);
},
findAll: function(filter) {
findAll(filter, offset) {
filter = filter || {};
var status = filter.status || "all";
offset = offset || 0;
const status = filter.status || "sent";
filter = _.omit(filter, "status");
return Discourse.ajax("/admin/email/" + status + ".json", { data: filter }).then(function(logs) {
return _.map(logs, function (log) {
return EmailLog.create(log);
});
});
return Discourse.ajax(`/admin/email/${status}.json?offset=${offset}`, { data: filter })
.then(logs => _.map(logs, log => EmailLog.create(log)));
}
});

View File

@ -0,0 +1,29 @@
import AdminUser from 'admin/models/admin-user';
const IncomingEmail = Discourse.Model.extend({});
IncomingEmail.reopenClass({
create(attrs) {
attrs = attrs || {};
if (attrs.user) {
attrs.user = AdminUser.create(attrs.user);
}
return this._super(attrs);
},
findAll(filter, offset) {
filter = filter || {};
offset = offset || 0;
const status = filter.status || "received";
filter = _.omit(filter, "status");
return Discourse.ajax(`/admin/email/${status}.json?offset=${offset}`, { data: filter })
.then(incomings => _.map(incomings, incoming => IncomingEmail.create(incoming)));
}
});
export default IncomingEmail;

View File

@ -1,2 +0,0 @@
import AdminEmailLogs from 'admin/routes/admin-email-logs';
export default AdminEmailLogs.extend({ status: "all" });

View File

@ -0,0 +1,14 @@
import IncomingEmail from 'admin/models/incoming-email';
export default Discourse.Route.extend({
model() {
return IncomingEmail.findAll({ status: this.get("status") });
},
setupController(controller, model) {
controller.set("model", model);
controller.set("filter", { status: this.get("status") });
}
});

View File

@ -1,11 +1,11 @@
import EmailSettings from 'admin/models/email-settings';
export default Discourse.Route.extend({
model: function() {
model() {
return EmailSettings.find();
},
renderTemplate: function() {
renderTemplate() {
this.render('admin/templates/email_index', { into: 'adminEmail' });
}
});

View File

@ -1,27 +1,14 @@
import EmailLog from 'admin/models/email-log';
/**
Handles routes related to viewing email logs.
@class AdminEmailSentRoute
@extends Discourse.Route
@namespace Discourse
@module Discourse
**/
export default Discourse.Route.extend({
model: function() {
model() {
return EmailLog.findAll({ status: this.get("status") });
},
setupController: function(controller, model) {
setupController(controller, model) {
controller.set("model", model);
// resets the filters
controller.set("filter", { status: this.get("status") });
},
renderTemplate: function() {
this.render("admin/templates/email_" + this.get("status"), { into: "adminEmail" });
}
});

View File

@ -0,0 +1,2 @@
import AdminEmailIncomings from 'admin/routes/admin-email-incomings';
export default AdminEmailIncomings.extend({ status: "received" });

View File

@ -0,0 +1,2 @@
import AdminEmailIncomings from 'admin/routes/admin-email-incomings';
export default AdminEmailIncomings.extend({ status: "rejected" });

View File

@ -8,9 +8,10 @@ export default {
});
this.resource('adminEmail', { path: '/email'}, function() {
this.route('all');
this.route('sent');
this.route('skipped');
this.route('received');
this.route('rejected');
this.route('previewDigest', { path: '/preview-digest' });
});

View File

@ -0,0 +1,55 @@
<table class='table email-list'>
<thead>
<tr>
<th>{{i18n 'admin.email.time'}}</th>
<th>{{i18n 'admin.email.incoming_emails.from_address'}}</th>
<th>{{i18n 'admin.email.incoming_emails.to_addresses'}}</th>
<th>{{i18n 'admin.email.incoming_emails.subject'}}</th>
</tr>
</thead>
<tr class="filters">
<td>{{i18n 'admin.email.logs.filters.title'}}</td>
<td>{{text-field value=filter.from placeholderKey="admin.email.incoming_emails.filters.from_placeholder"}}</td>
<td>{{text-field value=filter.to placeholderKey="admin.email.incoming_emails.filters.to_placeholder"}}</td>
<td>{{text-field value=filter.subject placeholderKey="admin.email.incoming_emails.filters.subject_placeholder"}}</td>
</tr>
{{#each email in model}}
<tr>
<td class="time">{{format-date email.created_at}}</td>
<td class="username">
<div>
{{#if email.user}}
{{#link-to 'adminUser' email.user}}
{{avatar email.user imageSize="tiny"}}
{{email.from_address}}
{{/link-to}}
{{else}}
&mdash;
{{/if}}
</div>
</td>
<td class="addresses">
{{#each to in email.to_addresses}}
<p><a href="mailto:{{unbound to}}" title="TO">{{unbound to}}</a></p>
{{/each}}
{{#each cc in email.cc_addresses}}
<p><a href="mailto:{{unbound cc}}" title="CC">{{unbound cc}}</a></p>
{{/each}}
</td>
<td>
{{#if email.post_url}}
<a href="{{email.post_url}}">{{email.subject}}</a>
{{else}}
{{email.subject}}
{{/if}}
</td>
</tr>
{{else}}
<tr><td colspan="4">{{i18n 'admin.email.incoming_emails.none'}}</td></tr>
{{/each}}
</table>
{{conditional-loading-spinner condition=view.loading}}

View File

@ -0,0 +1,52 @@
<table class='table email-list'>
<thead>
<tr>
<th>{{i18n 'admin.email.time'}}</th>
<th>{{i18n 'admin.email.incoming_emails.from_address'}}</th>
<th>{{i18n 'admin.email.incoming_emails.to_addresses'}}</th>
<th>{{i18n 'admin.email.incoming_emails.subject'}}</th>
<th>{{i18n 'admin.email.incoming_emails.error'}}</th>
</tr>
</thead>
<tr class="filters">
<td>{{i18n 'admin.email.logs.filters.title'}}</td>
<td>{{text-field value=filter.from placeholderKey="admin.email.incoming_emails.filters.from_placeholder"}}</td>
<td>{{text-field value=filter.to placeholderKey="admin.email.incoming_emails.filters.to_placeholder"}}</td>
<td>{{text-field value=filter.subject placeholderKey="admin.email.incoming_emails.filters.subject_placeholder"}}</td>
<td>{{text-field value=filter.error placeholderKey="admin.email.incoming_emails.filters.error_placeholder"}}</td>
</tr>
{{#each email in model}}
<tr>
<td class="time">{{format-date email.created_at}}</td>
<td class="username">
<div>
{{#if email.user}}
{{#link-to 'adminUser' email.user}}
{{avatar email.user imageSize="tiny"}}
{{email.from_address}}
{{/link-to}}
{{else}}
&mdash;
{{/if}}
</div>
</td>
<td class="addresses">
{{#each to in email.to_addresses}}
<p><a href="mailto:{{unbound to}}" title="TO">{{unbound to}}</a></p>
{{/each}}
{{#each cc in email.cc_addresses}}
<p><a href="mailto:{{unbound cc}}" title="CC">{{unbound cc}}</a></p>
{{/each}}
</td>
<td>{{email.subject}}</td>
<td class="error">{{email.error}}</td>
</tr>
{{else}}
<tr><td colspan="5">{{i18n 'admin.email.incoming_emails.none'}}</td></tr>
{{/each}}
</table>
{{conditional-loading-spinner condition=view.loading}}

View File

@ -1,4 +1,4 @@
<table class='table'>
<table class='table email-list'>
<thead>
<tr>
<th>{{i18n 'admin.email.sent_at'}}</th>
@ -37,3 +37,5 @@
{{/each}}
</table>
{{conditional-loading-spinner condition=view.loading}}

View File

@ -1,4 +1,4 @@
<table class='table'>
<table class='table email-list'>
<thead>
<tr>
<th>{{i18n 'admin.email.time'}}</th>
@ -37,3 +37,5 @@
{{/each}}
</table>
{{conditional-loading-spinner condition=view.loading}}

View File

@ -1,10 +1,11 @@
{{#admin-nav}}
{{nav-item route='adminEmail.index' label='admin.email.settings'}}
{{nav-item route='adminEmail.all' label='admin.email.all'}}
{{nav-item route='adminEmail.previewDigest' label='admin.email.preview_digest'}}
{{nav-item route='adminCustomizeEmailTemplates' label='admin.email.templates'}}
{{nav-item route='adminEmail.sent' label='admin.email.sent'}}
{{nav-item route='adminEmail.skipped' label='admin.email.skipped'}}
{{nav-item route='adminEmail.previewDigest' label='admin.email.preview_digest'}}
{{nav-item route='adminCustomizeEmailTemplates' label='admin.customize.email_templates.title'}}
{{nav-item route='adminEmail.received' label='admin.email.received'}}
{{nav-item route='adminEmail.rejected' label='admin.email.rejected'}}
{{/admin-nav}}
<div class="admin-container">

View File

@ -1,39 +0,0 @@
<table class='table'>
<thead>
<tr>
<th>{{i18n 'admin.email.time'}}</th>
<th>{{i18n 'admin.email.user'}}</th>
<th>{{i18n 'admin.email.to_address'}}</th>
<th>{{i18n 'admin.email.email_type'}}</th>
<th>{{i18n 'admin.email.skipped_reason'}}</th>
</tr>
</thead>
<tr class="filters">
<td>{{i18n 'admin.email.logs.filters.title'}}</td>
<td>{{text-field value=filter.user placeholderKey="admin.email.logs.filters.user_placeholder"}}</td>
<td>{{text-field value=filter.address placeholderKey="admin.email.logs.filters.address_placeholder"}}</td>
<td>{{text-field value=filter.type placeholderKey="admin.email.logs.filters.type_placeholder"}}</td>
<td>{{text-field value=filter.skipped_reason placeholderKey="admin.email.logs.filters.skipped_reason_placeholder"}}</td>
</tr>
{{#each l in model}}
<tr>
<td>{{format-date l.created_at}}</td>
<td>
{{#if l.user}}
{{#link-to 'adminUser' l.user}}{{avatar l.user imageSize="tiny"}}{{/link-to}}
{{#link-to 'adminUser' l.user}}{{l.user.username}}{{/link-to}}
{{else}}
&mdash;
{{/if}}
</td>
<td><a href='mailto:{{unbound l.to_address}}'>{{l.to_address}}</a></td>
<td>{{l.email_type}}</td>
<td>{{l.skipped_reason}}</td>
</tr>
{{else}}
<tr><td colspan="5">{{i18n 'admin.email.logs.none'}}</td></tr>
{{/each}}
</table>

View File

@ -0,0 +1,14 @@
import LoadMore from "discourse/mixins/load-more";
export default Ember.View.extend(LoadMore, {
loading: false,
eyelineSelector: ".email-list tr",
actions: {
loadMore() {
if (this.get("loading") || this.get("model.allLoaded")) { return; }
this.set("loading", true);
return this.get("controller").loadMore().then(() => this.set("loading", false));
}
}
});

View File

@ -0,0 +1,14 @@
import LoadMore from "discourse/mixins/load-more";
export default Ember.View.extend(LoadMore, {
loading: false,
eyelineSelector: ".email-list tr",
actions: {
loadMore() {
if (this.get("loading") || this.get("model.allLoaded")) { return; }
this.set("loading", true);
return this.get("controller").loadMore().then(() => this.set("loading", false));
}
}
});

View File

@ -0,0 +1,5 @@
import AdminEmailIncomingsView from "admin/views/admin-email-incomings";
export default AdminEmailIncomingsView.extend({
templateName: "admin/templates/email-received"
});

View File

@ -0,0 +1,5 @@
import AdminEmailIncomingsView from "admin/views/admin-email-incomings";
export default AdminEmailIncomingsView.extend({
templateName: "admin/templates/email-rejected"
});

View File

@ -0,0 +1,5 @@
import AdminEmailLogsView from "admin/views/admin-email-logs";
export default AdminEmailLogsView.extend({
templateName: "admin/templates/email-sent"
});

View File

@ -0,0 +1,5 @@
import AdminEmailLogsView from "admin/views/admin-email-logs";
export default AdminEmailLogsView.extend({
templateName: "admin/templates/email-skipped"
});

View File

@ -1769,6 +1769,30 @@ table#user-badges {
}
}
// Emails
.email-list {
.filters input {
width: 100%;
}
.time {
width: 50px;
}
.username div {
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.addresses p {
margin: 2px 0;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// Mobile specific styles
// Mobile view text-inputs need some padding
.mobile-view .admin-contents {

View File

@ -17,11 +17,6 @@ class Admin::EmailController < Admin::AdminController
end
end
def all
email_logs = filter_email_logs(EmailLog.all, params)
render_serialized(email_logs, EmailLogSerializer)
end
def sent
email_logs = filter_email_logs(EmailLog.sent, params)
render_serialized(email_logs, EmailLogSerializer)
@ -32,6 +27,16 @@ class Admin::EmailController < Admin::AdminController
render_serialized(email_logs, EmailLogSerializer)
end
def received
incoming_emails = filter_incoming_emails(IncomingEmail, params)
render_serialized(incoming_emails, IncomingEmailSerializer)
end
def rejected
incoming_emails = filter_incoming_emails(IncomingEmail.errored, params)
render_serialized(incoming_emails, IncomingEmailSerializer)
end
def preview_digest
params.require(:last_seen_at)
params.require(:username)
@ -49,13 +54,33 @@ class Admin::EmailController < Admin::AdminController
private
def filter_email_logs(email_logs, params)
email_logs = email_logs.limit(50).includes(:user).order("email_logs.created_at desc").references(:user)
email_logs = email_logs.where("users.username LIKE ?", "%#{params[:user]}%") if params[:user].present?
email_logs = email_logs.where("email_logs.to_address LIKE ?", "%#{params[:address]}%") if params[:address].present?
email_logs = email_logs.where("email_logs.email_type LIKE ?", "%#{params[:type]}%") if params[:type].present?
email_logs = email_logs.where("email_logs.reply_key LIKE ?", "%#{params[:reply_key]}%") if params[:reply_key].present?
email_logs = email_logs.where("email_logs.skipped_reason LIKE ?", "%#{params[:skipped_reason]}%") if params[:skipped_reason].present?
email_logs.to_a
email_logs = email_logs.includes(:user)
.references(:user)
.order(created_at: :desc)
.offset(params[:offset] || 0)
.limit(50)
email_logs = email_logs.where("users.username ILIKE ?", "%#{params[:user]}%") if params[:user].present?
email_logs = email_logs.where("email_logs.to_address ILIKE ?", "%#{params[:address]}%") if params[:address].present?
email_logs = email_logs.where("email_logs.email_type ILIKE ?", "%#{params[:type]}%") if params[:type].present?
email_logs = email_logs.where("email_logs.reply_key ILIKE ?", "%#{params[:reply_key]}%") if params[:reply_key].present?
email_logs = email_logs.where("email_logs.skipped_reason ILIKE ?", "%#{params[:skipped_reason]}%") if params[:skipped_reason].present?
email_logs
end
def filter_incoming_emails(incoming_emails, params)
incoming_emails = incoming_emails.includes(:user, { post: :topic })
.order(created_at: :desc)
.offset(params[:offset] || 0)
.limit(50)
incoming_emails = incoming_emails.where("from_address ILIKE ?", "%#{params[:from]}%") if params[:from].present?
incoming_emails = incoming_emails.where("to_addresses ILIKE ? OR cc_addresses ILIKE ?", "%#{params[:to]}%") if params[:to].present?
incoming_emails = incoming_emails.where("subject ILIKE ?", "%#{params[:subject]}%") if params[:subject].present?
incoming_emails = incoming_emails.where("error ILIKE ?", "%#{params[:error]}%") if params[:error].present?
incoming_emails
end
def delivery_settings

View File

@ -1,6 +1,3 @@
#
# Connects to a mailbox and checks for replies
#
require 'net/pop'
require_dependency 'email/receiver'
require_dependency 'email/sender'
@ -10,6 +7,7 @@ module Jobs
class PollMailbox < Jobs::Scheduled
every SiteSetting.pop3_polling_period_mins.minutes
sidekiq_options retry: false
include Email::BuildEmailHelper
def execute(args)
@ -17,53 +15,42 @@ module Jobs
poll_pop3 if SiteSetting.pop3_polling_enabled?
end
def handle_mail(mail)
def process_popmail(popmail)
begin
mail_string = mail.pop
mail_string = popmail.pop
Email::Receiver.new(mail_string).process
rescue => e
handle_failure(mail_string, e)
ensure
mail.delete
end
end
def handle_failure(mail_string, e)
Rails.logger.warn("Email can not be processed: #{e}\n\n#{mail_string}") if SiteSetting.log_mail_processing_failures
message_template = case e
when Email::Receiver::EmptyEmailError then :email_reject_empty
when Email::Receiver::NoBodyDetectedError then :email_reject_empty
when Email::Receiver::NoMessageIdError then :email_reject_no_message_id
when Email::Receiver::AutoGeneratedEmailError then :email_reject_auto_generated
when Email::Receiver::InactiveUserError then :email_reject_inactive_user
when Email::Receiver::BadDestinationAddress then :email_reject_bad_destination_address
when Email::Receiver::StrangersNotAllowedError then :email_reject_strangers_not_allowed
when Email::Receiver::InsufficientTrustLevelError then :email_reject_insufficient_trust_level
when Email::Receiver::ReplyUserNotMatchingError then :email_reject_reply_user_not_matching
when Email::Receiver::TopicNotFoundError then :email_reject_topic_not_found
when Email::Receiver::TopicClosedError then :email_reject_topic_closed
when Email::Receiver::InvalidPost then :email_reject_invalid_post
when ActiveRecord::Rollback then :email_reject_invalid_post
when Email::Receiver::InvalidPostAction then :email_reject_invalid_post_action
when Discourse::InvalidAccess then :email_reject_invalid_access
end
template_args = {}
case e
when Email::Receiver::UserNotSufficientTrustLevelError
message_template = :email_reject_trust_level
when Email::Receiver::UserNotFoundError
message_template = :email_reject_no_account
when Email::Receiver::EmptyEmailError
message_template = :email_reject_empty
when Email::Receiver::EmailUnparsableError
message_template = :email_reject_parsing
when Email::Receiver::EmailLogNotFound
message_template = :email_reject_reply_key
when Email::Receiver::BadDestinationAddress
message_template = :email_reject_destination
when Email::Receiver::TopicNotFoundError
message_template = :email_reject_topic_not_found
when Email::Receiver::TopicClosedError
message_template = :email_reject_topic_closed
when Email::Receiver::AutoGeneratedEmailError
message_template = :email_reject_auto_generated
when Discourse::InvalidAccess
message_template = :email_reject_invalid_access
when ActiveRecord::Rollback
message_template = :email_reject_post_error
when Email::Receiver::InvalidPost
if e.message.length < 6
message_template = :email_reject_post_error
else
message_template = :email_reject_post_error_specified
template_args[:post_error] = e.message
end
else
message_template = nil
# there might be more information available in the exception
if message_template == :email_reject_invalid_post && e.message.size > 6
message_template = :email_reject_invalid_post_specified
template_args[:post_error] = e.message
end
if message_template
@ -81,19 +68,16 @@ module Jobs
end
def poll_pop3
connection = Net::POP3.new(SiteSetting.pop3_polling_host, SiteSetting.pop3_polling_port)
connection.enable_ssl if SiteSetting.pop3_polling_ssl
pop3 = Net::POP3.new(SiteSetting.pop3_polling_host, SiteSetting.pop3_polling_port)
pop3.enable_ssl if SiteSetting.pop3_polling_ssl
connection.start(SiteSetting.pop3_polling_username, SiteSetting.pop3_polling_password) do |pop|
unless pop.mails.empty?
pop.each { |mail| handle_mail(mail) }
pop3.start(SiteSetting.pop3_polling_username, SiteSetting.pop3_polling_password) do |pop|
pop.delete_all do |p|
process_popmail(p)
end
pop.finish
end
rescue Net::POPAuthenticationError => e
Discourse.handle_job_exception(e, error_context(@args, "Signing in to poll incoming email"))
rescue Net::POPError => e
Discourse.handle_job_exception(e, error_context(@args, "Generic POP error"))
end
end

View File

@ -3,13 +3,23 @@ require_dependency 'email/message_builder'
class RejectionMailer < ActionMailer::Base
include Email::BuildEmailHelper
DISALLOWED_TEMPLATE_ARGS = [:to, :from, :base_url,
DISALLOWED_TEMPLATE_ARGS = [:to,
:from,
:base_url,
:user_preferences_url,
:include_respond_instructions, :html_override,
:add_unsubscribe_link, :respond_instructions,
:style, :body, :post_id, :topic_id, :subject,
:template, :allow_reply_by_email,
:private_reply, :from_alias]
:include_respond_instructions,
:html_override,
:add_unsubscribe_link,
:respond_instructions,
:style,
:body,
:post_id,
:topic_id,
:subject,
:template,
:allow_reply_by_email,
:private_reply,
:from_alias]
# Send an email rejection message.
#

View File

@ -0,0 +1,32 @@
class IncomingEmail < ActiveRecord::Base
belongs_to :user
belongs_to :topic
belongs_to :post
scope :errored, -> { where.not(error: nil) }
end
# == Schema Information
#
# Table name: incoming_emails
#
# id :integer not null, primary key
# user_id :integer
# topic_id :integer
# post_id :integer
# raw :text
# error :text
# message_id :text
# from_address :text
# to_addresses :text
# cc_addresses :text
# subject :text
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_incoming_emails_on_created_at (created_at)
# index_incoming_emails_on_error (error)
# index_incoming_emails_on_message_id (message_id)
#

View File

@ -514,6 +514,12 @@ class Topic < ActiveRecord::Base
true
end
def add_small_action(user, action_code, who=nil)
custom_fields = {}
custom_fields["action_code_who"] = who if who.present?
add_moderator_post(user, nil, post_type: Post.types[:small_action], action_code: action_code, custom_fields: custom_fields)
end
def add_moderator_post(user, text, opts=nil)
opts ||= {}
new_post = nil
@ -562,14 +568,7 @@ class Topic < ActiveRecord::Base
topic_user = topic_allowed_users.find_by(user_id: user.id)
if topic_user
topic_user.destroy
# add small action
self.add_moderator_post(
removed_by,
nil,
post_type: Post.types[:small_action],
action_code: "removed_user",
custom_fields: { action_code_who: user.username }
)
add_small_action(removed_by, "removed_user", user.username)
return true
end
end
@ -584,13 +583,7 @@ class Topic < ActiveRecord::Base
user = User.find_by_username_or_email(username_or_email)
if user && topic_allowed_users.create!(user_id: user.id)
# Create a small action message
self.add_moderator_post(
invited_by,
nil,
post_type: Post.types[:small_action],
action_code: "invited_user",
custom_fields: { action_code_who: user.username }
)
add_small_action(invited_by, "invited_user", user.username)
# Notify the user they've been invited
user.notifications.create(notification_type: Notification.types[:invited_to_private_message],

View File

@ -169,8 +169,7 @@ class User < ActiveRecord::Base
def self.suggest_name(email)
return "" if email.blank?
name = email.split(/[@\+]/)[0].gsub(".", " ")
name.titleize
email[/\A[^@]+/].tr(".", " ").titleize
end
def self.find_by_username_or_email(username_or_email)

View File

@ -0,0 +1,32 @@
class IncomingEmailSerializer < ApplicationSerializer
attributes :id,
:created_at,
:from_address,
:to_addresses,
:cc_addresses,
:subject,
:error,
:post_url
has_one :user, serializer: BasicUserSerializer, embed: :objects
def post_url
object.post.url
end
def include_post_url?
object.post.present?
end
def to_addresses
return if object.to_addresses.blank?
object.to_addresses.split(";")
end
def cc_addresses
return if object.cc_addresses.blank?
object.cc_addresses.split(";")
end
end

View File

@ -18,7 +18,8 @@ class SpamRule::AutoBlock
def block?
@user.blocked? or
(!@user.has_trust_level?(TrustLevel[1]) and
(!@user.staged? and
!@user.has_trust_level?(TrustLevel[1]) and
SiteSetting.num_flags_to_block_new_user > 0 and
SiteSetting.num_users_to_block_new_user > 0 and
num_spam_flags_against_user >= SiteSetting.num_flags_to_block_new_user and

View File

@ -21,6 +21,8 @@ class SpamRule::FlagSockpuppets
!first_post.user.staff? &&
!@post.user.staff? &&
!first_post.user.staged? &&
!@post.user.staged? &&
@post.user != first_post.user &&
@post.user.ip_address == first_post.user.ip_address &&
@post.user.new_user? &&

View File

@ -2190,14 +2190,17 @@ en:
email:
title: "Email"
title: "Emails"
settings: "Settings"
all: "All"
templates: "Templates"
preview_digest: "Preview Digest"
sending_test: "Sending test Email..."
error: "<b>ERROR</b> - %{server_error}"
test_error: "There was a problem sending the test email. Please double-check your mail settings, verify that your host is not blocking mail connections, and try again."
sent: "Sent"
skipped: "Skipped"
received: "Received"
rejected: "Rejected"
sent_at: "Sent At"
time: "Time"
user: "User"
@ -2207,7 +2210,6 @@ en:
send_test: "Send Test Email"
sent_test: "sent!"
delivery_method: "Delivery Method"
preview_digest: "Preview Digest"
preview_digest_desc: "Preview the content of the digest emails sent to inactive users."
refresh: "Refresh"
format: "Format"
@ -2216,6 +2218,19 @@ en:
last_seen_user: "Last Seen User:"
reply_key: "Reply Key"
skipped_reason: "Skip Reason"
incoming_emails:
from_address: "From"
to_addresses: "To"
cc_addresses: "Cc"
subject: "Subject"
error: "Error"
none: "No incoming emails found."
filters:
from_placeholder: "from@example.com"
to_placeholder: "to@example.com"
cc_placeholder: "cc@example.com"
subject_placeholder: "Subject..."
error_placeholder: "Error"
logs:
none: "No logs found."
filters:

View File

@ -945,8 +945,6 @@ en:
verbose_localization: "Show extended localization tips in the UI"
previous_visit_timeout_hours: "How long a visit lasts before we consider it the 'previous' visit, in hours"
allow_staged_accounts: "[BETA] Automatically create staged accounts for incoming emails."
rate_limit_create_topic: "After creating a topic, users must wait (n) seconds before creating another topic."
rate_limit_create_post: "After posting, users must wait (n) seconds before creating another post."
rate_limit_new_user_create_topic: "After creating a topic, new users must wait (n) seconds before creating another topic."
@ -1744,13 +1742,34 @@ en:
subject_template: "Data export failed"
text_body_template: "We're sorry, but your data export failed. Please check the logs or contact a staff member."
email_reject_trust_level:
email_reject_insufficient_trust_level:
subject_template: "[%{site_name}] Email issue -- Insufficient Trust Level"
text_body_template: |
We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work.
Your account does not have the required trust level to post new topics to this email address. If you believe this is in error, contact a staff member.
email_reject_inactive_user:
subject_template: "[%{site_name}] Email issue -- Inactive User"
text_body_template: |
We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work.
Your account associated with this email address is not activated. Please activate your account before sending emails in.
email_reject_reply_user_not_matching:
subject_template: "[%{site_name}] Email issue -- Reply User Not Matching"
text_body_template: |
We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work.
The original notification was not sent to this email address. Try sending from a different email address, or contact a staff member.
email_reject_no_message_id:
subject_template: "[%{site_name}] Email issue -- No Message Id"
text_body_template: |
We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work.
There was no Message-Id header in the email. Try sending from a different email address, or contact a staff member.
email_reject_no_account:
subject_template: "[%{site_name}] Email issue -- Unknown Account"
text_body_template: |
@ -1781,14 +1800,21 @@ en:
Your account does not have the privileges to post new topics in that category. If you believe this is in error, contact a staff member.
email_reject_post_error:
email_reject_strangers_not_allowed:
subject_template: "[%{site_name}] Email issue -- Invalid Access"
text_body_template: |
We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work.
The category you sent this email to does not allow emails from unrestricted accounts. If you believe this is in error, contact a staff member.
email_reject_invalid_post:
subject_template: "[%{site_name}] Email issue -- Posting error"
text_body_template: |
We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work.
Some possible causes are: complex formatting, message too large, message too small. Please try again, or post via the website if this continues.
email_reject_post_error_specified:
email_reject_invalid_post_specified:
subject_template: "[%{site_name}] Email issue -- Posting error"
text_body_template: |
We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work.
@ -1799,6 +1825,14 @@ en:
If you can correct the problem, please try again.
email_reject_invalid_post_action:
subject_template: "[%{site_name}] Email issue -- Invalid Post Action"
text_body_template: |
We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work.
The Post Action was not recognized. Please try again, or post via the website if this continues.
email_reject_reply_key:
subject_template: "[%{site_name}] Email issue -- Unknown Reply Key"
text_body_template: |
@ -1806,12 +1840,12 @@ en:
The provided reply key is invalid or unknown, so we don't know what this email is in reply to. Contact a staff member.
email_reject_destination:
email_reject_bad_destination_address:
subject_template: "[%{site_name}] Email issue -- Unknown To: Address"
text_body_template: |
We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work.
None of the destination addresses are recognized. Please make sure that the site address is in the To: line (not Cc: or Bcc:), and that you are sending to the correct email address provided by staff.
None of the destination addresses are recognized. Please make sure that you are sending to the correct email address provided by staff.
email_reject_topic_not_found:
subject_template: "[%{site_name}] Email issue -- Topic Not Found"

View File

@ -119,9 +119,10 @@ Discourse::Application.routes.draw do
resources :email, constraints: AdminConstraint.new do
collection do
post "test"
get "all"
get "sent"
get "skipped"
get "received"
get "rejected"
get "preview-digest" => "email#preview_digest"
post "handle_mail"
end

View File

@ -806,8 +806,6 @@ developer:
default: 500
client: true
hidden: true
allow_staged_accounts:
default: false
embedding:
feed_polling_enabled:

View File

@ -10,6 +10,6 @@ class DropGroupManagers < ActiveRecord::Migration
end
def down
raise ActiveRecord::IrriversableMigration
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -0,0 +1,24 @@
class CreateIncomingEmails < ActiveRecord::Migration
def change
create_table :incoming_emails do |t|
t.integer :user_id
t.integer :topic_id
t.integer :post_id
t.text :raw
t.text :error
t.text :message_id
t.text :from_address
t.text :to_addresses
t.text :cc_addresses
t.text :subject
t.timestamps null: false
end
add_index :incoming_emails, :created_at
add_index :incoming_emails, :message_id
add_index :incoming_emails, :error
end
end

View File

@ -0,0 +1,28 @@
class BackfillIncomingEmails < ActiveRecord::Migration
def up
execute <<-SQL
INSERT INTO incoming_emails (post_id, created_at, updated_at, user_id, topic_id, message_id, from_address, to_addresses, subject)
SELECT posts.id
, posts.created_at
, posts.created_at
, posts.user_id
, posts.topic_id
, array_to_string(regexp_matches(posts.raw_email, '^\s*Message-Id: .*<([^>]+)>', 'im'), '')
, users.email
, array_to_string(regexp_matches(array_to_string(regexp_matches(posts.raw_email, '^to:.+$', 'im'), ''), '[^<\s"''(]+@[^>\s"'')]+'), '')
, topics.title
FROM posts
JOIN topics ON posts.topic_id = topics.id
JOIN users ON posts.user_id = users.id
WHERE posts.user_id IS NOT NULL
AND posts.topic_id IS NOT NULL
AND posts.via_email = 't'
AND posts.raw_email ~* 'Message-Id'
ORDER BY posts.id;
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -16,9 +16,7 @@
{ "path": "script" },
{ "path": "spec" },
{ "path": "vendor" },
{ "path": "test",
"folder_exclude_patterns": ["fixtures"]
}
{ "path": "test" },
],
"settings":
{

View File

@ -48,7 +48,7 @@ module BackupRestore
switch_schema!
migrate_database
# migrate_database
reconnect_database
reload_site_settings
clear_emoji_cache
@ -56,7 +56,7 @@ module BackupRestore
disable_readonly_mode
### READ-ONLY / END ###
extract_uploads
# extract_uploads
rescue SystemExit
log "Restore process was cancelled!"
rollback

View File

@ -1,144 +1,198 @@
require_dependency 'new_post_manager'
require_dependency 'email/html_cleaner'
require_dependency 'post_action_creator'
require_dependency "new_post_manager"
require_dependency "post_action_creator"
require_dependency "email/html_cleaner"
module Email
class Receiver
include ActionView::Helpers::NumberHelper
class ProcessingError < StandardError; end
class EmptyEmailError < ProcessingError; end
class NoMessageIdError < ProcessingError; end
class AutoGeneratedEmailError < ProcessingError; end
class NoBodyDetectedError < ProcessingError; end
class InactiveUserError < ProcessingError; end
class BadDestinationAddress < ProcessingError; end
class StrangersNotAllowedError < ProcessingError; end
class InsufficientTrustLevelError < ProcessingError; end
class ReplyUserNotMatchingError < ProcessingError; end
class TopicNotFoundError < ProcessingError; end
class TopicClosedError < ProcessingError; end
class InvalidPost < ProcessingError; end
class InvalidPostAction < ProcessingError; end
class ProcessingError < StandardError; end
class EmailUnparsableError < ProcessingError; end
class EmptyEmailError < ProcessingError; end
class UserNotFoundError < ProcessingError; end
class UserNotSufficientTrustLevelError < ProcessingError; end
class BadDestinationAddress < ProcessingError; end
class TopicNotFoundError < ProcessingError; end
class TopicClosedError < ProcessingError; end
class AutoGeneratedEmailError < ProcessingError; end
class EmailLogNotFound < ProcessingError; end
class InvalidPost < ProcessingError; end
class ReplyUserNotFoundError < ProcessingError; end
class ReplyUserNotMatchingError < ProcessingError; end
class InactiveUserError < ProcessingError; end
class InvalidPostAction < ProcessingError; end
attr_reader :body, :email_log
def initialize(raw, opts=nil)
@raw = raw
@opts = opts || {}
def initialize(mail_string)
raise EmptyEmailError if mail_string.blank?
@raw_email = mail_string
@mail = Mail.new(@raw_email)
raise NoMessageIdError if @mail.message_id.blank?
end
def process
raise EmptyEmailError if @raw.blank?
@message = Mail.new(@raw)
raise AutoGeneratedEmailError if @message.header.to_s =~ /auto-(replied|generated)/
@body = parse_body(@message)
# 'smtp_envelope_to' is a combination of: to, cc and bcc fields
# prioriziting the `:reply` types
dest_infos = @message.smtp_envelope_to
.map { |to_address| check_address(to_address) }
.compact
.sort do |a, b|
if a[:type] == :reply && b[:type] != :reply
1
elsif a[:type] != :reply && b[:type] == :reply
-1
else
0
end
end
raise BadDestinationAddress if dest_infos.empty?
from = @message[:from].address_list.addresses.first
user_email = from.address
user_name = from.display_name
user = User.find_by_email(user_email)
raise InactiveUserError if user.present? && !user.active && !user.staged
# TODO: take advantage of all the "TO"s
dest_info = dest_infos[0]
case dest_info[:type]
when :group
group = dest_info[:obj]
if user.blank?
if SiteSetting.allow_staged_accounts
user = create_staged_account(user_email, user_name)
else
wrap_body_in_quote(user_email)
user = Discourse.system_user
end
end
create_new_topic(user, archetype: Archetype.private_message, target_group_names: [group.name])
when :category
category = dest_info[:obj]
if user.blank? && category.email_in_allow_strangers
if SiteSetting.allow_staged_accounts
user = create_staged_account(user_email)
else
wrap_body_in_quote(user_email)
user = Discourse.system_user
end
end
raise UserNotFoundError if user.blank?
raise UserNotSufficientTrustLevelError.new(user) unless category.email_in_allow_strangers || user.has_trust_level?(TrustLevel[SiteSetting.email_in_min_trust.to_i])
create_new_topic(user, category: category.id)
when :reply
@email_log = dest_info[:obj]
raise EmailLogNotFound if @email_log.blank?
raise TopicNotFoundError if Topic.find_by_id(@email_log.topic_id).nil?
raise TopicClosedError if Topic.find_by_id(@email_log.topic_id).closed?
raise ReplyUserNotFoundError if user.blank?
raise ReplyUserNotMatchingError if @email_log.user_id != user.id
if post_action_type = post_action_for(@body)
create_post_action(@email_log, post_action_type)
else
create_reply(@email_log)
end
end
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e
raise EmailUnparsableError.new(e)
@incoming_email = find_or_create_incoming_email
process_internal
rescue => e
@incoming_email.update_columns(error: e.to_s)
raise
end
def create_staged_account(email, name=nil)
User.create(
email: email,
username: UserNameSuggester.suggest(name.presence || email),
name: name.presence || User.suggest_name(email),
staged: true,
)
def find_or_create_incoming_email
IncomingEmail.find_or_create_by(message_id: @mail.message_id) do |incoming_email|
incoming_email.raw = @raw_email
incoming_email.subject = @mail.subject
incoming_email.from_address = @mail.from.first.downcase
incoming_email.to_addresses = @mail.to.map(&:downcase).join(";") if @mail.to.present?
incoming_email.cc_addresses = @mail.cc.map(&:downcase).join(";") if @mail.cc.present?
end
end
def process_internal
raise AutoGeneratedEmailError if is_auto_generated?
body = select_body || ""
raise NoBodyDetectedError if body.blank? && !@mail.has_attachments?
user = find_or_create_user(from)
@incoming_email.update_columns(user_id: user.id)
raise InactiveUserError if !user.active && !user.staged
if post = find_related_post
create_reply(user: user, raw: body, post: post, topic: post.topic)
else
destination = destinations.first
raise BadDestinationAddress if destination.blank?
case destination[:type]
when :group
group = destination[:obj]
create_topic(user: user, raw: body, title: @mail.subject, archetype: Archetype.private_message, target_group_names: [group.name], skip_validations: true)
when :category
category = destination[:obj]
raise StrangersNotAllowedError if user.staged? && !category.email_in_allow_strangers
raise InsufficientTrustLevelError if !user.has_trust_level?(SiteSetting.email_in_min_trust)
create_topic(user: user, raw: body, title: @mail.subject, category: category.id)
when :reply
email_log = destination[:obj]
raise ReplyUserNotMatchingError if email_log.user_id != user.id
create_reply(user: user, raw: body, post: email_log.post, topic: email_log.post.topic)
end
end
end
def is_auto_generated?
@mail.return_path.blank? ||
@mail[:precedence].to_s[/list|junk|bulk|auto_reply/] ||
@mail.header.to_s[/auto-(submitted|replied|generated)/]
end
def select_body
text = nil
html = nil
if @mail.multipart?
text = fix_charset(@mail.text_part)
html = fix_charset(@mail.html_part)
elsif @mail.content_type.to_s["text/html"]
html = fix_charset(@mail)
else
text = fix_charset(@mail)
end
# prefer text over html
if text.present?
text_encoding = text.encoding
text = DiscourseEmailParser.parse_reply(text)
text = try_to_encode(text, text_encoding)
return text if text.present?
end
# clean the html if that's all we've got
if html.present?
html_encoding = html.encoding
html = Email::HtmlCleaner.new(html).output_html
html = DiscourseEmailParser.parse_reply(html)
html = try_to_encode(html, html_encoding)
return html if html.present?
end
end
def fix_charset(mail_part)
return nil if mail_part.blank? || mail_part.body.blank?
string = mail_part.body.to_s
# TODO (use charlock_holmes to properly detect encoding)
# 1) use the charset provided
if mail_part.charset.present?
fixed = try_to_encode(string, mail_part.charset)
return fixed if fixed.present?
end
# 2) default to UTF-8
try_to_encode(string, "UTF-8")
end
def try_to_encode(string, encoding)
string.encode("UTF-8", encoding)
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
nil
end
def from
@from ||= @mail[:from].address_list.addresses.first
end
def find_or_create_user(address_field)
# decode the address field
address_field.decoded
# extract email and name
email = address_field.address.downcase
name = address_field.display_name.try(:to_s)
username = UserNameSuggester.sanitize_username(name) if name.present?
User.find_or_create_by(email: email) do |user|
user.username = UserNameSuggester.suggest(username.presence || email)
user.name = name.presence || User.suggest_name(email)
user.staged = true
end
end
def destinations
[ @mail.destinations,
[@mail[:x_forwarded_to]].flatten.compact.map(&:decoded),
[@mail[:delivered_to]].flatten.compact.map(&:decoded),
].flatten
.select(&:present?)
.uniq
.lazy
.map { |d| check_address(d) }
.drop_while(&:blank?)
end
def check_address(address)
# only check for a group/category when 'email_in' is enabled
if SiteSetting.email_in
group = Group.find_by_email(address)
return { address: address, type: :group, obj: group } if group
return { type: :group, obj: group } if group
category = Category.find_by_email(address)
return { address: address, type: :category, obj: category } if category
return { type: :category, obj: category } if category
end
# reply
match = reply_by_email_address_regex.match(address)
if match && match[1].present?
email_log = EmailLog.for(match[1])
return { address: address, type: :reply, obj: email_log }
return { type: :reply, obj: email_log } if email_log
end
end
@ -147,173 +201,89 @@ module Email
.gsub(Regexp.escape("%{reply_key}"), "([[:xdigit:]]{32})")
end
def parse_body(message)
body = select_body(message)
encoding = body.encoding
raise EmptyEmailError if body.strip.blank?
def find_related_post
message_ids = [@mail.in_reply_to, extract_references]
message_ids.flatten!
message_ids.select!(&:present?)
message_ids.uniq!
return if message_ids.empty?
body = discourse_email_trimmer(body)
raise EmptyEmailError if body.strip.blank?
body = DiscourseEmailParser.parse_reply(body)
raise EmptyEmailError if body.strip.blank?
body.force_encoding(encoding).encode("UTF-8")
IncomingEmail.where.not(post_id: nil)
.where(message_id: message_ids)
.first
.try(:post)
end
def select_body(message)
html = nil
if message.multipart?
text = fix_charset message.text_part
# prefer text over html
return text if text
html = fix_charset message.html_part
elsif message.content_type =~ /text\/html/
html = fix_charset message
def extract_references
if Array === @mail.references
@mail.references
elsif @mail.references.present?
@mail.references.split(/[\s,]/).map { |r| r.sub(/^</, "").sub(/>$/, "") }
end
if html
body = HtmlCleaner.new(html).output_html
else
body = fix_charset message
end
return body if @opts[:skip_sanity_check]
# Certain trigger phrases that means we didn't parse correctly
if body =~ /Content\-Type\:/ || body =~ /multipart\/alternative/ || body =~ /text\/plain/
raise EmptyEmailError
end
body
end
# Force encoding to UTF-8 on a Mail::Message or Mail::Part
def fix_charset(object)
return nil if object.nil?
if object.charset
object.body.decoded.force_encoding(object.charset.gsub(/utf8/i, "UTF-8")).encode("UTF-8").to_s
else
object.body.to_s
end
rescue
nil
end
REPLYING_HEADER_LABELS = ['From', 'Sent', 'To', 'Subject', 'In-Reply-To', 'Cc', 'Bcc', 'Date']
REPLYING_HEADER_REGEX = Regexp.union(REPLYING_HEADER_LABELS.map { |lbl| "#{lbl}:" })
def line_is_quote?(l)
l =~ /\A\s*\-{3,80}\s*\z/ ||
l =~ Regexp.new("\\A\\s*" + I18n.t('user_notifications.previous_discussion') + "\\s*\\Z") ||
(l =~ /via #{SiteSetting.title}(.*)\:$/) ||
# This one might be controversial but so many reply lines have years, times and end with a colon.
# Let's try it and see how well it works.
(l =~ /\d{4}/ && l =~ /\d:\d\d/ && l =~ /\:$/) ||
(l =~ /On [\w, ]+\d+.*wrote:/)
end
def discourse_email_trimmer(body)
lines = body.scrub.lines.to_a
range_start = 0
range_end = 0
# If we started with a quote, skip it
lines.each_with_index do |l, idx|
break unless line_is_quote?(l) or l =~ /^>/ or l.blank?
range_start = idx + 1
end
lines[range_start..-1].each_with_index do |l, idx|
break if line_is_quote?(l)
# Headers on subsequent lines
break if (0..2).all? { |off| lines[idx+off] =~ REPLYING_HEADER_REGEX }
# Headers on the same line
break if REPLYING_HEADER_LABELS.count { |lbl| l.include? lbl } >= 3
range_end = range_start + idx
end
lines[range_start..range_end].join.strip
end
private
def wrap_body_in_quote(user_email)
@body = "[quote=\"#{user_email}\"]\n#{@body}\n[/quote]"
end
def create_post_action(email_log, type)
PostActionCreator.new(email_log.user, email_log.post).perform(type)
rescue Discourse::InvalidAccess, PostAction::AlreadyActed => e
raise InvalidPostAction.new(e)
def likes
@likes ||= Set.new ["+1", I18n.t('post_action_types.like.title').downcase]
end
def post_action_for(body)
if ['+1', I18n.t('post_action_types.like.title').downcase].include? body.downcase
if likes.include?(body.strip.downcase)
PostActionType.types[:like]
end
end
def create_reply(email_log)
create_post_with_attachments(email_log.user,
raw: @body,
topic_id: email_log.topic_id,
reply_to_post_number: email_log.post.post_number)
def create_topic(options={})
create_post_with_attachments(options)
end
def create_new_topic(user, topic_options={})
topic_options[:raw] = @body
topic_options[:title] = @message.subject
def create_reply(options={})
raise TopicNotFoundError if options[:topic].nil? || options[:topic].trashed?
raise TopicClosedError if options[:topic].closed?
result = create_post_with_attachments(user, topic_options)
topic_id = result.post.present? ? result.post.topic_id : nil
EmailLog.create(
email_type: "topic_via_incoming_email",
to_address: user.email,
topic_id: topic_id,
user_id: user.id,
)
result
if post_action_type = post_action_for(options[:raw])
create_post_action(options[:user], options[:post], post_action_type)
else
options[:topic_id] = options[:post].try(:topic_id)
options[:reply_to_post_number] = options[:post].try(:post_number)
create_post_with_attachments(options)
end
end
def create_post_with_attachments(user, post_options={})
options = {
cooking_options: { traditional_markdown_linebreaks: true },
}.merge(post_options)
raw = options[:raw]
def create_post_action(user, post, type)
PostActionCreator.new(user, post).perform(type)
rescue PostAction::AlreadyActed
# it's cool, don't care
rescue Discourse::InvalidAccess => e
raise InvalidPostAction.new(e)
end
def create_post_with_attachments(options={})
# deal with attachments
@message.attachments.each do |attachment|
@mail.attachments.each do |attachment|
tmp = Tempfile.new("discourse-email-attachment")
begin
# read attachment
File.open(tmp.path, "w+b") { |f| f.write attachment.body.decoded }
# create the upload for the user
upload = Upload.create_for(user.id, tmp, attachment.filename, tmp.size)
upload = Upload.create_for(options[:user].id, tmp, attachment.filename, tmp.size)
if upload && upload.errors.empty?
# try to inline images
if attachment.content_type.start_with?("image/")
if raw =~ /\[image: Inline image \d+\]/
raw.sub!(/\[image: Inline image \d+\]/, attachment_markdown(upload))
next
end
if attachment.content_type.start_with?("image/") && options[:raw][/\[image: .+ \d+\]/]
options[:raw].sub!(/\[image: .+ \d+\]/, attachment_markdown(upload))
else
options[:raw] << "\n#{attachment_markdown(upload)}\n"
end
raw << "\n#{attachment_markdown(upload)}\n"
end
ensure
tmp.close!
tmp.try(:close!) rescue nil
end
end
options[:raw] = raw
post_options = {
cooking_options: { traditional_markdown_linebreaks: true },
}.merge(options)
create_post(user, options)
create_post(post_options)
end
def attachment_markdown(upload)
@ -324,20 +294,46 @@ module Email
end
end
def create_post(user, options)
# Mark the reply as incoming via email
def create_post(options={})
options[:via_email] = true
options[:raw_email] = @raw
options[:raw_email] = @raw_email
manager = NewPostManager.new(user, options)
# ensure posts aren't created in the future
options[:created_at] = [@mail.date, DateTime.now].min
manager = NewPostManager.new(options[:user], options)
result = manager.perform
if result.errors.present?
raise InvalidPost, result.errors.full_messages.join("\n")
end
raise InvalidPost, result.errors.full_messages.join("\n") if result.errors.any?
result
if result.post
@incoming_email.update_columns(topic_id: result.post.topic_id, post_id: result.post.id)
if result.post.topic && result.post.topic.private_message?
add_other_addresses(result.post.topic, options[:user])
end
end
end
def add_other_addresses(topic, sender)
%i(to cc bcc).each do |d|
if @mail[d] && @mail[d].address_list && @mail[d].address_list.addresses
@mail[d].address_list.addresses.each do |address|
begin
if user = find_or_create_user(address)
unless topic.topic_allowed_users.where(user_id: user.id).exists? &&
topic.topic_allowed_groups.where("group_id IN (SELECT group_id FROM group_users WHERE user_id = ?)", user.id).exists?
topic.topic_allowed_users.create!(user_id: user.id)
topic.add_small_action(sender, "invited_user", user.username)
end
end
rescue ActiveRecord::RecordInvalid
# don't care if user already allowed
end
end
end
end
end
end
end

56
lib/tasks/emails.rake Normal file
View File

@ -0,0 +1,56 @@
def process_popmail(popmail)
begin
mail_string = popmail.pop
Email::Receiver.new(mail_string).process
rescue
putc "!"
else
putc "."
end
end
desc "use this task to import a mailbox into Disourse"
task "emails:import" => :environment do
begin
unless SiteSetting.email_in
puts "ERROR: you should enable the 'email_in' site setting before running this task"
exit(1)
end
address = ENV["ADDRESS"].presence || "pop.gmail.com"
port = (ENV["PORT"].presence || 995).to_i
ssl = (ENV["SSL"].presence || "1") == "1"
username = ENV["USERNAME"].presence
password = ENV["PASSWORD"].presence
if username.blank?
puts "ERROR: expecting USERNAME=<username> rake emails:import"
exit(2)
elsif password.blank?
puts "ERROR: expecting PASSWORD=<password> rake emails:import"
exit(3)
end
RateLimiter.disable
mails_left = 1
pop3 = Net::POP3.new(address, port)
pop3.enable_ssl if ssl
while mails_left > 0
pop3.start(username, password) do |pop|
pop.delete_all do |p|
process_popmail(p)
end
mails_left = pop.n_mails
end
end
puts "Done"
rescue Net::POPAuthenticationError
puts "AUTH EXCEPTION: please make sure your credentials are correct."
exit(10)
ensure
RateLimiter.enable
end
end

View File

@ -193,6 +193,6 @@ class TopicCreator
end
def check_can_send_permission!(topic, obj)
rollback_with!(topic, :cant_send_pm) unless @guardian.can_send_private_message?(obj)
rollback_with!(topic, :cant_send_pm) unless @opts[:skip_validations] || @guardian.can_send_private_message?(obj)
end
end

View File

@ -34,12 +34,11 @@ module UserNameSuggester
end
def self.sanitize_username(name)
name = ActiveSupport::Inflector.transliterate(name)
name = name.gsub(/^[^[:alnum:]]+|\W+$/, "")
.gsub(/\W+/, "_")
.gsub(/^\_+/, '')
.gsub(/[\-_\.]{2,}/, "_")
name
ActiveSupport::Inflector.transliterate(name)
.gsub(/^[^[:alnum:]]+|\W+$/, "")
.gsub(/\W+/, "_")
.gsub(/^\_+/, '')
.gsub(/[\-_\.]{2,}/, "_")
end
def self.rightsize_username(name)

View File

@ -1,791 +1,233 @@
# -*- encoding : utf-8 -*-
require 'rails_helper'
require 'email/receiver'
require "rails_helper"
require "email/receiver"
describe Email::Receiver do
before do
SiteSetting.reply_by_email_address = "reply+%{reply_key}@appmail.adventuretime.ooo"
SiteSetting.email_in = false
SiteSetting.title = "Discourse"
SiteSetting.email_in = true
SiteSetting.reply_by_email_address = "reply+%{reply_key}@bar.com"
end
describe 'parse_body' do
def test_parse_body(mail_string)
Email::Receiver.new(nil).parse_body(Mail::Message.new mail_string)
def email(email_name)
fixture_file("emails/#{email_name}.eml")
end
def process(email_name)
Email::Receiver.new(email(email_name)).process
end
it "raises an EmptyEmailError when 'mail_string' is blank" do
expect { Email::Receiver.new(nil) }.to raise_error(Email::Receiver::EmptyEmailError)
expect { Email::Receiver.new("") }.to raise_error(Email::Receiver::EmptyEmailError)
end
it "raises an NoMessageIdError when 'mail_string' is not an email" do
expect { Email::Receiver.new("wat") }.to raise_error(Email::Receiver::NoMessageIdError)
end
it "raises an NoMessageIdError when 'mail_string' is missing the message_id" do
expect { Email::Receiver.new(email(:missing_message_id)) }.to raise_error(Email::Receiver::NoMessageIdError)
end
it "raises an AutoGeneratedEmailError when the mail has no return path" do
expect { process(:no_return_path) }.to raise_error(Email::Receiver::AutoGeneratedEmailError)
end
it "raises an AutoGeneratedEmailError when the mail is auto generated" do
expect { process(:auto_generated_precedence) }.to raise_error(Email::Receiver::AutoGeneratedEmailError)
expect { process(:auto_generated_header) }.to raise_error(Email::Receiver::AutoGeneratedEmailError)
end
it "raises a NoBodyDetectedError when the body is blank" do
expect { process(:no_body) }.to raise_error(Email::Receiver::NoBodyDetectedError)
end
it "raises an InactiveUserError when the sender is inactive" do
Fabricate(:user, email: "inactive@bar.com", active: false)
expect { process(:inactive_sender) }.to raise_error(Email::Receiver::InactiveUserError)
end
skip "doesn't raise an InactiveUserError when the sender is staged" do
Fabricate(:user, email: "staged@bar.com", active: false, staged: true)
expect { process(:staged_sender) }.not_to raise_error
end
it "raises a BadDestinationAddress when destinations aren't matching any of the incoming emails" do
expect { process(:bad_destinations) }.to raise_error(Email::Receiver::BadDestinationAddress)
end
context "reply" do
let(:reply_key) { "4f97315cc828096c9cb34c6f1a0d6fe8" }
let(:user) { Fabricate(:user, email: "discourse@bar.com") }
let(:topic) { create_topic(user: user) }
let(:post) { create_post(topic: topic, user: user) }
let!(:email_log) { Fabricate(:email_log, reply_key: reply_key, user: user, topic: topic, post: post) }
it "raises a ReplyUserNotMatchingError when the email address isn't matching the one we sent the notification to" do
expect { process(:reply_user_not_matching) }.to raise_error(Email::Receiver::ReplyUserNotMatchingError)
end
it "raises EmptyEmailError if the message is blank" do
expect { test_parse_body("") }.to raise_error(Email::Receiver::EmptyEmailError)
it "raises a TopicNotFoundError when the topic was deleted" do
topic.update_columns(deleted_at: 1.day.ago)
expect { process(:reply_user_matching) }.to raise_error(Email::Receiver::TopicNotFoundError)
end
it "raises EmptyEmailError if the message is not an email" do
expect { test_parse_body("asdf" * 30) }.to raise_error(Email::Receiver::EmptyEmailError)
it "raises a TopicClosedError when the topic was closed" do
topic.update_columns(closed: true)
expect { process(:reply_user_matching) }.to raise_error(Email::Receiver::TopicClosedError)
end
it "raises EmptyEmailError if there is no reply content" do
expect { test_parse_body(fixture_file("emails/no_content_reply.eml")) }.to raise_error(Email::Receiver::EmptyEmailError)
it "raises an InvalidPost when there was an error while creating the post" do
expect { process(:too_small) }.to raise_error(Email::Receiver::InvalidPost)
end
skip "raises EmailUnparsableError if the headers are corrupted" do
expect { ; }.to raise_error(Email::Receiver::EmailUnparsableError)
it "raises an InvalidPost when there are too may mentions" do
SiteSetting.max_mentions_per_post = 1
Fabricate(:user, username: "user1")
Fabricate(:user, username: "user2")
expect { process(:too_many_mentions) }.to raise_error(Email::Receiver::InvalidPost)
end
it "can parse the html section" do
expect(test_parse_body(fixture_file("emails/html_only.eml"))).to eq("The EC2 instance - I've seen that there tends to be odd and " +
"unrecommended settings on the Bitnami installs that I've checked out.")
it "raises an InvalidPostAction when they aren't allowed to like a post" do
topic.update_columns(archived: true)
expect { process(:like) }.to raise_error(Email::Receiver::InvalidPostAction)
end
it "supports a Dutch reply" do
expect(test_parse_body(fixture_file("emails/dutch.eml"))).to eq("Dit is een antwoord in het Nederlands.")
it "works" do
expect { process(:text_reply) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("This is a text reply :)")
expect(topic.posts.last.via_email).to eq(true)
expect(topic.posts.last.cooked).not_to match(/<br/)
expect { process(:html_reply) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("This is a <b>HTML</b> reply ;)")
expect { process(:hebrew_reply) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("שלום! מה שלומך היום?")
expect { process(:chinese_reply) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("您好! 你今天好吗?")
end
it "supports a Hebrew reply" do
I18n.stubs(:t).with('user_notifications.previous_discussion').returns('כלטוב')
# The force_encoding call is only needed for the test - it is passed on fine to the cooked post
expect(test_parse_body(fixture_file("emails/hebrew.eml"))).to eq("שלום")
it "prefers text over html" do
expect { process(:text_and_html_reply) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("This is the *text* part.")
end
it "supports a BIG5-encoded reply" do
# The force_encoding call is only needed for the test - it is passed on fine to the cooked post
expect(test_parse_body(fixture_file("emails/big5.eml"))).to eq("媽!我上電視了!")
it "removes the 'on <date>, <contact> wrote' quoting line" do
expect { process(:on_date_contact_wrote) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("This is the actual reply.")
end
it "removes 'via' lines if they match the site title" do
SiteSetting.title = "Discourse"
expect(test_parse_body(fixture_file("emails/via_line.eml"))).to eq("Hello this email has content!")
end
it "removes an 'on date wrote' quoting line" do
expect(test_parse_body(fixture_file("emails/on_wrote.eml"))).to eq("Sure, all you need to do is frobnicate the foobar and you'll be all set!")
end
it "removes the 'Previous Discussion' marker" do
expect(test_parse_body(fixture_file("emails/previous.eml"))).to eq("This will not include the previous discussion that is present in this email.")
it "removes the 'Previous Replies' marker" do
expect { process(:previous_replies) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("This will not include the previous discussion that is present in this email.")
end
it "handles multiple paragraphs" do
expect(test_parse_body(fixture_file("emails/paragraphs.eml"))).
to eq(
"Is there any reason the *old* candy can't be be kept in silos while the new candy
is imported into *new* silos?
The thing about candy is it stays delicious for a long time -- we can just keep
it there without worrying about it too much, imo.
Thanks for listening."
)
end
it "handles multiple paragraphs when parsing html" do
expect(test_parse_body(fixture_file("emails/html_paragraphs.eml"))).
to eq(
"Awesome!
Pleasure to have you here!
:boom:"
)
end
it "handles newlines" do
expect(test_parse_body(fixture_file("emails/newlines.eml"))).
to eq(
"This is my reply.
It is my best reply.
It will also be my *only* reply."
)
expect { process(:paragraphs) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("Do you like liquorice?\n\nI really like them. One could even say that I am *addicted* to liquorice. Anf if\nyou can mix it up with some anise, then I'm in heaven ;)")
end
it "handles inline reply" do
expect(test_parse_body(fixture_file("emails/inline_reply.eml"))).
to eq(
"On Wed, Oct 8, 2014 at 11:12 AM, techAPJ <info@unconfigured.discourse.org> wrote:
> techAPJ <https://meta.discourse.org/users/techapj>
> November 28
>
> Test reply.
>
> First paragraph.
>
> Second paragraph.
>
> To respond, reply to this email or visit
> https://meta.discourse.org/t/testing-default-email-replies/22638/3 in
> your browser.
> ------------------------------
> Previous Replies codinghorror
> <https://meta.discourse.org/users/codinghorror>
> November 28
>
> We're testing the latest GitHub email processing library which we are
> integrating now.
>
> https://github.com/github/email_reply_parser
>
> Go ahead and reply to this topic and I'll reply from various email clients
> for testing.
> ------------------------------
>
> To respond, reply to this email or visit
> https://meta.discourse.org/t/testing-default-email-replies/22638/3 in
> your browser.
>
> To unsubscribe from these emails, visit your user preferences
> <https://meta.discourse.org/my/preferences>.
>
The quick brown fox jumps over the lazy dog. The quick brown fox jumps over
the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown
fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog. The quick brown fox jumps over
the lazy dog. The quick brown fox jumps over the lazy dog."
)
expect { process(:inline_reply) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("On Tue, Jan 15, 2016 at 11:12 AM, Bar Foo <info@unconfigured.discourse.org> wrote:\n\n> WAT <https://bar.com/users/wat> November 28\n>\n> This is the previous post.\n\nAnd this is *my* reply :+1:")
end
it "can retrieve the first part of multiple replies" do
expect(test_parse_body(fixture_file("emails/inline_mixed.eml"))).to eq(
"The quick brown fox jumps over the lazy dog. The quick brown fox jumps over
the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown
fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog. The quick brown fox jumps over
the lazy dog. The quick brown fox jumps over the lazy dog.
> First paragraph.
>
> Second paragraph.
The quick brown fox jumps over the lazy dog. The quick brown fox jumps over
the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown"
)
it "retrieves the first part of multiple replies" do
expect { process(:inline_mixed_replies) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("On Tue, Jan 15, 2016 at 11:12 AM, Bar Foo <info@unconfigured.discourse.org> wrote:\n\n> WAT <https://bar.com/users/wat> November 28\n>\n> This is the previous post.\n\nAnd this is *my* reply :+1:\n\n> This is another post.\n\nAnd this is **another** reply.")
end
it "should not include previous replies" do
expect(test_parse_body(fixture_file("emails/previous_replies.eml"))).not_to match(/Previous Replies/)
end
it "strips signatures" do
expect { process(:iphone_signature) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("This is not the signature you're looking for.")
it "strips iPhone signature" do
expect(test_parse_body(fixture_file("emails/iphone_signature.eml"))).not_to match(/Sent from my iPhone/)
end
it "strips regular signature" do
expect(test_parse_body(fixture_file("emails/signature.eml"))).not_to match(/Arpit/)
expect { process(:signature) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("You shall not sign!")
end
it "strips 'original message' context" do
expect(test_parse_body(fixture_file("emails/original_message_context.eml"))).not_to match(/Context/)
expect { process(:original_message) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to eq("This is a reply :)")
end
it "properly renders email reply from gmail web client" do
expect(test_parse_body(fixture_file("emails/gmail_web.eml"))).
to eq(
"### This is a reply from standard GMail in Google Chrome.
it "supports attachments" do
expect { process(:no_body_with_attachments) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to match(/<img/)
The quick brown fox jumps over the lazy dog. The quick brown fox jumps over
the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown
fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog. The quick brown fox jumps over
the lazy dog. The quick brown fox jumps over the lazy dog.
Here's some **bold** text in Markdown.
Here's a link http://example.com"
)
expect { process(:inline_attachment) }.to change { topic.posts.count }
expect(topic.posts.last.raw).to match(/Before\s+<img.+\s+After/m)
end
it "properly renders email reply from iOS default mail client" do
expect(test_parse_body(fixture_file("emails/ios_default.eml"))).
to eq(
"### this is a reply from iOS default mail
The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.
Here's some **bold** markdown text.
Here's a link http://example.com"
)
it "supports liking via email" do
expect { process(:like) }.to change(PostAction, :count)
end
it "properly renders email reply from Android 5 gmail client" do
expect(test_parse_body(fixture_file("emails/android_gmail.eml"))).
to eq(
"### this is a reply from Android 5 gmail
The quick brown fox jumps over the lazy dog. The quick brown fox jumps over
the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown
fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.
The quick brown fox jumps over the lazy dog.
This is **bold** in Markdown.
This is a link to http://example.com"
)
end
it "properly renders email reply from Windows 8.1 Metro default mail client" do
expect(test_parse_body(fixture_file("emails/windows_8_metro.eml"))).
to eq(
"### reply from default mail client in Windows 8.1 Metro
The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.
This is a **bold** word in Markdown
This is a link http://example.com"
)
end
it "properly renders email reply from MS Outlook client" do
expect(test_parse_body(fixture_file("emails/outlook.eml"))).to eq("Microsoft Outlook 2010")
end
it "converts back to UTF-8 at the end" do
result = test_parse_body(fixture_file("emails/big5.eml"))
expect(result.encoding).to eq(Encoding::UTF_8)
# should not throw
TextCleaner.normalize_whitespaces(
test_parse_body(fixture_file("emails/big5.eml"))
)
end
end
describe "posting replies" do
let(:reply_key) { raise "Override this in a lower describe block" }
let(:email_raw) { raise "Override this in a lower describe block" }
# ----
let(:to) { SiteSetting.reply_by_email_address.gsub("%{reply_key}", reply_key) }
let(:receiver) { Email::Receiver.new(email_raw) }
let(:post) { create_post }
let(:topic) { post.topic }
let(:posting_user) { post.user }
let(:replying_user_email) { 'jake@adventuretime.ooo' }
let(:replying_user) { Fabricate(:user, email: replying_user_email, trust_level: 2)}
let(:email_log) { EmailLog.new(reply_key: reply_key,
post: post,
post_id: post.id,
topic_id: post.topic_id,
email_type: 'user_posted',
user: replying_user,
user_id: replying_user.id,
to_address: replying_user_email
) }
before do
email_log.save
end
# === Success Posting ===
describe "valid_reply.eml" do
let!(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' }
let!(:email_raw) { fill_email(fixture_file("emails/valid_reply.eml"), replying_user_email, to) }
it "creates a post with the correct content" do
start_count = topic.posts.count
receiver.process
expect(topic.posts.count).to eq(start_count + 1)
created_post = topic.posts.last
expect(created_post.via_email).to eq(true)
expect(created_post.cooked.strip).to eq(fixture_file("emails/valid_reply.cooked").strip)
end
end
describe "paragraphs.eml" do
let!(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' }
let!(:email_raw) { fixture_file("emails/paragraphs.eml") }
it "cooks multiple paragraphs with traditional Markdown linebreaks" do
start_count = topic.posts.count
receiver.process
expect(topic.posts.count).to eq(start_count + 1)
expect(topic.posts.last.cooked.strip).to eq(fixture_file("emails/paragraphs.cooked").strip)
expect(topic.posts.last.cooked).not_to match(/<br/)
end
end
describe "attachment.eml" do
let!(:reply_key) { '636ca428858779856c226bb145ef4fad' }
let!(:email_raw) {
fixture_file("emails/attachment.eml")
.gsub("TO", "reply+#{reply_key}@appmail.adventuretime.ooo")
.gsub("FROM", replying_user_email)
}
let(:upload_sha) { '04df605be528d03876685c52166d4b063aabb78a' }
it "creates a post with an attachment" do
Upload.stubs(:fix_image_orientation)
ImageOptim.any_instance.stubs(:optimize_image!)
start_count = topic.posts.count
Upload.find_by(sha1: upload_sha).try(:destroy)
receiver.process
expect(topic.posts.count).to eq(start_count + 1)
expect(topic.posts.last.cooked).to match(/<img src=['"](\/uploads\/default\/original\/.+\.png)['"] width=['"]289['"] height=['"]126['"]>/)
expect(Upload.find_by(sha1: upload_sha)).not_to eq(nil)
end
describe 'Liking via email' do
let!(:reply_key) { '636ca428858779856c226bb145ef4fad' }
let(:replied_user_like_params) { { user: replying_user, post: post, post_action_type_id: PostActionType.types[:like] } }
let(:replied_user_like) { PostAction.find_by(replied_user_like_params) }
describe "plus_one.eml" do
let!(:email_raw) {
fixture_file("emails/plus_one.eml")
.gsub("TO", "reply+#{reply_key}@appmail.adventuretime.ooo")
.gsub("FROM", replying_user_email)
}
it "adds a user like to the post" do
expect { receiver.process }.to change { PostAction.count }.by(1)
expect(replied_user_like).to be_present
end
it "does not create a duplicate like" do
PostAction.create(replied_user_like_params)
before_count = PostAction.count
expect { receiver.process }.to raise_error(Email::Receiver::InvalidPostAction)
expect(PostAction.count).to eq before_count
expect(replied_user_like).to be_present
end
it "does not allow unauthorized happiness" do
post.trash!
before_count = PostAction.count
expect { receiver.process }.to raise_error(Email::Receiver::InvalidPostAction)
expect(PostAction.count).to eq before_count
expect(replied_user_like).to_not be_present
end
end
describe "like.eml" do
let!(:email_raw) {
fixture_file("emails/like.eml")
.gsub("TO", "reply+#{reply_key}@appmail.adventuretime.ooo")
.gsub("FROM", replying_user_email)
}
it 'adds a user like to the post' do
expect { receiver.process }.to change { PostAction.count }.by(1)
expect(replied_user_like).to be_present
end
end
end
end
# === Failure Conditions ===
describe "too_short.eml" do
let!(:reply_key) { '636ca428858779856c226bb145ef4fad' }
let!(:email_raw) {
fixture_file("emails/too_short.eml")
.gsub("TO", "reply+#{reply_key}@appmail.adventuretime.ooo")
.gsub("FROM", replying_user_email)
.gsub("SUBJECT", "re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux'")
}
it "raises an InvalidPost error" do
SiteSetting.min_post_length = 5
expect { receiver.process }.to raise_error(Email::Receiver::InvalidPost)
end
end
describe "too_many_mentions.eml" do
let!(:reply_key) { '636ca428858779856c226bb145ef4fad' }
let!(:email_raw) { fixture_file("emails/too_many_mentions.eml") }
it "raises an InvalidPost error" do
SiteSetting.max_mentions_per_post = 10
(1..11).each do |i|
Fabricate(:user, username: "user#{i}").save
end
expect { receiver.process }.to raise_error(Email::Receiver::InvalidPost)
end
end
describe "auto response email replies should not be accepted" do
let!(:reply_key) { '636ca428858779856c226bb145ef4fad' }
let!(:email_raw) { fixture_file("emails/auto_reply.eml") }
it "raises a AutoGeneratedEmailError" do
expect { receiver.process }.to raise_error(Email::Receiver::AutoGeneratedEmailError)
end
it "ensures posts aren't dated in the future" do
expect { process(:from_the_future) }.to change { topic.posts.count }
expect(topic.posts.last.created_at).to be_within(1.minute).of(DateTime.now)
end
end
describe "posting reply to a closed topic" do
let(:reply_key) { raise "Override this in a lower describe block" }
let(:email_raw) { raise "Override this in a lower describe block" }
let(:to) { SiteSetting.reply_by_email_address.gsub("%{reply_key}", reply_key) }
let(:receiver) { Email::Receiver.new(email_raw) }
let(:topic) { Fabricate(:topic, closed: true) }
let(:post) { Fabricate(:post, topic: topic, post_number: 1) }
let(:replying_user_email) { 'jake@adventuretime.ooo' }
let(:replying_user) { Fabricate(:user, email: replying_user_email, trust_level: 2) }
let(:email_log) { EmailLog.new(reply_key: reply_key,
post: post,
post_id: post.id,
topic_id: topic.id,
email_type: 'user_posted',
user: replying_user,
user_id: replying_user.id,
to_address: replying_user_email
) }
context "new message to a group" do
before do
email_log.save
let!(:group) { Fabricate(:group, incoming_email: "team@bar.com") }
it "handles encoded display names" do
expect { process(:encoded_display_name) }.to change(Topic, :count)
topic = Topic.last
expect(topic.private_message?).to eq(true)
expect(topic.allowed_groups).to include(group)
user = topic.user
expect(user.staged).to eq(true)
expect(user.username).to eq("random_name")
expect(user.name).to eq("Случайная Имя")
end
describe "should not create post" do
let!(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' }
let!(:email_raw) { fill_email(fixture_file("emails/valid_reply.eml"), replying_user_email, to) }
it "raises a TopicClosedError" do
expect { receiver.process }.to raise_error(Email::Receiver::TopicClosedError)
end
end
end
describe "posting reply to a deleted topic" do
let(:reply_key) { raise "Override this in a lower describe block" }
let(:email_raw) { raise "Override this in a lower describe block" }
let(:to) { SiteSetting.reply_by_email_address.gsub("%{reply_key}", reply_key) }
let(:receiver) { Email::Receiver.new(email_raw) }
let(:deleted_topic) { Fabricate(:deleted_topic) }
let(:post) { Fabricate(:post, topic: deleted_topic, post_number: 1) }
let(:replying_user_email) { 'jake@adventuretime.ooo' }
let(:replying_user) { Fabricate(:user, email: replying_user_email, trust_level: 2) }
let(:email_log) { EmailLog.new(reply_key: reply_key,
post: post,
post_id: post.id,
topic_id: deleted_topic.id,
email_type: 'user_posted',
user: replying_user,
user_id: replying_user.id,
to_address: replying_user_email
) }
before do
email_log.save
end
describe "should not create post" do
let!(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' }
let!(:email_raw) { fill_email(fixture_file("emails/valid_reply.eml"), replying_user_email, to) }
it "raises a TopicNotFoundError" do
expect { receiver.process }.to raise_error(Email::Receiver::TopicNotFoundError)
end
end
end
describe "posting reply as a inactive user" do
let(:reply_key) { raise "Override this in a lower describe block" }
let(:email_raw) { raise "Override this in a lower describe block" }
let(:to) { SiteSetting.reply_by_email_address.gsub("%{reply_key}", reply_key) }
let(:receiver) { Email::Receiver.new(email_raw) }
let(:topic) { Fabricate(:topic) }
let(:post) { Fabricate(:post, topic: topic, post_number: 1) }
let(:replying_user_email) { 'jake@adventuretime.ooo' }
let(:replying_user) { Fabricate(:user, email: replying_user_email, trust_level: 2, active: false) }
let(:email_log) { EmailLog.new(reply_key: reply_key,
post: post,
post_id: post.id,
topic_id: topic.id,
email_type: 'user_posted',
user: replying_user,
user_id: replying_user.id,
to_address: replying_user_email
) }
before do
email_log.save
end
describe "should not create post" do
let!(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' }
let!(:email_raw) { fill_email(fixture_file("emails/valid_reply.eml"), replying_user_email, to) }
it "raises a InactiveUserError" do
expect { receiver.process }.to raise_error(Email::Receiver::InactiveUserError)
end
end
end
describe "posting a new topic in a category" do
let(:category_destination) { raise "Override this in a lower describe block" }
let(:email_raw) { raise "Override this in a lower describe block" }
let(:allow_strangers) { false }
# ----
let(:receiver) { Email::Receiver.new(email_raw) }
let(:user_email) { 'jake@adventuretime.ooo' }
let(:user) { Fabricate(:user, email: user_email, trust_level: 2)}
let(:category) { Fabricate(:category, email_in: category_destination, email_in_allow_strangers: allow_strangers) }
before do
SiteSetting.email_in = true
user.save
category.save
end
describe "too_short.eml" do
let!(:category_destination) { 'incoming+amazing@appmail.adventuretime.ooo' }
let(:email_raw) {
fixture_file("emails/too_short.eml")
.gsub("TO", category_destination)
.gsub("FROM", user_email)
.gsub("SUBJECT", "A long subject that passes the checks")
}
it "does not create a topic if the post fails" do
before_topic_count = Topic.count
expect { receiver.process }.to raise_error(Email::Receiver::InvalidPost)
expect(Topic.count).to eq(before_topic_count)
end
it "invites everyone in the chain" do
expect { process(:cc) }.to change(Topic, :count)
emails = Topic.last.allowed_users.pluck(:email)
expect(emails.size).to eq(5)
expect(emails).to include("someone@else.com", "discourse@bar.com", "team@bar.com", "wat@bar.com", "42@bar.com")
end
end
def process_email(opts)
incoming_email = fixture_file("emails/valid_incoming.eml")
email = fill_email(incoming_email, opts[:from], opts[:to], opts[:body], opts[:subject], opts[:cc])
Email::Receiver.new(email).process
end
context "new topic in a category" do
describe "with a valid email" do
let(:reply_key) { "59d8df8370b7e95c5a49fbf86aeb2c93" }
let(:to) { SiteSetting.reply_by_email_address.gsub("%{reply_key}", reply_key) }
let(:user_email) { "test@test.com" }
let(:user) { Fabricate(:user, email: user_email, trust_level: 2)}
let(:post) { create_post(user: user) }
let(:valid_reply) {
reply = fixture_file("emails/valid_reply.eml")
fill_email(reply, user.email, to)
}
let(:receiver) { Email::Receiver.new(valid_reply) }
let(:email_log) { EmailLog.new(reply_key: reply_key,
post_id: post.id,
topic_id: post.topic_id,
user_id: post.user_id,
post: post,
user: user,
email_type: 'test',
to_address: user.email
) }
let(:reply_body) {
"I could not disagree more. I am obviously biased but adventure time is the
greatest show ever created. Everyone should watch it.
- Jake out" }
describe "with an email log" do
it "extracts data" do
expect { receiver.process }.to raise_error(Email::Receiver::EmailLogNotFound)
email_log.save!
receiver.process
expect(receiver.body).to eq(reply_body)
expect(receiver.email_log).to eq(email_log)
end
let!(:category) { Fabricate(:category, email_in: "category@bar.com", email_in_allow_strangers: false) }
it "raises a StrangersNotAllowedError when 'email_in_allow_strangers' is disabled" do
expect { process(:stranger_not_allowed) }.to raise_error(Email::Receiver::StrangersNotAllowedError)
end
end
describe "with a valid email from a different user" do
let(:reply_key) { SecureRandom.hex(16) }
let(:to) { SiteSetting.reply_by_email_address.gsub("%{reply_key}", reply_key) }
let(:user) { Fabricate(:user, email: "test@test.com", trust_level: 2)}
let(:post) { create_post(user: user) }
let!(:email_log) { EmailLog.create(reply_key: reply_key,
post_id: post.id,
topic_id: post.topic_id,
user_id: post.user_id,
post: post,
user: user,
email_type: 'test',
to_address: user.email) }
it "raises ReplyUserNotFoundError when user doesn't exist" do
reply = fill_email(fixture_file("emails/valid_reply.eml"), "unknown@user.com", to)
receiver = Email::Receiver.new(reply)
expect { receiver.process }.to raise_error(Email::Receiver::ReplyUserNotFoundError)
it "raises an InsufficientTrustLevelError when user's trust level isn't enough" do
SiteSetting.email_in_min_trust = 4
Fabricate(:user, email: "insufficient@bar.com", trust_level: 3)
expect { process(:insufficient_trust_level) }.to raise_error(Email::Receiver::InsufficientTrustLevelError)
end
it "raises ReplyUserNotMatchingError when user is not matching the reply key" do
another_user = Fabricate(:user, email: "existing@user.com")
reply = fill_email(fixture_file("emails/valid_reply.eml"), another_user.email, to)
receiver = Email::Receiver.new(reply)
expect { receiver.process }.to raise_error(Email::Receiver::ReplyUserNotMatchingError)
end
end
it "raises an InvalidAccess when the user is part of a readonly group" do
user = Fabricate(:user, email: "readonly@bar.com", trust_level: SiteSetting.email_in_min_trust)
group = Fabricate(:group)
describe "processes an email to a category" do
let(:to) { "some@email.com" }
group.add(user)
group.save
before do
SiteSetting.email_in = true
SiteSetting.email_in_min_trust = TrustLevel[4].to_s
end
it "correctly can target categories" do
Fabricate(:category, email_in_allow_strangers: false, email_in: to)
# no email in for user
expect{
process_email(from: "cobb@dob.com", to: "invalid@address.com")
}.to raise_error(Email::Receiver::BadDestinationAddress)
# valid target invalid user
expect{
process_email(from: "cobb@dob.com", to: to)
}.to raise_error(Email::Receiver::UserNotFoundError)
# untrusted
user = Fabricate(:user)
expect{
process_email(from: user.email, to: to)
}.to raise_error(Email::Receiver::UserNotSufficientTrustLevelError)
# trusted
user.trust_level = 4
user.save
process_email(from: user.email, to: to)
expect(user.posts.count).to eq(1)
# email too short
message = nil
begin
process_email(from: user.email, to: to, body: "x", subject: "this is my new topic title")
rescue Email::Receiver::InvalidPost => e
message = e.message
end
expect(e.message).to include("too short")
end
it "blocks user in restricted group from creating topic" do
to = "some@email.com"
restricted_user = Fabricate(:user, trust_level: 4)
restricted_group = Fabricate(:group)
restricted_group.add(restricted_user)
restricted_group.save
category = Fabricate(:category, email_in_allow_strangers: false, email_in: to)
category.set_permissions(restricted_group => :readonly)
category.set_permissions(group => :readonly)
category.save
expect{
process_email(from: restricted_user.email, to: to)
}.to raise_error(Discourse::InvalidAccess)
expect { process(:readonly) }.to raise_error(Discourse::InvalidAccess)
end
end
describe "processes an unknown email sender to category" do
let(:email_in) { "bob@bob.com" }
let(:user_email) { "#{SecureRandom.hex(32)}@foobar.com" }
let(:body) { "This is a new topic created\n\ninside a category ! :)" }
before do
SiteSetting.email_in = true
SiteSetting.allow_staged_accounts = true
end
it "rejects anon email" do
Fabricate(:category, email_in_allow_strangers: false, email_in: email_in)
expect {
process_email(from: user_email, to: email_in, body: body)
}.to raise_error(Email::Receiver::UserNotFoundError)
end
it "creates a topic for matching category" do
Fabricate(:category, email_in_allow_strangers: true, email_in: email_in)
process_email(from: user_email, to: email_in, body: body)
staged_account = User.find_by_email(user_email)
expect(staged_account).to be
expect(staged_account.staged).to be(true)
expect(staged_account.posts.order(id: :desc).limit(1).pluck(:raw).first).to eq(body)
end
end
describe "processes an unknown email sender to group" do
let(:incoming_email) { "foo@bar.com" }
let(:user_email) { "#{SecureRandom.hex(32)}@foobar.com" }
let(:body) { "This is a message to\n\na group ;)" }
before do
SiteSetting.email_in = true
SiteSetting.allow_staged_accounts = true
end
it "creates a message for matching group" do
Fabricate(:group, incoming_email: incoming_email)
process_email(from: user_email, to: incoming_email, body: body)
staged_account = User.find_by_email(user_email)
expect(staged_account).to be
expect(staged_account.name).to eq("Jake the Dog")
expect(staged_account.staged).to be(true)
post = staged_account.posts.order(id: :desc).first
expect(post).to be
expect(post.raw).to eq(body)
expect(post.topic.private_message?).to eq(true)
end
end
describe "supports incoming mail in CC fields" do
let(:incoming_email) { "foo@bar.com" }
let(:user_email) { "#{SecureRandom.hex(32)}@foobar.com" }
let(:body) { "This is a message to\n\na group via CC ;)" }
before do
SiteSetting.email_in = true
SiteSetting.allow_staged_accounts = true
end
it "creates a message for matching group" do
Fabricate(:group, incoming_email: incoming_email)
process_email(from: user_email, to: "some@email.com", body: body, cc: incoming_email)
staged_account = User.find_by_email(user_email)
expect(staged_account).to be
expect(staged_account.staged).to be(true)
post = staged_account.posts.order(id: :desc).first
expect(post).to be
expect(post.raw).to eq(body)
expect(post.topic.private_message?).to eq(true)
it "works" do
Fabricate(:user, email: "sufficient@bar.com", trust_level: SiteSetting.email_in_min_trust)
expect { process(:sufficient_trust_level) }.to change(Topic, :count)
end
end

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
spec/fixtures/emails/cc.eml vendored Normal file

Binary file not shown.

BIN
spec/fixtures/emails/chinese_reply.eml vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
spec/fixtures/emails/from_the_future.eml vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
spec/fixtures/emails/hebrew_reply.eml vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
spec/fixtures/emails/html_reply.eml vendored Normal file

Binary file not shown.

BIN
spec/fixtures/emails/inactive_sender.eml vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
spec/fixtures/emails/no_body.eml vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
spec/fixtures/emails/no_return_path.eml vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +0,0 @@
<p>Is there any reason the <em>old</em> candy can't be be kept in silos while the new candy
is imported into <em>new</em> silos?</p>
<p>The thing about candy is it stays delicious for a long time -- we can just keep
it there without worrying about it too much, imo.</p>
<p>Thanks for listening.</p>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
spec/fixtures/emails/readonly.eml vendored Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More